Compare commits

..

7 Commits

Author SHA1 Message Date
Дмитрий 6ce2f0058d fix(router-gate): session-lock skips readonly Bash (scope calibration)
The parallel-session-lock fired on every PreToolUse tool, blocking even
readonly Bash (git status/log/diff, cat, grep, ls) from a peer session.
The lock's purpose is to serialize concurrent FILE MUTATION on the same
worktree; readonly commands mutate nothing, so they are outside that scope.

isReadonlyBashEvent() reuses the router-gate Bash classifier (an allow-verdict
whose reason is readonly/reading), mirroring the LLM-judge readonly
calibration. main() short-circuits readonly Bash to allow without
acquiring/blocking. Mutating tools, git commit/push, dangerous Bash, and
every non-Bash tool still acquire/check the lock — same-worktree mutation
serialization is unchanged (scope fix, NOT a discipline drop).

TDD: +6 unit tests. Full tools-vitest 2038 passed / 2 skipped.
2026-06-01 07:46:26 +03:00
Дмитрий d35fefddd9 ci(a11y): bump Pa11y workflow Node 20 -> 22 (cspell@10 engine requirement)
The a11y (Pa11y live) PR check failed at "Install root JS deps": root `npm ci`
hits EBADENGINE because @cspell/cspell-*@10.0.0 require Node >=22.18.0 while the
workflow pinned Node 20. Pre-existing mismatch (cspell ^10 predates this branch
and fails identically on main), unrelated to the discipline-guard hook changes.
Node 22 satisfies both the repo engines (>=20) and cspell (>=22.18).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:00:05 +03:00
Дмитрий e56ddd6a1b fix(router-gate): coverage line honors cross-turn active skill (verify + remind)
Backlog item G. The `coverage:` line under-reported a skill chosen in a PRIOR turn:
enforce-coverage-verify credited channel=skill only if the Skill tool ran in the
CURRENT turn, so an honest `skill:X` continuation line was BLOCKED -> the controller
learned to under-report as direct/chain. Two-sided systemic fix, no weakening:

- enforce-coverage-verify: decide() also accepts skill:X when X was invoked anywhere
  earlier in THIS session (new priorSkillNames param; main() collects them via
  sessionToolUses). Still unforgeable -- a real Skill tool_use must exist in the
  transcript. The only residual is possibly-stale attribution, far better than the
  forced dishonest direct-reporting it replaces.
- enforce-prompt-injection: the §17 reminder now lists active skills carried over
  from earlier turns (read from the transcript) and tells the controller to report
  `coverage: skill:<name>` when work continues under one -- the proactive half, so
  the correct line is not merely allowed but prompted.

TDD: RED -> GREEN per behavior. tools-vitest 2032 passed / 2 skipped.
Plan docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md (item G).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:37:44 +03:00
Дмитрий 53407a77cd feat(router-gate): tdd-gate credits delegated (subagent) TDD + transcript write-deny
Closes the TDD-gate cross-actor gap: when a subagent (spawned by a Task in the
controller's current turn) writes the failing test and confirms RED, the
controller's subsequent production edit was falsely blocked because the gate only
scanned the controller's own turn. Net strengthening, no discipline weakened.

- Part 1 (enforce-runtime-write-deny): block the Write tool from any
  ~/.claude/projects/**/*.jsonl (session/subagent transcripts). Memory *.md there
  stays writable (never matches .jsonl$). Resolving normalizer defeats ./.. evasion.
  This makes the agent-<id>.jsonl that Part 2 trusts unforgeable (it was the last
  ungated write channel; Bash/PowerShell/Read gates already covered it).
- Part 2 (enforce-tdd-gate): decide() also credits a subagent's matching test edit
  + RED via a new subagentEntriesList. turnTaskAgentIds() reads the hex agentId from
  the harness-written Task tool_result (the controller cannot forge its own
  tool_result; hex-only match blocks "agentId: ../../x" path traversal).
  subagentTranscriptPaths() derives <dir>/<controller-session>/subagents/agent-<id>.jsonl.
  main() reads them best-effort (missing/unreadable -> no extra credit = stricter).

No new weakening: a delegated subagent doing real TDD is legitimate; the only
forgery vector (overwriting the agent jsonl) is closed by Part 1. Existing
controller-turn behaviour is preserved (empty subagent list == old logic).

OWNER (settings.json, Claude can't edit it): enforce-tdd-gate is already a
registered PreToolUse hook -> Part 2 goes live on merge. enforce-runtime-write-deny
must be registered on PreToolUse(Edit|Write|MultiEdit|NotebookEdit) for Part 1 to be live.

TDD: RED -> GREEN per behavior. tools-vitest 2027 passed / 2 skipped.
Backlog item C (=Z); plan docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:18:44 +03:00
Дмитрий 6577c04a1f fix(router-gate): session-lock hygiene — clearer block message + stale-lock prune
Closes the remaining parallel-session-lock remarks on top of the keying fix
(7a469dc9), with NO weakening of same-worktree serialization:

- D: the block message now identifies the holder by its STABLE session_id and
  marks the recorded pid as transient ("may change between attempts"). Chasing
  the pid is what led to closing the wrong session. Decision logic is unchanged
  (text only) — existing /pid N/ triage assertion still holds.
- B: pruneStaleLocks() best-effort deletes leaked lock files that are ALREADY
  stale by the shared isStale() definition (now exported from the pure module —
  single source of truth). Active within-TTL locks are never touched, so the
  serialization guarantee is not weakened. Wired into the PreToolUse branch of
  main(), wrapped so hygiene can never break the gate (fail-open).
- C (no code): release-on-SessionEnd needs only a settings.json registration
  (owner action) — the existing !tool_name branch already releases. Documented
  in the plan. Until then, leaked locks self-heal via B + the 5-min TTL takeover.

TDD: RED -> GREEN per behavior. tools-vitest 2014 passed / 2 skipped.
Backlog items B/C/D; plan docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:43:03 +03:00
Дмитрий 7a469dc913 fix(router-gate): key session-lock by session work-tree root, not hook cwd
enforce-parallel-session-lock keyed the lock on the hook's process.cwd(),
which collapses to the main repo dir after a session resume — so sessions in
DIFFERENT git worktrees shared one lock and false-blocked each other (observed:
a brainrepo-worktree session blocked launching agents by a discipline-guard
session). New resolveWorkspacePath() keys on the session's stable cwd
(event.cwd) resolved to the git work-tree root (git -C <cwd> rev-parse
--show-toplevel), with fallback to process.cwd() so behaviour never regresses
when event.cwd is absent. Same-worktree concurrency stays serialized
(unchanged) — discipline not weakened; only cross-worktree false-blocks fixed.

TDD: RED (5 resolveWorkspacePath cases) -> GREEN -> tools-vitest 2003 passed /
2 skipped. Backlog item F; plan
docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:02:32 +03:00
Дмитрий be4e1a6123 feat(router-gate): whitelist npm ci in SAFE_EXACT (worktree dep restore)
`npm ci` does a clean install strictly from the committed lockfile
(deterministic, no version drift) — needed to restore junction node_modules
in a fresh worktree. Distinct from `npm install`/`npm i`, which stay
hard-blacklisted because they can pull new/updated versions; the blacklist
runs before the whitelist, so they remain blocked. Word boundary after `ci`
prevents `npm cider`-style prefix matches; chain semantics still block
`npm ci && <mutating>`.

TDD: RED (3 allow-cases failed default-deny) -> GREEN (/^npm\s+ci\b/) ->
tools-vitest 1998 passed / 2 skipped (2000). Backlog item A; plan
docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:46:58 +03:00
41 changed files with 921 additions and 1756 deletions
+15 -140
View File
@@ -38,42 +38,12 @@
},
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell|Skill|Task",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-llm-judge-per-tool.mjs",
"timeout": 30
}
]
},
{
"matcher": "Read|Grep|Glob|LS|TodoWrite|AskUserQuestion|Edit|Write|MultiEdit|NotebookEdit|Bash|Skill|Task|EnterPlanMode",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-safe-baseline-metering.mjs",
"timeout": 10
}
]
},
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-runtime-write-deny.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const pd=process.env.CLAUDE_PROJECT_DIR||''; const path=require('path'); if (f && pd && path.resolve(f) === path.resolve(pd, 'CLAUDE.md')) { process.stderr.write('\\n[hook] WARNING: Direct edit of root CLAUDE.md detected. Per CLAUDE.md Р’В§5 Р С—.10, prefer /claude-md-management:revise-claude-md or /claude-md-management:claude-md-improver. If invoked via that skill, this warning is informational.\\n'); }\""
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const pd=process.env.CLAUDE_PROJECT_DIR||''; const path=require('path'); if (f && pd && path.resolve(f) === path.resolve(pd, 'CLAUDE.md')) { process.stderr.write('\\n[hook] WARNING: Direct edit of root CLAUDE.md detected. Per CLAUDE.md §5 п.10, prefer /claude-md-management:revise-claude-md or /claude-md-management:claude-md-improver. If invoked via that skill, this warning is informational.\\n'); }\""
}
]
},
@@ -82,7 +52,7 @@
"hooks": [
{
"type": "command",
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
}
]
},
@@ -176,6 +146,16 @@
}
]
},
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "node tools/askuser-cosmetic-detector.mjs",
"timeout": 5
}
]
},
{
"matcher": "mcp__.*",
"hooks": [
@@ -195,71 +175,6 @@
"timeout": 5
}
]
},
{
"matcher": "Workflow",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-workflow-gate.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash|Task",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-decomposition-detector.mjs",
"timeout": 8
},
{
"type": "command",
"command": "node tools/enforce-parallel-session-lock.mjs",
"timeout": 3
}
]
},
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "node tools/askuser-cosmetic-detector.mjs",
"timeout": 5
}
]
},
{
"matcher": "Read|Grep|Glob|LS|TodoWrite|AskUserQuestion|Edit|Write|MultiEdit|NotebookEdit|Bash|Skill|Task|EnterPlanMode",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-safe-baseline-metering.mjs",
"timeout": 10
}
]
},
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-runtime-write-deny.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash|Task",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-parallel-session-lock.mjs",
"timeout": 3
}
]
}
],
"PostToolUse": [
@@ -277,7 +192,7 @@
"hooks": [
{
"type": "command",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md Р’В§5 Р С—.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md §5 п.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
}
]
},
@@ -291,7 +206,7 @@
},
{
"type": "command",
"command": "echo ok",
"command": "node tools/enforce-rationalization-audit.mjs",
"timeout": 5
}
]
@@ -301,7 +216,7 @@
"hooks": [
{
"type": "command",
"command": "echo ok",
"command": "node tools/enforce-rationalization-audit.mjs",
"timeout": 5
}
]
@@ -315,29 +230,9 @@
"timeout": 10
}
]
},
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-askuser-answer-parser.mjs",
"timeout": 2
}
]
}
],
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-llm-judge-response-scan.mjs",
"timeout": 30
}
]
},
{
"hooks": [
{
@@ -382,15 +277,6 @@
"timeout": 10
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/enforce-parallel-session-lock.mjs",
"timeout": 3
}
]
}
],
"UserPromptSubmit": [
@@ -423,17 +309,6 @@
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "node tools/enforce-parallel-session-lock.mjs",
"timeout": 3
}
]
}
]
}
}
+2 -2
View File
@@ -21,10 +21,10 @@ jobs:
extensions: pdo, pdo_pgsql, redis, mbstring, intl, bcmath
coverage: none
- name: Setup Node 20
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'
- name: Install root JS deps
+1 -5
View File
File diff suppressed because one or more lines are too long
+5 -439
View File
@@ -5,7 +5,6 @@
"packages": {
"": {
"dependencies": {
"keytar": "*",
"lucide-vue-next": "^1.0.0"
},
"devDependencies": {
@@ -40,9 +39,6 @@
"vue-tsc": "^3.2.8",
"vuedraggable": "^4.1.0",
"vuetify": "^3.12.5"
},
"optionalDependencies": {
"keytar": "^7.9.0"
}
},
"node_modules/@acemir/cssom": {
@@ -4226,27 +4222,6 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -4267,18 +4242,6 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"optional": true,
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -4312,31 +4275,6 @@
"node": ">=8"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true,
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@@ -4443,13 +4381,6 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC",
"optional": true
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4721,32 +4652,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -4828,7 +4733,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -4953,16 +4858,6 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"optional": true,
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -5375,16 +5270,6 @@
"node": ">=0.10.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"optional": true,
"engines": {
"node": ">=6"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -5685,13 +5570,6 @@
"node": ">=18.3.0"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT",
"optional": true
},
"node_modules/fs-extra": {
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
@@ -5821,13 +5699,6 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT",
"optional": true
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@@ -6296,27 +6167,6 @@
"node": ">= 14"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -6344,18 +6194,11 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC",
"optional": true
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"devOptional": true,
"dev": true,
"license": "ISC"
},
"node_modules/is-docker": {
@@ -6717,25 +6560,6 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/keytar": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz",
"integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^4.3.0",
"prebuild-install": "^7.0.1"
}
},
"node_modules/keytar/node_modules/node-addon-api": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
"integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
"license": "MIT",
"optional": true
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -7466,19 +7290,6 @@
"node": ">= 0.6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -7499,7 +7310,7 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -7522,13 +7333,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT",
"optional": true
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -7582,13 +7386,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT",
"optional": true
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -7596,19 +7393,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.92.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
@@ -7670,16 +7454,6 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"optional": true,
"dependencies": {
"wrappy": "1"
}
},
"node_modules/oniguruma-parser": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
@@ -8069,34 +7843,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -8151,17 +7897,6 @@
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"optional": true,
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -8203,47 +7938,6 @@
],
"license": "MIT"
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"optional": true,
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/rc/node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"optional": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -8628,27 +8322,6 @@
"node": ">=6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true
},
"node_modules/sass": {
"version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz",
@@ -9058,7 +8731,7 @@
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"devOptional": true,
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -9140,53 +8813,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true,
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -9307,16 +8933,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"optional": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -9479,36 +9095,6 @@
"node": ">=16.0.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -9653,19 +9239,6 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -9882,7 +9455,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/utils-merge": {
@@ -10533,13 +10106,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC",
"optional": true
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
-3
View File
@@ -51,8 +51,5 @@
},
"dependencies": {
"lucide-vue-next": "^1.0.0"
},
"optionalDependencies": {
"keytar": "^7.9.0"
}
}
-7
View File
@@ -38,11 +38,4 @@ describe('DealsFilters', () => {
});
expect(w.find('[data-testid="clear-filters-btn"]').exists()).toBe(true);
});
it('поле поиска имеет доступное имя (label) для скринридера', () => {
const w = mount(DealsFilters, { props: baseProps, global: { plugins: [vuetify] } });
const label = w.find('[data-testid="filter-search-phone"] label');
expect(label.exists()).toBe(true);
expect(label.text()).toContain('Поиск по телефону');
});
});
-7
View File
@@ -47,11 +47,4 @@ describe('KanbanColumn.vue', () => {
expect(wrapper.emitted('openDeal')).toBeTruthy();
expect(wrapper.emitted('openDeal')?.[0]).toEqual([dealsForNew[0].id]);
});
// Контраст column-total на ивори чинится в scoped CSS (var(--accent) → нейтральный #4a463f),
// jsdom scoped-стили не вычисляет → числовую проверку контраста делает Pa11y. Здесь — структурный якорь.
it('column-total отрисован для пустой колонки', () => {
const wrapper = factory({ status, deals: [] });
expect(wrapper.find('.column-total').exists()).toBe(true);
});
});
-10
View File
@@ -49,14 +49,4 @@ describe('ProjectCard', () => {
});
expect(wrapper.text()).toContain('На паузе');
});
it('чип типа сигнала — flat-вариант с классом signal-chip (a11y контраст)', () => {
const wrapper = mount(ProjectCard, {
global: { plugins: [vuetify] },
props: { project: baseProject, selected: false },
});
const chip = wrapper.find('.signal-chip');
expect(chip.exists()).toBe(true);
expect(chip.classes()).toContain('v-chip--variant-flat');
});
});
-20
View File
@@ -4,26 +4,6 @@
# A4 design-tooling integration (v2.8 / v3.8 / v1.22)
iconify
# lead-region-resolution spec/plan (DaData + Россвязь, 2026-05-29)
dadata
rossvyaz
unmappable
mnp
incrby
deyatelnost
resurs
numeracii
vypiska
reestra
sistemy
plana
маппингах
реконсиляция
сетап
хелперы
регэкспом
резолвом
# Бренд и термины проекта
лидерра
liderra
+2 -7
View File
@@ -31,14 +31,9 @@ paths:
keyset (cursor) — O(1) глубины; offset-based — backward-совместимость.
При count_only=true возвращает только {"total": N} без строк.
parameters:
- name: status_in
- name: status_in[]
in: query
description: >
Фильтр по статусам (можно несколько). На проводе сериализуется
Laravel array-binding: status_in[]=NEW&status_in[]=WON. Имя параметра
в спецификации — без скобок: ключи свойств MCP-инструмента обязаны
матчить ^[a-zA-Z0-9_.-]{1,64}$ (скобки запрещены, иначе Anthropic
tools-схема падает с 400).
description: Фильтр по статусам (можно несколько)
required: false
schema:
type: array
+1 -4
View File
@@ -1,10 +1,7 @@
{
"2026-05": {
"WIN_USER_PATH": 206,
"WIN_USER_PATH": 123,
"IPV4": 1,
"RU_PHONE": 1
},
"2026-06": {
"WIN_USER_PATH": 91
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"last_read_at": "2026-05-30T12:32:49.927Z",
"read_count_last_period": 6,
"last_read_at": "2026-05-27T00:53:33.490Z",
"read_count_last_period": 5,
"period_start": "2026-05-19T00:00:00+03:00"
}
+60 -29
View File
@@ -1,22 +1,22 @@
# Brain Status (auto-generated)
Last updated: 2026-06-08T14:07:33.978Z
Last updated: 2026-05-30T13:11:39.164Z
| Контролёр | Состояние | Детали |
|---|---|---|
| C1 L1-watcher | ✅ | [l1-watcher] OK — 0 drift |
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 1 week(s) ago |
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
| C4 Сигнальный статус | ✅ | This file (self-reference) |
| C5 Observer-coverage | | 666 episode(s) this month · Stop-hook + post-commit OK |
| C5 Observer-coverage | ⚠️ | 752 episode(s) this month · Stop-hook + post-commit OK · 20 missed activation(s) — see /brain-retro |
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync |
## Метрики (информационные, не алерты)
- Observer evidence: 666 episodes this month, 0 observer_error markers, 88 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 666
- Last /brain-retro: 9 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
- Observer evidence: 752 episodes this month, 0 observer_error markers, 186 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 613
- Last /brain-retro: 0 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 20. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
## Метрики дисциплины
@@ -24,14 +24,16 @@ Baseline дисциплины роутера (этап 2 router discipline overh
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|---|---|---|---|
| planning | 96 | 10.4% | 13.5% |
| analysis | 33 | 6.1% | 0.0% |
| bugfix | 26 | 15.4% | 19.2% |
| feature | 24 | 12.5% | 4.2% |
| analysis | 34 | 23.5% | 14.7% |
| planning | 25 | 12.0% | 16.0% |
| bugfix | 25 | 24.0% | 20.0% |
| feature | 19 | 10.5% | 0.0% |
| cleanup | 6 | 0.0% | 0.0% |
| refactor | 1 | 0.0% | 0.0% |
Router step distribution: 1: 321, 2: 261, 3: 18, 5: 55
Router step distribution: 1: 330, 2: 279, 3: 67, 5: 67
Boundaries applied (ADR / границы): 7 of 655 эпизодов (1.1%).
Boundaries applied (ADR / границы): 76 of 743 эпизодов (10.2%).
## Активные многоэтапные проекты
@@ -43,16 +45,22 @@ Boundaries applied (ADR / границы): 7 of 655 эпизодов (1.1%).
## Длинные сессии
Ни одной сессии с >50 ходов сегодня (UTC). ✅
⚠️ Сегодня (2026-05-30 UTC) есть сессии с 50 ходов — корреляция с падением дисциплины роутинга (retro #5 candidate B).
| session_id | макс. ход | % regulated | последний эпизод |
|---|---|---|---|
| `52b2b52d` | 75 | 3% | 2026-05-30T11:45:39.213Z |
Long sessions correlate with discipline drift. Если % regulated просел в текущей сессии — рассмотри перезапуск.
## Стоимость месяца
| Компонент | Токены (in/out) | USD |
|---|---|---|
| Classifier (Sonnet 4.6) | 41653/183234 | $2.87 |
| Classifier (Sonnet 4.6) | 12550/86494 | $1.34 |
| Self-assessment (Sonnet 4.6) | 0/0 | $0.00 |
| Reviewer (Opus 4.7 + fallback) | 0/0 | $0.00 |
| **Итого** | | **$2.87** |
| **Итого** | | **$1.34** |
## Аномалии классификатора
@@ -65,20 +73,50 @@ Episodes since last run: 542 / threshold: 10
## Reviewer: субагент vs fallback
0 эпизодов проверено из 666.
0 эпизодов проверено из 752.
## Reviewer findings
(нет проверенных эпизодов в текущем периоде)
Проверено: 372 эпизодов. **69 actionable** (wrong_skill + wrong_chain_order).
### error_root_cause
| cause | count |
|---|---:|
| n/a | 271 |
| wrong_skill | 55 |
| external_failure | 28 |
| wrong_chain_order | 14 |
| wrong_tool | 4 |
### Топ alternative_better
| recommended | count |
|---|---:|
| #19 | 18 |
| #25 | 15 |
| #34 | 8 |
| #18 | 8 |
| #33 | 3 |
### node_quality
| judgment | count |
|---|---:|
| disputable | 207 |
| correct | 120 |
| wrong_node | 40 |
| underkill | 3 |
| overkill | 2 |
## Использование override-фраз
⚠️ Превышен порог override-использования сегодня (≥5/день)
| Фраза | За всё время | За сегодня |
|---|---|---|
| `recovery` | 2302 | 0 |
| `без скилов` | 507 | 0 |
| `recovery` | 2302 | 23 ⚠️ |
| `без скилов` | 507 | 40 ⚠️ |
| `ремонт инфраструктуры` | 331 | 0 |
| `срочно` | 225 | 0 |
| `memory dump` | 46 | 0 |
@@ -87,14 +125,7 @@ Episodes since last run: 542 / threshold: 10
## System Health
Топ-3 процессов с CPU > 1ч:
| PID | Имя | CPU-время | Возраст |
|---|---|---|---|
| 3916 | MsMpEng | 1.99ч | NaNч |
| 15260 | Code | 1.71ч | 0.0ч |
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
Долго работающих процессов нет (порог CPU > 1ч).
## Алерт-индикаторы
File diff suppressed because one or more lines are too long
@@ -15,13 +15,11 @@
## File Structure
**Create:**
- `app/app/Services/Audit/AuditChainConfig.php` — shared конфиг 6 audit-таблиц (columns + partition_clause). Public const `TABLES`. Helper `rowExpression(string $table): string` для построения `ROW(...)` выражения.
- `app/tests/Unit/Services/Audit/AuditChainConfigTest.php` — unit-тесты на конфиг (полнота 6 таблиц, корректность ROW expression).
- `docs/incidents/2026-06-XX-activity-log-y2026-m05-cleanup-handoff.md` — handoff для прод-выкатки финального cleanup'а (Task 7).
**Modify:**
- `app/app/Console/Commands/VerifyAuditChains.php:98-238` — заменить private `TABLE_CONFIG` const на чтение из `AuditChainConfig::TABLES`. Поведение не меняется (regression-safe refactor).
- `app/app/Console/Commands/AuditRebuildChain.php:40-218` — заменить private `COLUMN_CONFIG` на `AuditChainConfig`, переписать `handle()` SQL под per-partition_clause logic (через `LAG OVER`).
- `app/tests/Feature/Audit/AuditRebuildChainTest.php` — добавить 3 новых сценария (multi-tenant / BYPASSRLS table / single-row partition); существующие тесты должны продолжать проходить.
@@ -32,7 +30,6 @@
### Task 1: Создать shared AuditChainConfig
**Files:**
- Create: `app/app/Services/Audit/AuditChainConfig.php`
- Test: `app/tests/Unit/Services/Audit/AuditChainConfigTest.php`
@@ -217,7 +214,6 @@ git commit -m "feat(audit): extract AuditChainConfig shared TABLE config (ADR-01
### Task 2: Перевести VerifyAuditChains на shared config (regression-safe refactor)
**Files:**
- Modify: `app/app/Console/Commands/VerifyAuditChains.php:96-238` (заменить private const на чтение `AuditChainConfig::TABLES`)
- Test: `app/tests/Feature/Audit/AuditChainRaceConditionTest.php` (existing — должен продолжать проходить)
@@ -275,7 +271,6 @@ git commit -m "refactor(audit): VerifyAuditChains использует shared Au
### Task 3: Failing tests для per-tenant rebuild
**Files:**
- Modify: `app/tests/Feature/Audit/AuditRebuildChainTest.php` (add 3 scenarios — multi-tenant / BYPASSRLS / single-row)
- [ ] **Step 1: Добавить multi-tenant test (failing)**
@@ -397,7 +392,6 @@ git commit -m "test(audit): failing tests для per-tenant rebuild (ADR-018, RE
### Task 4: Реализовать per-tenant rebuild через LAG OVER
**Files:**
- Modify: `app/app/Console/Commands/AuditRebuildChain.php` (целиком переписать `handle()` + удалить `COLUMN_CONFIG` + использовать `AuditChainConfig`)
- [ ] **Step 1: Переписать AuditRebuildChain**
@@ -573,7 +567,6 @@ git commit -m "fix(audit): AuditRebuildChain per-tenant LAG OVER (ADR-018, close
### Task 5: Активировать ADR-018 Enforcement rule
**Files:**
- Modify: `docs/adr/ADR-018-audit-chain-per-tenant-semantics.md` (Enforcement-блок — снять «активируется после имплементации» note + проверить что rule срабатывает)
- [ ] **Step 1: Обновить Enforcement-блок**
@@ -654,7 +647,6 @@ git commit -m "style(audit): pint auto-fix на shared config + rebuild rewrite"
### Task 7: Handoff для прод-выкатки cleanup'а activity_log_y2026_m05
**Files:**
- Create: `docs/incidents/2026-05-29-audit-rebuild-per-tenant-cleanup-handoff.md`
- [ ] **Step 1: Создать handoff-док**
@@ -5,7 +5,6 @@
**Goal:** Remove 5 obsolete v3.9 enforcement hooks and register all 12 active router-gate v4 hooks in `.claude/settings.json` in block-mode, creating 5 thin wrappers for pure modules that still need them.
**Architecture:** Three layers:
1. **Pure modules** in `tools/<name>.mjs` — already created by streams A-E.
2. **Thin `enforce-<name>.mjs` wrappers** — stdin event → pure module `decide()``exitDecision`. Pattern lifted from existing `tools/enforce-router-gate.mjs:183-204`.
3. **`.claude/settings.json` registration** — matcher + command path + timeout. Block-mode means `exitDecision({ block: true })` exits with code 2 stopping the originating tool call.
@@ -13,7 +12,6 @@
**Tech Stack:** Node.js ESM (`.mjs`), `vitest` (jsdom env), `lefthook` pre-commit, `.claude/settings.json` schema `https://json.schemastore.org/claude-code-settings.json`.
**Reference helpers** (already present in `tools/enforce-hook-helpers.mjs`):
- `readStdin()` — read PreToolUse/PostToolUse/Stop event JSON.
- `parseEventJson(raw)` — safe JSON.parse with `{}` fallback.
- `readTranscript(event.transcript_path)` — load JSONL.
@@ -112,7 +110,6 @@ Expected: line with sha + `refs/heads/backup-pre-v4-cleanup`.
The vocab-based override system is fully removed in v4 (universal vocab removal per spec §4.2). Existing call sites in deleted hooks go away in Task 7; other callers (`enforce-verify-before-push.mjs`, `enforce-tdd-gate.mjs`, `enforce-memory-coverage.mjs`, `enforce-branch-switch.mjs`) still import `findOverride` / `findOverrideAttempt` / `loadOverrideVocab`. We keep these symbols as permanent stubs so non-deleted hooks keep building.
**Files:**
- Modify: `tools/enforce-hook-helpers.mjs:197-249` (functions `loadOverrideVocab`, `findOverride`, `findOverrideAttempt`)
- Modify: `tools/enforce-hook-helpers.test.mjs` (drop vocab-dependent assertions, add stub contract tests)
@@ -202,7 +199,6 @@ Wraps `tools/todowrite-skill-verifier.mjs::verifyClaims + hardSyncCheck`. Fires
**Pattern reference:** `tools/enforce-router-gate.mjs:183-207` (main() shape, fail-CLOSE behaviour). For Stop hooks we use fail-open (`block: false`) because false-positive Stop block would freeze sessions.
**Files:**
- Create: `tools/enforce-todowrite-skill-verifier.mjs`
- Create: `tools/enforce-todowrite-skill-verifier.test.mjs`
@@ -337,7 +333,6 @@ git commit -m "feat(router-gate-v4): enforce-todowrite-skill-verifier (Stop hook
Wraps `tools/tdd-real-test-verifier.mjs::verifyRealTest`. Fires on Edit/Write of a `*.test.*` or `*.spec.*` file. If the test content lacks `expect(...)` / `it(...)` / `test(...)` or covers none of the prod files edited in this session, blocks.
**Files:**
- Create: `tools/enforce-tdd-real-test-verifier.mjs`
- Create: `tools/enforce-tdd-real-test-verifier.test.mjs`
@@ -484,7 +479,6 @@ git commit -m "feat(router-gate-v4): enforce-tdd-real-test-verifier (PreToolUse
Wraps `tools/self-debrief-detector.mjs::detectSelfDebrief`. Fires on mutating tools (Edit|Write|MultiEdit|Bash). Reads transcript; if last controller text matches self-debrief patterns and no `self-retrospect` / `brain-retro` Skill invoked recently — block.
**Files:**
- Create: `tools/enforce-self-debrief-detector.mjs`
- Create: `tools/enforce-self-debrief-detector.test.mjs`
@@ -612,7 +606,6 @@ Wraps `tools/mcp-tool-classifier.mjs::classifyMcpTool`. Fires on any `mcp__*` to
**Pre-step:** Inspect `tools/mcp-tool-classifier.mjs` exported function names (`classifyMcpTool` vs `classify` vs other) — adjust import below if name differs.
**Files:**
- Create: `tools/enforce-mcp-classification.mjs`
- Create: `tools/enforce-mcp-classification.test.mjs`
@@ -716,7 +709,6 @@ Wraps `tools/decomposition-detector.mjs::detectDecomposition`. Fires on mutating
**Pre-step:** Inspect `tools/decomposition-detector.mjs` for the actual function name and signature; adapt below.
**Files:**
- Create: `tools/enforce-decomposition-detector.mjs`
- Create: `tools/enforce-decomposition-detector.test.mjs`
@@ -823,7 +815,6 @@ git commit -m "feat(router-gate-v4): enforce-decomposition-detector (PreToolUse
## Task 7: Delete 5 v3.9 hooks and the vocab file
**Files (delete):**
- `tools/enforce-chain-recommendation.mjs`
- `tools/enforce-chain-recommendation.test.mjs`
- `tools/enforce-classifier-match.mjs`
@@ -856,7 +847,6 @@ git rm tools/enforce-override-vocab.json
- [ ] **Step 3: Run full vitest tools suite (must still pass — no orphan references)**
Run:
```
npx vitest run tools/ \
--exclude='**/worktrees/**' \
@@ -864,7 +854,6 @@ npx vitest run tools/ \
--exclude='**/subagent-prompt-prefix*' \
--exclude='**/llm-judge.integration*'
```
Expected: all PASS. If any failure references a deleted file — it's a stale import; fix that file by removing the dead import.
- [ ] **Step 4: Commit**
@@ -886,13 +875,11 @@ Deleted hooks superseded by v4 architecture (spec §4 behavioural pivot):
## Task 8: Update `.claude/settings.json` — remove 5 v3.9 regs, add 12 v4 regs in block-mode
**Files:**
- Modify: `.claude/settings.json`
**Plan:** Read the current file (already done at planning time — see baseline below), then apply edits via multiple `Edit` tool calls because `settings.json` is JSON (no comments allowed) and the changes are scattered across the `hooks.PreToolUse`, `hooks.PostToolUse`, and `hooks.Stop` arrays.
**Baseline (current state, lines 39262):** five v3.9 hook blocks present at:
- PreToolUse[3] (lines 6978) — `enforce-chain-recommendation` — REMOVE
- PreToolUse[4] (lines 7988) — `enforce-override-limit` — REMOVE
- PreToolUse[7] (lines 119128) — `enforce-semgrep-security` — REMOVE
@@ -1058,7 +1045,6 @@ Stream G of router-gate v4 deployment, last step before user-run smokes."
- [ ] **Step 1: Full vitest tools suite**
Run:
```
npx vitest run tools/ \
--exclude='**/worktrees/**' \
@@ -40,7 +40,6 @@
### Task 1: RED tests for skill-body skip + negative tests for non-skill `isMeta`
**Files:**
- Modify: `tools/enforce-hook-helpers.test.mjs` — add 3 cases at end of `describe('lastTurnEntries / ...')` block.
- [ ] **Step 1:** Add a new `it()` block "lastTurnEntries skips skill body injections (isMeta + sourceToolUseID)" that constructs an entries array `[user-prompt, assistant+SkillToolUse, skillBody(isMeta=true, sourceToolUseID), assistant+follow-up]` and asserts `lastTurnEntries(entries)` returns starting from `user-prompt` (NOT from skill body).
@@ -54,7 +53,6 @@
### Task 2: Implement skill-body skip in lastTurnEntries
**Files:**
- Modify: `tools/enforce-hook-helpers.mjs` lines 100-115 (`lastTurnEntries` body).
- [ ] **Step 1:** In the back-walk loop, before checking `e.message.role === 'user'`, add: `if (e && e.isMeta === true && typeof e.sourceToolUseID === 'string') continue;` — this skips skill-body injections (isMeta + tool-spawned) while keeping all other `isMeta:true` cases as valid turn boundaries.
@@ -66,7 +64,6 @@
### Task 3: Commit
**Files:**
- Commit message in `.scratch/sibling-lastturn-fix-msg.txt`.
- [ ] **Step 1:** Pre-write approval records for:
@@ -0,0 +1,144 @@
# Discipline-guard backlog — router-gate `tools/enforce-*.mjs`
**Worktree:** `.claude/worktrees/discipline-guard` (branch `worktree-discipline-guard`).
**Date:** 2026-05-31. Owner-authorized backlog after quirk-2 + 1A closure (commit `b0cd18d7`).
## Context (already done — do NOT redo)
- **Quirk 2** — redirect detector is quote-aware (`stripQuotedSpans` in `tools/enforce-router-gate.mjs`): `>`/`2>` inside quotes no longer false-blocks. Commit `b0cd18d7`.
- **1A** — removed advertising of dead override phrases (`findOverride` is a v4 stub) from `enforce-prompt-injection` + verify-before-push / coverage-verify / memory-coverage / tdd-gate. Locked by negative tests. Same commit.
- Marketing MCP servers cut from `.mcp.json` (commit `63100dec`).
## Deliberately NOT doing (these are defense lines, not bugs)
- Calibration 6 of the judge (reading chat context) — weakens in-session defense.
- Quirk 3 (loosen exact-match of git approval) — that exact-match is an anti-injection property.
## Backlog (by priority)
### A. `npm ci` in router-gate whitelist (`SAFE_EXACT` in `tools/enforce-router-gate.mjs`) ← current
Restoring locked dependencies is safe and closes worktree-setup friction. `npm ci` installs
exactly the committed lockfile (deterministic, no version drift) — unlike `npm install`/`npm i`,
which stay hard-blacklisted because they can pull new/updated versions.
**TDD:**
1. RED — new describe block in `tools/enforce-router-gate.test.mjs`: allow `npm ci`,
`npm ci --no-audit`, `npm ci --prefer-offline`; still block `npm install`/`npm i`/
`npm install foo`/`npm i foo` (hard-blacklist), `npm cider` (word boundary → default-deny),
`npm ci && rm x` (chain mutating).
2. GREEN — add `/^npm\s+ci\b/` to `SAFE_EXACT` with rationale comment. `\b` prevents
`npm cider`-style prefix matches. Blacklist runs before whitelist, so `npm install`/`npm i`
stay blocked (the `i`-alternative needs `i` right after the space; `npm ci` has `c` there).
3. tools-vitest full run (also the push sentinel).
4. Commit via AskUserQuestion (label = exact command).
### B. Cosmetic path strings in gate messages
`c:/` vs `/c/`, unexpanded `$env:` in gate messages. Polish only.
### F. Parallel-session-lock false cross-worktree collision (2026-05-31, owner-raised)
Symptom: a session in worktree `discipline-guard` was blocked by
`enforce-parallel-session-lock` (held by another session `7f6efd48`, pid changed
12552→19044 across attempts → holder still active; pid is the transient hook-node pid,
session_id is the stable identity).
**Investigation (read-only):**
- Lock keyed by `computeWorkspaceHash(process.cwd())` = md5(cwd).slice(0,12); file
`~/.claude/runtime/session-lock-<hash>.json`; release only on Stop; TTL 5 min.
- 9 lock files accumulated → stale files leak when a session closes without a clean Stop.
- `enforce-branch-switch` read branch "worktree-discipline-guard" via
`git branch --show-current` from `process.cwd()` → the hook's cwd IS the worktree →
**keying is already per-worktree** (NOT coarse main-dir). So the holder shared this
worktree's hash → genuine same-worktree concurrency, the lock working as designed —
NOT a false positive. Do NOT re-key (would weaken same-tree serialization).
**Genuinely-fixable part (no weakening):** leaked lock on close-without-Stop blocks the next
same-worktree session for up to TTL. Fix: release on SessionEnd (not only Stop) + prune
stale lock files on acquire. Ground-truth the lock JSON before coding.
**Closure (2026-05-31).** All keying/hygiene/UX parts done, no discipline weakened:
- **A — keying by worktree root** (`resolveWorkspacePath`, commit `7a469dc9`): keys the
lock on the session's stable `event.cwd` → git toplevel, not the volatile hook
`process.cwd()` (which collapses to main on resume → cross-worktree false-blocks).
Same-worktree serialization unchanged; fallback to `process.cwd()` if `event.cwd` absent.
- **D — clearer block message**: identifies the holder by its STABLE `session_id`; marks
the recorded pid as transient ("may change between attempts"). Chasing the pid was what
led to closing the wrong session. Logic untouched (text only).
- **B — `pruneStaleLocks`**: best-effort delete of leaked lock files that are ALREADY
stale by the shared `isStale()` (now exported — single source of truth). Active
within-TTL locks are never touched → serialization not weakened. Wired into the
PreToolUse branch of `main()`, wrapped so hygiene can never break the gate.
- **C — release on SessionEnd**: NO new code. The existing `!event.tool_name` branch
already releases. To make release fire on session end (not only on Stop turns),
**OWNER ACTION in `.claude/settings.json`**: add `enforce-parallel-session-lock.mjs`
to the `SessionEnd` hook array (it already runs on `Stop`). Pure config; Claude cannot
edit settings.json. Until added, leaked locks are still self-healing via B (prune) +
the 5-min TTL takeover — so this is a reliability nicety, not a correctness gap.
- **E/F — live**: fix is on branch `worktree-discipline-guard`; the live hook executes
from `tools/` on **main**, so it is active only after merge to main. Runtime
effectiveness of A depends on the PreToolUse payload carrying `cwd`; if absent, the
safe fallback = prior behavior (no regression). Verify on main.
### C. TDD-gate cross-actor — chosen: **Z** (full, 2026-05-31; on hold behind F)
`enforce-tdd-gate` does not see test edits made by a subagent (scans only the controller's
own turn; subagent test edit + RED live in `agent-<id>.jsonl`). **Z = Part 1 (close the
projects/ Write hole — verified prerequisite) then Part 2 (read subagent transcript bound to
a Task in this turn).** Condition 1 verified VIOLATED (no Write-tool gate covers
`~/.claude/projects/`), so Variant 1 alone would weaken — safe only bundled with Part 1.
**Closure (2026-05-31, TDD, no discipline weakened — net strengthening):**
- **Part 1**`enforce-runtime-write-deny.mjs` extended with `TRANSCRIPT_RE`
(`(^|/)\.claude/projects/.*\.jsonl$`): the Write tool can no longer create/overwrite any
session/subagent transcript `.jsonl`. Memory files there are `.md` and stay writable
(never match `.jsonl$`). Resolving normalizer blocks `.`/`..` evasion. This makes the
agent-`<id>`.jsonl that Part 2 trusts unforgeable.
- **Part 2**`enforce-tdd-gate.mjs`: `decide()` now also credits a subagent's matching
test edit + RED run via new `subagentEntriesList`. `turnTaskAgentIds(turn)` extracts the
**hex** agentId from the harness-written `Task` tool_result ("agentId: <hex>") — the
controller cannot forge its own tool_result, and the hex-only match blocks
`agentId: ../../x` path-traversal. `subagentTranscriptPaths()` derives
`<dir>/<controller-session>/subagents/agent-<id>.jsonl` (bound to the controller session).
`main()` reads those transcripts best-effort (missing → no extra credit = stricter, never
an error). No NEW weakening: a delegated subagent doing real TDD is legitimate; the only
forgery vector (overwrite the agent jsonl) is closed by Part 1.
- Full tools-vitest: **2027 passed / 2 skipped**.
- **OWNER ACTION (settings.json, Claude can't edit it):** `enforce-tdd-gate.mjs` is already
a registered PreToolUse hook → Part 2 goes live on merge. **Part 1 requires that
`enforce-runtime-write-deny.mjs` be registered** on PreToolUse(Edit|Write|MultiEdit|
NotebookEdit); if it is not yet registered, the transcript Write-deny is inert until added.
### G. Coverage line under-reports cross-turn active skill (2026-05-31, owner-raised)
Symptom: the `coverage: <channel>:<id>` line says `direct`/`chain` when a skill chosen in a
PRIOR turn is still active in the current turn. Root cause: `enforce-coverage-verify.mjs`
credits `channel=skill` only if the `Skill` tool was invoked in the CURRENT turn
(`turnToolUses`). On a continuation turn (skill still active, not re-invoked) an honest
`skill:X` line would be BLOCKED → so the controller learns to under-report as `direct`/`chain`.
**Fix (no weakening):** also credit `skill:X` if X was invoked anywhere earlier in THIS
session (a real `Skill` tool_use in the transcript — still unforgeable). decide() gains a
`priorSkillNames` param; main() collects session-wide Skill names via `sessionToolUses`.
Residual: attribution may be stale (skill invoked long ago) — acceptable; the alternative
(forced dishonest `direct`) is worse, and the owner wants cross-turn skills honored.
### D. Smoke 8 — live Workflow-gate F2 test
Needs a clean session (not code).
### E. H10 — auto-bootstrap worktree (junction node_modules) in `tools/subagent-prompt-prefix.mjs`
### (later) Layer 5 — VM + YubiKey — needs hardware.
## Environment working rules
- Tests / push sentinel: `npx vitest run --root app --config vitest.config.tools.mjs`
(NOT `npm run test:tools` — breaks on keytar). From inside the worktree it's run as
`--root app`; from the main checkout, point `--root` at the worktree app dir.
- Commit: only via AskUserQuestion where the option label = the EXACT command (router-gate
compares verbatim) + plain-language explanation; commit text via `-F` file in `.scratch/`;
commit only explicit paths (parallel sessions).
- Push: needs a fresh verify-sentinel (full run ≤30 min); override phrases are dead
(`findOverride` is a stub) → the only path to push non-`.md` changes is to run the tests.
@@ -1,290 +0,0 @@
# Router-gate dev/prod re-scope — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Разрешить локальную разработку (composer/npm/git/worktree) через контроллера, сохранив блок боевого/опасного и дисциплины.
**Architecture:** Точечно расширить whitelist Bash-гейта (`enforce-router-gate.mjs`) дев-инструментами + разрешить dev-safe git в общем `shell-content-rules.mjs` (`classifyGitCommand`) с «стражем main» для push. Философия default-deny сохраняется; hard-blacklist опасного и дисциплинарные хуки не трогаются.
**Tech Stack:** Node ESM, vitest (`vitest.config.tools.mjs`, root `app`).
**Spec:** `docs/superpowers/specs/2026-06-02-router-gate-dev-prod-rescope-design.md`
**Verify-команда (вся регрессия tools):**
`npx vitest run --root app --config vitest.config.tools.mjs`
Узкий прогон файла: добавить хвост `<имя>.test` (например `enforce-router-gate.test`).
**Bootstrap-нюанс (важно):** до того как Task 3 (git dev-allow) применится, `git commit` ещё
заблокирован самим гейтом. Поэтому коммиты НЕ делаем по ходу — все правки складываем в рабочее
дерево, гоняем тесты, и **один раз** коммитим в конце (Task 5), когда git уже разрешён. Реализация —
в основной копии (worktree пока недоступен; это и есть bootstrap-исключение из спеки).
---
## Задачи
### Task 1: Разрешить `composer` (install/update/require/remove/dump-autoload)
**Files:**
- Modify: `tools/enforce-router-gate.mjs` (BASH_HARD_BLACKLIST ~line 59; SAFE_EXACT ~line 124)
- Test: `tools/enforce-router-gate.test.mjs`
- [ ] **Step 1: Write failing tests** — добавить в конец `enforce-router-gate.test.mjs`:
```js
import { matchBashHardBlacklist as mhb2, classifyBashCommand as cbc2 } from './enforce-router-gate.mjs';
describe('composer dev-allow (owner-authorized 2026-06-02)', () => {
it('allows composer install', () => {
expect(mhb2('composer install')).toBe(null);
expect(cbc2('composer install', {}).result).toBe('allow');
});
it('allows composer require / update / dump-autoload', () => {
expect(cbc2('composer require monolog/monolog', {}).result).toBe('allow');
expect(cbc2('composer update', {}).result).toBe('allow');
expect(cbc2('composer dump-autoload', {}).result).toBe('allow');
});
it('still allows composer install with -d working-dir', () => {
expect(cbc2('composer install -d app --no-interaction', {}).result).toBe('allow');
});
});
```
- [ ] **Step 2: Run to verify FAIL**
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
Expected: FAIL (composer install currently hard-blacklisted → matchBashHardBlacklist truthy, classify 'block').
- [ ] **Step 3: Remove composer from hard-blacklist** — в `tools/enforce-router-gate.mjs` удалить строку:
```js
{ re: /\bcomposer\s+(?:install|update|require|remove)\b/, reason: 'composer install/update/require/remove запрещён' },
```
- [ ] **Step 4: Add composer to whitelist** — в массив `SAFE_EXACT`, рядом с существующей `/^composer\s+(?:show|outdated)\b/`, добавить:
```js
/^composer\s+(?:install|update|require|remove|dump-autoload|dump)\b/, // dev-allow 2026-06-02
```
- [ ] **Step 5: Run to verify PASS**
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
Expected: PASS (включая новый describe).
---
### Task 2: Разрешить `npm` (install/ci/run-скрипты)
**Files:**
- Modify: `tools/enforce-router-gate.mjs` (BASH_HARD_BLACKLIST ~line 60; SAFE_EXACT ~line 122)
- Test: `tools/enforce-router-gate.test.mjs`
- [ ] **Step 1: Write failing tests** — добавить describe:
```js
describe('npm dev-allow (owner-authorized 2026-06-02)', () => {
it('allows npm install / i / ci', () => {
expect(mhb2('npm install')).toBe(null);
expect(cbc2('npm install', {}).result).toBe('allow');
expect(cbc2('npm ci', {}).result).toBe('allow');
});
it('allows npm run <script>', () => {
expect(cbc2('npm run build', {}).result).toBe('allow');
});
});
```
- [ ] **Step 2: Run to verify FAIL**
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
Expected: FAIL (npm install hard-blacklisted).
- [ ] **Step 3: Remove npm from hard-blacklist** — удалить строку:
```js
{ re: /\bnpm\s+(?:install|i|update|remove|uninstall)\b/, reason: 'npm install/update/remove запрещён' },
```
- [ ] **Step 4: Add npm to whitelist** — в `SAFE_EXACT`, рядом с существующей `/^npm\s+(?:test|run\s+test|run\s+lint(?::[\w-]+)?)\b/`, добавить:
```js
/^npm\s+(?:install|i|ci)\b/, // dev-allow 2026-06-02
/^npm\s+run\s+[\w:-]+/, // dev-allow 2026-06-02 (любой script)
```
- [ ] **Step 5: Run to verify PASS**
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
Expected: PASS.
---
### Task 3: Разрешить dev-safe git (commit/add/branch/switch/checkout/stash/worktree)
**Files:**
- Modify: `tools/shell-content-rules.mjs` (GIT_CONDITIONAL_SUB ~line 167; classifyGitCommand ~line 215)
- Test: `tools/shell-content-rules.test.mjs`
- [ ] **Step 1: Write failing tests** — добавить в `shell-content-rules.test.mjs`:
```js
import { classifyGitCommand as cgc2 } from './shell-content-rules.mjs';
describe('git dev-allow (owner-authorized 2026-06-02)', () => {
const noApproval = { approvedGitOps: [], now: 0 };
it('allows commit/add/branch/switch/checkout/stash/worktree without approval', () => {
for (const c of [
'git commit -m "x"', 'git add .', 'git branch feature-x',
'git switch -c feature-x', 'git checkout -b feature-x',
'git stash push -m wip', 'git worktree add ../wt -b feat origin/main',
]) {
expect(cgc2(c, noApproval).result).toBe('allow');
}
});
it('STILL blocks commit --no-verify and add -f (hard patterns)', () => {
expect(cgc2('git commit --no-verify -m x', noApproval).result).toBe('block');
expect(cgc2('git add -f ignored.txt', noApproval).result).toBe('block');
});
it('keeps merge/rebase/reset conditional (needs approval)', () => {
expect(cgc2('git reset --hard HEAD~1', noApproval).result).toBe('block');
expect(cgc2('git merge feature', noApproval).result).toBe('block');
});
});
```
- [ ] **Step 2: Run to verify FAIL**
Run: `npx vitest run --root app --config vitest.config.tools.mjs shell-content-rules.test`
Expected: FAIL (commit/branch/... currently conditional → block без approval; worktree → default-deny).
- [ ] **Step 3: Add GIT_DEV_SUB + trim GIT_CONDITIONAL_SUB** — в `tools/shell-content-rules.mjs`:
Заменить блок `GIT_CONDITIONAL_SUB`:
```js
const GIT_CONDITIONAL_SUB = new Set([
'add', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch',
'branch', 'stash', 'cherry-pick', 'revert', 'pull', 'push', 'clean',
]);
```
на:
```js
// dev-safe (owner-authorized 2026-06-02): allow без approval. GIT_HARD_PATTERNS
// (--no-verify / add -f / -c / force / --output) пре-фильтруют опасное ВЫШЕ.
const GIT_DEV_SUB = new Set([
'add', 'commit', 'branch', 'switch', 'checkout', 'stash', 'worktree',
]);
const GIT_CONDITIONAL_SUB = new Set([
'merge', 'rebase', 'reset', 'cherry-pick', 'revert', 'pull', 'clean',
]);
```
- [ ] **Step 4: Insert dev-allow + push-guard в classifyGitCommand** — после блока `if (sub === 'remote') { … }` (≈line 213) и ПЕРЕД `// 3. conditional → approve check`, вставить:
```js
// dev-safe git (owner-authorized 2026-06-02): hard-patterns уже отсеяли опасное выше.
if (GIT_DEV_SUB.has(sub)) return { result: 'allow', reason: `dev-safe git ${sub}` };
// push: фичевые ветки — allow; main/master — клик владельца (force уже заблокирован hard).
if (sub === 'push') {
if (/\b(?:main|master)\b/.test(norm)) {
return { result: 'block', reason: 'git push в main/master — клик владельца' };
}
return { result: 'allow', reason: 'git push в фичевую ветку' };
}
```
- [ ] **Step 5: Run to verify PASS**
Run: `npx vitest run --root app --config vitest.config.tools.mjs shell-content-rules.test`
Expected: PASS.
---
### Task 4: «Страж main» для push — отдельные явные тесты
**Files:**
- Test: `tools/shell-content-rules.test.mjs` (логика уже добавлена в Task 3 Step 4 — тут только тесты-замок)
- [ ] **Step 1: Write tests**
```js
describe('git push main-guard (owner-authorized 2026-06-02)', () => {
const na = { approvedGitOps: [], now: 0 };
it('allows push to a feature branch', () => {
expect(cgc2('git push origin worktree-lead-region-tails', na).result).toBe('allow');
expect(cgc2('git push', na).result).toBe('allow');
expect(cgc2('git push -u origin feature-x', na).result).toBe('allow');
});
it('blocks push to main/master', () => {
expect(cgc2('git push origin main', na).result).toBe('block');
expect(cgc2('git push origin HEAD:main', na).result).toBe('block');
expect(cgc2('git push origin master', na).result).toBe('block');
});
it('blocks force-push (hard pattern, unchanged)', () => {
expect(cgc2('git push --force origin feature-x', na).result).toBe('block');
expect(cgc2('git push origin feature-x --force-with-lease', na).result).toBe('block');
});
});
```
- [ ] **Step 2: Run to verify PASS** (логика из Task 3 уже на месте)
Run: `npx vitest run --root app --config vitest.config.tools.mjs shell-content-rules.test`
Expected: PASS.
---
### Task 5: Полная регрессия + коммит в фичевую ветку + PR
- [ ] **Step 1: Полная регрессия tools**
Run: `npx vitest run --root app --config vitest.config.tools.mjs`
Expected: всё GREEN (baseline ~1989 + новые). 0 падений.
- [ ] **Step 2: Дымовая проверка живьём** — после правок гейт читается заново; проверить, что
ранее блокированное теперь проходит (а опасное — нет). Прогнать через Bash:
```
composer --version
```
Expected: проходит (раньше любой `composer install` блокировался; `--version` и так был ок — проверка, что не сломали). Затем убедиться, что `git worktree list` (readonly) и `git status` работают.
- [ ] **Step 3: Создать фичевую ветку + worktree (теперь разрешено) и закоммитить**
```bash
git worktree add "../worktree-gate-rescope" -b feat/gate-dev-prod-rescope origin/main
```
(или коммит в основной копии на новой ветке — на усмотрение исполнителя; main НЕ трогать)
```bash
git add tools/enforce-router-gate.mjs tools/shell-content-rules.mjs \
tools/enforce-router-gate.test.mjs tools/shell-content-rules.test.mjs \
docs/superpowers/specs/2026-06-02-router-gate-dev-prod-rescope-design.md \
docs/superpowers/plans/2026-06-02-router-gate-dev-prod-rescope.md
git commit -m "feat(gate): re-scope router-gate — allow local dev (composer/npm/git/worktree), keep prod+discipline blocks"
git push origin feat/gate-dev-prod-rescope
```
- [ ] **Step 4: Открыть PR (клик владельца)** — дать владельцу ссылку из вывода `git push`; слияние в main — его клик.
---
## Self-Review
- **Spec coverage:** composer (Task 1) ✓ / npm (Task 2) ✓ / git dev-subs + worktree (Task 3) ✓ /
push main-guard (Task 4) ✓ / discipline+prod untouched (явно не трогаем в Task 1-4) ✓ /
«main = owner» (push-guard + PR в Task 5) ✓.
- **Placeholders:** нет — весь код приведён дословно.
- **Type/имена:** `GIT_DEV_SUB` / `GIT_CONDITIONAL_SUB` согласованы Task 3↔4; `classifyGitCommand`,
`matchBashHardBlacklist`, `classifyBashCommand` — реальные экспортируемые имена (проверено по коду).
- **Bootstrap:** коммит батчем в Task 5 (git разрешается только после применения Task 3) — учтено.
@@ -1,131 +0,0 @@
# Router-gate re-scope: «боевое блокируем, локальную разработку разрешаем»
**Дата:** 2026-06-02
**Статус:** design (утверждён владельцем; реализация — отдельным планом)
**Автор контекста:** сессия lead-region-tails
## Проблема
Router-gate v4 (`tools/enforce-router-gate.mjs`) работает в режиме «по умолчанию запрещено»
(whitelist для Bash + hard-blacklist + MCP-классификатор + дисциплинарные хуки). Он задумывался
как защита **боевого** контура (выкат на liderra.ru, изменение боевой БД, секреты, запуск
воркфлоу), но по факту блокирует и **весь локальный инструмент разработки**: `composer install`,
`npm install`, `git worktree`, `git commit`/`push`, и даже правку тест-файлов (через
`enforce-tdd-real-test-verifier`). Это делает обычную разработку через контроллера непрактичной —
любая PHP/JS-задача с тестами упирается в стену (подтверждено в сессии 2026-06-02: попытка сделать
fix реестра Россвязи провалилась на цепочке взаимно-охраняющих замков).
## Цель
Перенастроить замок так, чтобы он блокировал **только боевое и опасное**, а **локальную
разработку разрешал** — сохранив при этом дисциплину работы контроллера и защиту боевого контура.
## Решения (утверждены владельцем 2026-06-02)
1. **Дисциплину оставляем.** Хуки качества (TDD-gate, tdd-real-test-verifier, chain-recommendation,
graph-first, override-limit, llm-judge, coverage-verify, memory-coverage и пр.) — **не трогаем**.
Контроллер продолжает писать тесты до кода и не срезать углы.
2. **Защиту боевого оставляем железно.** Выкат/боевая БД/секреты/запуск воркфлоу/защищённые
пути — без изменений.
3. **Инструменты разработки разрешаем.** composer/npm/pest/git/worktree.
4. **Граница git:** ветки — контроллер сам (commit/push в не-главную ветку + подготовка PR);
слияние в main, push в main, force-push, выкат — **клик владельца**.
## Подход
**Approach A (выбран):** точечно расширить whitelist дев-инструментами, сохранив философию
«по умолчанию запрещено». Правим **два файла**`tools/enforce-router-gate.mjs` (composer/npm) и
`tools/shell-content-rules.mjs` (git; там общий `classifyGitCommand`). MCP-классификатор
(`tools/mcp-tool-classifier.mjs`) и дисциплинарные хуки — без изменений.
Отвергнут **Approach B** (перевернуть в default-allow + blacklist опасного): любой пропуск в
перечне опасного = дыра; ломает безопасную философию default-deny.
## Матрица: что блокируем / что разрешаем
### Остаётся ЗАБЛОКИРОВАННЫМ
| Категория | Примеры | Где |
|---|---|---|
| Боевой контур | выкат на сайт, изменение боевой БД, секреты/`.env`, защищённые пути (CLAUDE.md, memory/, transcripts, `~/.claude/runtime`) | без изменений |
| GitHub на запись | `create_*`/`update_*`/`merge_*`/`push_files`/`actions_run_trigger` | MCP-классификатор без изменений (read-only, открытый 2026-06-02, остаётся) |
| Опасные команды | `rm`/`mv`/`cp`/`chmod`/`chown`, `curl -X POST/PUT/DELETE`, `wget`, `nc`/`ncat`/`socat`, `node -e` с `fs.*`, `eval`, `bash -c`/`sh -c`, `python -c`, redirects в protected | hard-blacklist без изменений |
| Дисциплина | TDD-gate, tdd-real-test-verifier, override-limit, chain-recommendation, graph-first, llm-judge, coverage | хуки без изменений |
| Главная ветка | `git push` в main, `git push --force`, слияние в main | новый «страж main» |
### Становится РАЗРЕШЁННЫМ (локальная разработка)
| Инструмент | Команды |
|---|---|
| Composer | `composer install`, `composer dump-autoload`, `composer require`, `composer update` |
| NPM | `npm install`, `npm ci`, `npm run <script>` |
| Тесты | `pest`, `vendor/bin/pest`, `php artisan test` (уже частично в whitelist) |
| Git (ветки) | `git commit`, `git add`, `git branch`, `git switch`/`checkout`, `git worktree`, `git stash`, `git push` **в не-главную ветку** |
## Изменения в коде (два файла)
Git-логика живёт не в самом router-gate, а в общем модуле `shell-content-rules.mjs`
(`classifyGitCommand`, используется и Bash-, и PowerShell-гейтом). Поэтому правок — два файла.
### `tools/enforce-router-gate.mjs` (composer / npm)
1. **Из hard-blacklist (`BASH_HARD_BLACKLIST`) убрать** строки про `composer install/update/require/remove`
и `npm install/i/update/remove/uninstall`. `yarn`/`pnpm` остаются заблокированными (проект на npm,
не нужны). Истинно-опасные fs/сеть/exec (`rm/mv/cp/chmod`, `curl POST`, `wget`, `nc`, `node -e fs`,
`eval`, `bash -c`, `python -c`, redirects) — **без изменений**.
2. **В whitelist (`SAFE_EXACT`) добавить:** `composer (install|update|require|remove|dump-autoload|dump)`,
`npm (install|i|ci)`, `npm run <script>` (любой скрипт). Существующие `composer show/outdated/test/...`
и `npm test/run test/run lint` — остаются.
### `tools/shell-content-rules.mjs` (git)
1. **Новый `GIT_DEV_SUB`** = `{add, commit, branch, switch, checkout, stash, worktree}` → в
`classifyGitCommand` после hard-pattern-проверки возвращать `allow`. Эти подкоманды **убрать** из
`GIT_CONDITIONAL_SUB`. (`worktree` сейчас падает в default-deny — попадёт в dev-allow.)
2. **`GIT_HARD_PATTERNS` не трогаем** — `--no-verify`, `git add -f`, `git -c`, force-push, `--output`/`-o`
и т.п. по-прежнему блокируются ПЕРВЫМИ, до dev-allow. То есть `git commit --no-verify` и `git add -f`
остаются заблокированы даже как «dev».
3. **Страж main для `push`** (`mainPushGuard`, чистая функция): `push` остаётся, но —
если в аргументах фигурирует `main`/`master` как ref (`git push origin main`, `HEAD:main`, `:main`)
**block** (клик владельца); force-push уже заблокирован `GIT_HARD_PATTERNS`. Иначе (`git push origin <feature>`,
bare `git push`) → allow. Допущение: bare `git push` считаем пушем не-главной ветки (контроллер по модели
всегда на не-главной ветке); пуш в main возможен только явным `origin main` → пойман.
4. **Conditional остаётся** для `merge, rebase, reset, cherry-pick, revert, pull, clean` (require approval) —
риск потери работы / слияние в main = клик владельца.
**Не меняем:** `tools/mcp-tool-classifier.mjs`, `tools/bash-tokenizer.mjs` (`isMutatingSegment` — чейн-правило
C13 «цепочка с мутацией → блок» сохраняется), любые `enforce-*` дисциплинарные хуки, `.claude/settings.json`.
## Тестирование (TDD)
Через `tools/enforce-router-gate.test.mjs` (vitest, работает в основной копии):
- `composer install` / `composer require x` → allow; `composer` (без подкоманды) → как раньше.
- `npm install` → allow; `npm run build` → allow.
- `git commit -m x` / `git worktree add ...` / `git push origin feature-x` → allow.
- `git push origin main` / `git push --force`**block** (страж main).
- Регресс: опасное по-прежнему блокируется — `rm -rf x`, `curl -X POST`, `node -e "...fs..."`,
`eval`, `python -c` → block.
- Полная регрессия tools-тестов (`npx vitest run --root app --config vitest.config.tools.mjs`).
## Граница реализации (bootstrap-нюанс)
Сам этот re-scope — bootstrap-исключение: его нельзя делать в worktree (worktree пока заблокирован).
Реализуется в основной копии (там активен живой замок и работает vitest). После правки замка
`git`/`worktree`/`composer` становятся разрешены — дальнейшие задачи (например, fix реестра)
пойдут уже по модели «ветка + PR».
## Остаточные риски (приняты)
- Разрешён `composer require`/`npm install` → теоретический supply-chain (установка пакета).
Принято: это собственный проект владельца; дисциплина и code-review остаются.
- `rm`/`mv`/`cp` остаются заблокированы — если реально мешают разработке, пересматриваем отдельно
(файловые правки покрываются инструментами Write/Edit).
- «Страж main» опирается на парсинг аргументов `git push`; экзотические формы (push по URL,
refspec-трюки) при сомнении → block (fail-safe в сторону защиты main).
## Что НЕ входит (YAGNI)
- Не инвертируем модель замка (default-deny остаётся).
- Не трогаем боевые воркфлоу, секреты, MCP-write.
- Не ослабляем дисциплину.
+18 -5
View File
@@ -26,6 +26,7 @@ import {
lastAssistantText,
parseCoverageLine,
turnToolUses,
sessionToolUses,
findOverride,
logOverride,
exitDecision,
@@ -38,7 +39,7 @@ const MUTATING_TOOLS = new Set([
]);
export function decide({
toolUses, assistantText, override,
toolUses, assistantText, override, priorSkillNames = [],
}) {
// Pure conversational turn — skip.
const hasMutating = toolUses.some((u) => MUTATING_TOOLS.has(u.name));
@@ -59,12 +60,19 @@ export function decide({
}
if (cov.channel === 'skill') {
const found = toolUses.some((u) => u.name === 'Skill' && u.input && (u.input.skill === cov.id || u.input.skill === cov.id.replace(/^superpowers:/, '')));
if (!found) {
// Accept if the skill was invoked in THIS turn OR anywhere earlier in this
// session (item G): a skill chosen in a prior turn stays active, so an honest
// skill:X line on a continuation turn must not be punished into under-reporting.
// Still unforgeable — a real Skill tool_use must exist in the transcript.
const norm = (s) => String(s || '').replace(/^superpowers:/, '');
const idNorm = norm(cov.id);
const foundThisTurn = toolUses.some((u) => u.name === 'Skill' && u.input && norm(u.input.skill) === idNorm);
const foundPrior = (priorSkillNames || []).some((n) => norm(n) === idNorm);
if (!foundThisTurn && !foundPrior) {
return {
block: true,
message: [
`[enforce-coverage-verify] coverage says skill:${cov.id} but the Skill tool was never invoked with that name in this turn.`,
`[enforce-coverage-verify] coverage says skill:${cov.id} but the Skill tool was never invoked with that name in this turn or any prior turn of this session.`,
`Either invoke the skill via Skill tool, or switch coverage to direct:<role> with justification.`,
].join('\n'),
};
@@ -87,8 +95,13 @@ async function main() {
const toolUses = turnToolUses(transcript);
const assistantText = lastAssistantText(transcript);
// Session-wide Skill invocations (item G): a skill chosen in a prior turn is
// still active and may legitimately be named in this turn's coverage line.
const priorSkillNames = sessionToolUses(transcript)
.filter((u) => u.name === 'Skill' && u.input && u.input.skill)
.map((u) => u.input.skill);
const result = decide({ toolUses, assistantText, override });
const result = decide({ toolUses, assistantText, override, priorSkillNames });
exitDecision(result);
} catch {
exitDecision({ block: false });
+34
View File
@@ -1,6 +1,40 @@
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-coverage-verify.mjs';
// Cross-turn skill credit (backlog item G, 2026-05-31): a skill chosen in a PRIOR
// turn stays active; an honest `skill:X` line on a continuation turn must NOT be
// blocked just because the Skill tool was not re-invoked this turn. decide() takes
// priorSkillNames (real Skill tool_uses from earlier in the session transcript).
describe('enforce-coverage-verify / decide — cross-turn active skill (enforce-coverage-verify.mjs)', () => {
it('credits skill:X when X was invoked in a PRIOR turn (priorSkillNames)', () => {
const r = decide({
toolUses: [{ name: 'Edit', input: { file_path: 'foo.mjs' } }],
assistantText: 'coverage: skill:superpowers:test-driven-development\nработаю',
priorSkillNames: ['superpowers:test-driven-development'],
});
expect(r.block).toBe(false);
});
it('normalizes the superpowers: prefix for prior-turn skills too', () => {
const r = decide({
toolUses: [{ name: 'Edit', input: { file_path: 'foo.mjs' } }],
assistantText: 'coverage: skill:superpowers:test-driven-development',
priorSkillNames: ['test-driven-development'],
});
expect(r.block).toBe(false);
});
it('still blocks skill:X when X is neither in this turn nor any prior turn', () => {
const r = decide({
toolUses: [{ name: 'Edit', input: { file_path: 'foo.mjs' } }],
assistantText: 'coverage: skill:superpowers:test-driven-development',
priorSkillNames: ['some-other-skill'],
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/never invoked/);
});
});
describe('enforce-coverage-verify / decide', () => {
it('allows turn with no mutating tools (pure conversational)', () => {
const r = decide({ toolUses: [{ name: 'Read', input: {} }], assistantText: 'just talking' });
+121 -4
View File
@@ -11,10 +11,12 @@
* Activation: settings.json registration is deferred to Phase H-α/H-β
* batch step. main() is a no-op (exit 0) until then.
*/
import { acquire, release, computeWorkspaceHash } from './parallel-session-lock.mjs';
import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
import { acquire, release, computeWorkspaceHash, isStale } from './parallel-session-lock.mjs';
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, readdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { readStdin, parseEventJson, exitDecision, runtimeDir } from './enforce-hook-helpers.mjs';
import { classifyBashCommand } from './enforce-router-gate.mjs';
/**
* Pure decision: given an acquire() result, decide block/allow.
@@ -29,12 +31,41 @@ export function decide({ acquireResult, sessionId }) {
if (!acquireResult || typeof acquireResult !== 'object') return { block: false };
if (acquireResult.acquired) return { block: false };
const holder = acquireResult.holder || {};
// Identify the holder by its STABLE session id, not the pid: the recorded pid
// is the transient hook-node pid and changes between attempts, so chasing it
// leads to closing the wrong session. Surface the pid only as a triage hint.
return {
block: true,
reason: `parallel session lock held by ${holder.session_id || 'unknown'} (pid ${holder.pid || '?'}) — wait or close that session first`,
reason: `parallel session lock held by session ${holder.session_id || 'unknown'} (current pid ${holder.pid || '?'}, may change between attempts — identify the session by its id, not pid) — wait for the 5-min TTL or close THAT session`,
};
}
/**
* Calibration (2026-05-31, SCOPE fix, NOT a discipline drop). The lock's purpose
* is to serialize concurrent FILE MUTATION between sessions on the same worktree.
* A readonly Bash command (git status/log/diff, cat, grep, ls "смотрелки")
* mutates nothing, so a peer session's lock must NOT block it. Reuse the
* router-gate Bash classifier: an allow-verdict whose reason mentions
* readonly/reading is a no-state-change command. Mirrors the LLM-judge readonly
* calibration. Everything that can mutate file edits, git commit/push,
* dangerous Bash, and every NON-Bash tool still acquires/checks the lock, so
* same-worktree mutation serialization is unchanged.
*
* @param {object} event
* @returns {boolean}
*/
export function isReadonlyBashEvent(event) {
if (!event || event.tool_name !== 'Bash') return false;
const command = (event.tool_input && event.tool_input.command) || '';
if (!command) return false;
try {
const c = classifyBashCommand(command, {});
return !!c && c.result === 'allow' && /readonly|reading/i.test(c.reason || '');
} catch {
return false;
}
}
/**
* PreToolUse wiring: acquire (or same-session refresh / stale takeover) the lock,
* then decide block/allow. I/O injected for testability.
@@ -60,6 +91,64 @@ export function runReleaseAction({ event, cwd, readLock, deleteLock }) {
return { released: true };
}
/**
* Resolve the stable work-tree root used as the lock key. Keys on the SESSION's
* cwd (`event.cwd`, stable across resume) resolved to the git work-tree root
* NOT the hook's `process.cwd()`, which collapses to the main repo dir after a
* session resume and thereby false-blocks sessions in DIFFERENT worktrees.
* Pure (I/O injected): `runGitToplevel(dir)` returns the toplevel or '' on failure.
*
* @param {object} p
* @param {object} p.event
* @param {string} p.processCwd
* @param {(dir:string)=>string} p.runGitToplevel
* @returns {string}
*/
export function resolveWorkspacePath({ event, processCwd, runGitToplevel }) {
const dir = (event && typeof event.cwd === 'string' && event.cwd) ? event.cwd : processCwd;
try {
const top = runGitToplevel(dir);
if (top && typeof top === 'string') return top;
} catch { /* fall through to raw dir (fail-open) */ }
return dir;
}
/**
* Disk hygiene: delete leaked lock files whose record is ALREADY stale by the
* shared isStale() definition (so an active within-TTL lock is never touched).
* Pure (I/O injected). Best-effort: a failed read counts the file as stale
* (garbage), a failed delete is swallowed hygiene must never break the gate.
*
* @param {object} p
* @param {string[]} p.files - absolute lock-file paths
* @param {(f:string)=>object|null} p.readRecord
* @param {(f:string)=>void} p.deleteRecord
* @param {(rec:object|null, now:number)=>boolean} p.isStaleFn
* @param {number} p.now
* @returns {{pruned: number}}
*/
export function pruneStaleLocks({ files, readRecord, deleteRecord, isStaleFn, now }) {
let pruned = 0;
for (const f of files || []) {
let rec = null;
try { rec = readRecord(f); } catch { rec = null; }
if (isStaleFn(rec, now)) {
try { deleteRecord(f); pruned++; } catch { /* best-effort */ }
}
}
return { pruned };
}
function realGitToplevel(dir) {
try {
return execFileSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], {
encoding: 'utf-8',
timeout: 1000,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch { return ''; }
}
function lockPathFor(cwd) {
return join(runtimeDir(), `session-lock-${computeWorkspaceHash(cwd)}.json`);
}
@@ -82,7 +171,10 @@ async function main() {
// a lock bug can NEVER wedge the user out of their own session.
try {
const event = parseEventJson(await readStdin());
const cwd = process.cwd();
// Key by the session's stable work-tree root (event.cwd → git toplevel),
// not the volatile hook process.cwd() (collapses to main on resume → false
// cross-worktree blocks). Fallback to process.cwd() keeps prior behavior.
const cwd = resolveWorkspacePath({ event, processCwd: process.cwd(), runGitToplevel: realGitToplevel });
const p = lockPathFor(cwd);
// Stop event carries no tool_name → release path.
@@ -91,6 +183,31 @@ async function main() {
return exitDecision({ block: false });
}
// Calibration (2026-05-31): a readonly Bash command never mutates the
// worktree, so it is outside the lock's mutation-serialization scope — allow
// without acquiring/blocking. Mutating tools (and every non-Bash tool) fall
// through to acquire/check below, so serialization is unchanged.
if (isReadonlyBashEvent(event)) {
return exitDecision({ block: false });
}
// Best-effort disk hygiene (B): drop leaked stale lock files before acquiring.
// isStale-gated → an active within-TTL lock is never pruned, so same-worktree
// serialization is untouched. Wrapped so hygiene can never break the gate.
try {
const dir = runtimeDir();
const files = readdirSync(dir)
.filter((f) => /^session-lock-.*\.json$/.test(f))
.map((f) => join(dir, f));
pruneStaleLocks({
files,
readRecord: (fp) => realReadLock(fp),
deleteRecord: (fp) => realDeleteLock(fp),
isStaleFn: isStale,
now: Date.now(),
});
} catch { /* hygiene is best-effort */ }
// PreToolUse on a mutating tool → acquire/refresh, then block/allow.
const r = runAcquireDecision({
event,
+164 -1
View File
@@ -1,7 +1,7 @@
// tools/enforce-parallel-session-lock.test.mjs
// Stream H Task 7 — wrapper tests around the pure parallel-session-lock module.
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-parallel-session-lock.mjs';
import { decide, isReadonlyBashEvent } from './enforce-parallel-session-lock.mjs';
describe('enforce-parallel-session-lock wrapper (Stream H Task 7)', () => {
it('allow when acquire succeeded (fresh own-lock)', () => {
@@ -43,6 +43,25 @@ describe('enforce-parallel-session-lock wrapper (Stream H Task 7)', () => {
});
});
// D (2026-05-31): the block message must steer the human to the STABLE identity
// (session id), not the transient hook pid — chasing the pid was what caused the
// owner to close the wrong session and deadlock the workspace.
describe('decide() message clarity (D) — pid is transient, identify by session id', () => {
const blocked = { acquired: false, holder: { session_id: 'sess-A', pid: 12552, acquired_at: 0 } };
it('names the holder session id as the stable identity', () => {
expect(decide({ acquireResult: blocked, sessionId: 's1' }).reason).toMatch(/sess-A/);
});
it('marks the pid as changeable so the human does not chase it', () => {
expect(decide({ acquireResult: blocked, sessionId: 's1' }).reason).toMatch(/may change|transient/i);
});
it('still surfaces the pid for triage', () => {
expect(decide({ acquireResult: blocked, sessionId: 's1' }).reason).toMatch(/12552/);
});
});
// Live wiring (point 2, 2026-05-31): PreToolUse acquires/refreshes the lock,
// Stop releases it. I/O is injected (readLock/writeLock/deleteLock) so the
// wiring stays pure and unit-testable; main() binds real fs.
@@ -131,3 +150,147 @@ describe('runReleaseAction — Stop release wiring', () => {
expect(deleted).toBe(false);
});
});
// Cross-worktree false-block fix (2026-05-31). The lock must key on the session's
// stable work-tree root (from event.cwd → git toplevel), NOT the hook process.cwd()
// — which collapses to the main repo dir after a session resume, making sessions in
// DIFFERENT worktrees share one lock and block each other.
import { resolveWorkspacePath, pruneStaleLocks } from './enforce-parallel-session-lock.mjs';
describe('resolveWorkspacePath — stable worktree key', () => {
it('keys on event.cwd (the session worktree), not the hook process.cwd()', () => {
const r = resolveWorkspacePath({
event: { cwd: '/repo/.claude/worktrees/wt-A' },
processCwd: '/repo',
runGitToplevel: (dir) => dir,
});
expect(r).toBe('/repo/.claude/worktrees/wt-A');
});
it('gives different keys for two different worktrees (no cross-block)', () => {
const opts = { processCwd: '/repo', runGitToplevel: (dir) => dir };
const a = resolveWorkspacePath({ event: { cwd: '/repo/.claude/worktrees/wt-A' }, ...opts });
const b = resolveWorkspacePath({ event: { cwd: '/repo/.claude/worktrees/wt-B' }, ...opts });
expect(a).not.toBe(b);
});
it('resolves to the git work-tree root (collapses subdir variance)', () => {
const r = resolveWorkspacePath({
event: { cwd: '/repo/.claude/worktrees/wt-A/tools' },
processCwd: '/repo',
runGitToplevel: () => '/repo/.claude/worktrees/wt-A',
});
expect(r).toBe('/repo/.claude/worktrees/wt-A');
});
it('falls back to processCwd when event.cwd is absent', () => {
const r = resolveWorkspacePath({
event: { tool_name: 'Edit' },
processCwd: '/repo',
runGitToplevel: (dir) => dir,
});
expect(r).toBe('/repo');
});
it('falls back to the raw dir when git toplevel resolution fails (fail-open)', () => {
const r = resolveWorkspacePath({
event: { cwd: '/some/dir' },
processCwd: '/repo',
runGitToplevel: () => '',
});
expect(r).toBe('/some/dir');
});
});
// B (2026-05-31): disk hygiene. Leaked lock files (session closed without a clean
// Stop) pile up in ~/.claude/runtime. Pruning ONLY removes records that are
// already stale by the SAME isStale() definition acquire() uses — so it can never
// drop an active (within-TTL) lock and never weakens same-worktree serialization.
describe('pruneStaleLocks — drops only already-stale leaked locks (B)', () => {
const fresh = { schema_version: 1, session_id: 'A', pid: 1, acquired_at: 1000, ttl_ms: 300000 };
const stale = { schema_version: 1, session_id: 'B', pid: 2, acquired_at: 0, ttl_ms: 100 };
const isStaleFn = (rec, now) => !rec || (now - (rec && rec.acquired_at || 0)) > ((rec && rec.ttl_ms) || 300000);
it('deletes stale lock files and never the fresh (active) ones', () => {
const records = { '/r/lock-fresh.json': fresh, '/r/lock-stale.json': stale };
const deleted = [];
const r = pruneStaleLocks({
files: Object.keys(records),
readRecord: (f) => records[f],
deleteRecord: (f) => deleted.push(f),
isStaleFn, now: 1000,
});
expect(deleted).toEqual(['/r/lock-stale.json']);
expect(r.pruned).toBe(1);
});
it('treats an unreadable/garbage lock file as stale and prunes it', () => {
const deleted = [];
pruneStaleLocks({
files: ['/r/garbage.json'],
readRecord: () => { throw new Error('bad json'); },
deleteRecord: (f) => deleted.push(f),
isStaleFn, now: 1000,
});
expect(deleted).toEqual(['/r/garbage.json']);
});
it('never throws when a delete fails (best-effort hygiene)', () => {
expect(() => pruneStaleLocks({
files: ['/r/x.json'],
readRecord: () => stale,
deleteRecord: () => { throw new Error('locked'); },
isStaleFn, now: 1000,
})).not.toThrow();
});
it('does nothing for an empty file list', () => {
const r = pruneStaleLocks({ files: [], readRecord: () => null, deleteRecord: () => {}, isStaleFn, now: 1 });
expect(r.pruned).toBe(0);
});
});
// ── Calibration (2026-05-31): readonly Bash is outside the lock scope ──
// The lock serializes concurrent FILE MUTATION between sessions on the same
// worktree. A readonly Bash command (git status/log/diff, cat, grep, ls)
// mutates nothing, so a peer session's lock must NOT block it. This mirrors the
// LLM-judge readonly calibration (isReadonlyBashEvent in enforce-llm-judge-per-tool).
// Everything that can mutate — file edits, git commit/push, dangerous Bash, and
// every NON-Bash tool — still acquires/checks the lock, so mutation
// serialization is unchanged (scope fix, NOT a discipline drop).
describe('isReadonlyBashEvent — readonly Bash bypasses the lock (calibration 2026-05-31)', () => {
const ev = (command) => ({ tool_name: 'Bash', tool_input: { command } });
it('treats readonly git (status/log/diff) as readonly', () => {
expect(isReadonlyBashEvent(ev('git status'))).toBe(true);
expect(isReadonlyBashEvent(ev('git log --oneline -5'))).toBe(true);
expect(isReadonlyBashEvent(ev('git diff'))).toBe(true);
});
it('treats whitelisted reading commands (cat/grep/ls) as readonly', () => {
expect(isReadonlyBashEvent(ev('ls -la'))).toBe(true);
expect(isReadonlyBashEvent(ev('cat README.md'))).toBe(true);
expect(isReadonlyBashEvent(ev('grep -n foo bar.txt'))).toBe(true);
});
it('does NOT treat mutating Bash as readonly (still acquires/blocks)', () => {
expect(isReadonlyBashEvent(ev('rm -rf x'))).toBe(false);
expect(isReadonlyBashEvent(ev('git commit -m "x"'))).toBe(false);
expect(isReadonlyBashEvent(ev('npm install foo'))).toBe(false);
});
it('does NOT treat a chain with a mutating part as readonly (C13)', () => {
expect(isReadonlyBashEvent(ev('git status && rm x'))).toBe(false);
});
it('only applies to the Bash tool — other tools still acquire the lock', () => {
expect(isReadonlyBashEvent({ tool_name: 'Edit', tool_input: { file_path: 'a.js' } })).toBe(false);
expect(isReadonlyBashEvent({ tool_name: 'Write', tool_input: { file_path: 'a.js' } })).toBe(false);
});
it('is safe on malformed input', () => {
expect(isReadonlyBashEvent(null)).toBe(false);
expect(isReadonlyBashEvent({ tool_name: 'Bash', tool_input: {} })).toBe(false);
expect(isReadonlyBashEvent({ tool_name: 'Bash' })).toBe(false);
});
});
+2 -2
View File
@@ -72,8 +72,8 @@ describe('classifyPowerShellCommand', () => {
it('blocks reading a protected path', () => {
expect(classifyPowerShellCommand('Get-Content ~/.claude/settings.json', {}).result).toBe('block');
});
it('routes git through shared classifier (commit dev-allowed 2026-06-02 re-scope)', () => {
expect(classifyPowerShellCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('allow');
it('routes git through shared classifier (block unapproved commit)', () => {
expect(classifyPowerShellCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('block');
});
it('allows readonly git through PowerShell', () => {
expect(classifyPowerShellCommand('git status', {}).result).toBe('allow');
+28 -2
View File
@@ -21,13 +21,15 @@ import {
parseEventJson,
readRouterState,
readRationalizationFlags,
readTranscript,
sessionToolUses,
findOverride,
loadOverrideVocab,
} from './enforce-hook-helpers.mjs';
const SUPPRESS_RULE = 'classifier-mismatch';
export function buildReminder({ classification, recentFlags, override }) {
export function buildReminder({ classification, recentFlags, override, activeSkills = [] }) {
const lines = ['## §17 Coverage / Discipline Reminder', ''];
if (override) {
lines.push(`Override phrase detected: "${override.phrase}". The following rules are suppressed for THIS prompt only:`);
@@ -38,6 +40,16 @@ export function buildReminder({ classification, recentFlags, override }) {
lines.push(' `coverage: <channel>:<id>`');
lines.push('Channels: skill, node, chain, hook, agent, direct.');
lines.push('');
// Item G (2026-05-31): a skill invoked in an EARLIER turn stays active. Remind
// explicitly so the coverage line is not under-reported as direct/chain when the
// work actually continues under that skill. (The verifier now accepts a prior-turn
// skill, so this report is honest, not a violation.)
if (Array.isArray(activeSkills) && activeSkills.length > 0) {
lines.push('**Active skill(s) still in effect from earlier this session:**');
for (const s of activeSkills) lines.push(` - ${s}`);
lines.push('If your work continues under one of these, report `coverage: skill:<name>` (not direct/chain).');
lines.push('');
}
if (classification) {
lines.push(`**Classifier output:** task_type=${classification.task_type || 'unknown'}, confidence=${classification.confidence ?? 'n/a'}`);
if (classification.recommended_node) {
@@ -94,7 +106,21 @@ async function main() {
const flags = readRationalizationFlags(sessionId);
const reminder = buildReminder({ classification, recentFlags: flags, override });
// Item G: detect skills invoked earlier this session (still active). The
// transcript at UserPromptSubmit holds all prior turns. Best-effort.
let activeSkills = [];
try {
const transcript = readTranscript(event.transcript_path);
const seen = new Set();
for (const u of sessionToolUses(transcript)) {
if (u.name === 'Skill' && u.input && u.input.skill && !seen.has(u.input.skill)) {
seen.add(u.input.skill);
activeSkills.push(u.input.skill);
}
}
} catch { activeSkills = []; }
const reminder = buildReminder({ classification, recentFlags: flags, override, activeSkills });
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
+16
View File
@@ -66,6 +66,22 @@ describe('enforce-prompt-injection / buildReminder', () => {
expect(txt).toMatch(/verify-before-push/);
});
it('reminds about active skills carried over from prior turns (item G)', () => {
const txt = buildReminder({
classification: null,
recentFlags: [],
activeSkills: ['superpowers:test-driven-development'],
});
expect(txt).toMatch(/Active skill/i);
expect(txt).toMatch(/test-driven-development/);
expect(txt).toMatch(/coverage: skill:/);
});
it('omits the active-skill note when none are active', () => {
const txt = buildReminder({ classification: null, recentFlags: [], activeSkills: [] });
expect(txt).not.toMatch(/Active skill/i);
});
it('does NOT advertise dead override-vocabulary phrases (v4 stub — 1A 2026-05-31)', () => {
const txt = buildReminder({ classification: null, recentFlags: [] });
// findOverride/loadOverrideVocab — заглушки (vocab removed in v4); реклама фраз
+9 -21
View File
@@ -56,8 +56,8 @@ export const BASH_HARD_BLACKLIST = [
{ re: /\bpython3?\s+-c\b/, reason: 'python -c запрещён' },
{ re: /\b(?:bash|sh)\s+-c\b/, reason: 'bash/sh -c запрещён' },
{ re: /(^|\s|;|&&|\|\|)eval\b/, reason: 'eval запрещён' },
// composer/npm перенесены в whitelist (dev-allow, 2026-06-02 re-scope) — это локальные
// инструменты разработки, не боевой контур. yarn/pnpm остаются заблокированы (проект на npm).
{ re: /\bcomposer\s+(?:install|update|require|remove)\b/, reason: 'composer install/update/require/remove запрещён' },
{ re: /\bnpm\s+(?:install|i|update|remove|uninstall)\b/, reason: 'npm install/update/remove запрещён' },
{ re: /\b(?:yarn|pnpm)\s+(?:add|install|remove)\b/, reason: 'yarn/pnpm add/install/remove запрещён' },
{ re: /\bnpx\s+claude-/, reason: 'npx claude-* запрещён' },
{ re: /\bcurl\b[^|;]*-X\s*(?:POST|PUT|DELETE|PATCH)\b/i, reason: 'curl -X POST/PUT/DELETE/PATCH запрещён' },
@@ -120,10 +120,14 @@ const READING_CMDS = new Set(['ls', 'pwd', 'wc', 'head', 'tail', 'file', 'stat',
const SAFE_EXACT = [
/^npx\s+vitest\s+(?:run|--version)\b/,
/^npm\s+(?:test|run\s+test|run\s+lint(?::[\w-]+)?)\b/,
/^npm\s+(?:install|i|ci)\b/, // dev-allow 2026-06-02 re-scope
/^npm\s+run\s+[\w:-]+/, // dev-allow 2026-06-02 re-scope (любой npm-скрипт)
// `npm ci` (2026-05-31, owner-authorized) — clean install from the committed
// lockfile (deterministic, no version drift) to restore junction node_modules
// in a fresh worktree. Distinct from `npm install`/`npm i`, which stay
// hard-blacklisted (line ~60) because they can pull new/updated versions.
// `\b` after `ci` prevents `npm cider`-style prefix matches.
/^npm\s+ci\b/,
/^php\s+artisan\s+(?:list|route:list|migrate:status)\b/,
/^composer\s+(?:show|outdated|install|update|require|remove|dump-autoload|dump)\b/, // +dev-allow 2026-06-02 re-scope
/^composer\s+(?:show|outdated)\b/,
/^node\s+(?!.*(?:-e|--eval|-p|--print|-r|--require|--import|--experimental-loader)\b)/,
// Laravel dev workflow (2026-05-30) — exclude tinker (REPL = arbitrary PHP exec risk).
// Hard-blacklist (composer install/update/require/remove) remains the first check, unaffected.
@@ -140,22 +144,6 @@ const SAFE_EXACT = [
// hard-blacklist + chain-mutating rule (both run before the whitelist), and each
// chain segment after `cd app &&` must still be independently whitelisted.
/^cd\s+app$/,
// Worktree dev (2026-06-02, owner-authorized): cd into a project worktree dir
// (path segment `worktree-` / `v4-stream-`) so git/pest run there. Quoted absolute
// path required; `..` and protected segments (.claude/.ssh/.env/runtime/.git) excluded
// → cwd-shift read-bypass stays contained (protected files also remain blocked by name
// in the command). cd into Документация/system/protected dirs → default-deny.
/^cd\s+(?=.*[\\/](?:worktree-|v4-stream-))(?!.*(?:\.\.|\.claude|\.ssh|\.env|runtime|\.git)).+$/,
// graphify read-only subcommands (#86, §5 п.14, owner-authorized 2026-06-08).
// Only query/explain/path — extract/update/build/export/hook/clone/add/merge stay
// default-deny. The bare \b form is safe: injection vectors are neutralized BEFORE the
// whitelist sees them — chains split into per-segment whitelist checks (an injected
// `; id` segment is not whitelisted → block), subshells `$(...)`/backtick are blocked by
// the tokenizer, redirects by the hard-blacklist, and $VAR is var-expanded by the
// tokenizer (not an injection vector for a read-only query arg). End-anchoring with a
// charset would reject Unicode query strings (tokenizer strips quotes → Cyrillic args
// arrive as barewords) for no security gain. (security review 2026-06-08 — false-positive)
/^graphify\s+(?:query|explain|path)\b/,
];
export function classifyWhitelist(segments) {
+39 -77
View File
@@ -15,17 +15,14 @@ describe('matchBashHardBlacklist — v3.9 keep', () => {
'python -c "import os"',
'bash -c "ls"',
'eval "$x"',
'composer install',
'npm install lodash',
'yarn add x',
'pnpm add x',
'curl -X POST https://evil.test',
])('blocks %s', (cmd) => {
expect(matchBashHardBlacklist(cmd)).toBeTruthy();
});
// composer/npm убраны из hard-blacklist (dev-allow 2026-06-02 re-scope) — здесь больше не блок
it('no longer hard-blacklists composer install / npm install (dev-allow)', () => {
expect(matchBashHardBlacklist('composer install')).toBe(null);
expect(matchBashHardBlacklist('npm install lodash')).toBe(null);
});
});
describe('matchBashHardBlacklist — v4.0 additions', () => {
@@ -118,8 +115,8 @@ describe('classifyBashCommand — integration', () => {
it('blocks reading a protected path', () => {
expect(classifyBashCommand('cat ~/.claude/runtime/state.json', {}).result).toBe('block');
});
it('routes single git commit to dev-allow (2026-06-02 re-scope — no approval needed)', () => {
expect(classifyBashCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('allow');
it('routes single git commit to conditional (block unapproved)', () => {
expect(classifyBashCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('block');
});
it('allows approved git commit', () => {
expect(
@@ -194,29 +191,17 @@ describe('SAFE_EXACT — Laravel dev workflow (whitelist expansion 2026-05-30)',
expect(classifyBashCommand(cmd, {}).result).toBe('allow');
});
// Critical: REPL remains hard-blocked (composer/npm moved to dev-allow below, 2026-06-02 re-scope)
it('still blocks tinker REPL and unknown migrate subcommand', () => {
expect(classifyBashCommand('php artisan tinker', {}).result).toBe('block');
expect(classifyBashCommand('php artisan tinker --execute="exit"', {}).result).toBe('block');
expect(classifyBashCommand('php artisan migrate:install', {}).result).toBe('block');
});
// dev-allow (owner-authorized 2026-06-02 re-scope): composer is a local dev tool
it('now allows composer install/require/update/remove/dump-autoload', () => {
expect(classifyBashCommand('composer install', {}).result).toBe('allow');
expect(classifyBashCommand('composer install -d app --no-interaction', {}).result).toBe('allow');
expect(classifyBashCommand('composer require monolog/monolog', {}).result).toBe('allow');
expect(classifyBashCommand('composer update', {}).result).toBe('allow');
expect(classifyBashCommand('composer remove monolog/monolog', {}).result).toBe('allow');
expect(classifyBashCommand('composer dump-autoload', {}).result).toBe('allow');
});
// dev-allow (owner-authorized 2026-06-02 re-scope): npm is a local dev tool
it('now allows npm install/i/ci/run', () => {
expect(classifyBashCommand('npm install', {}).result).toBe('allow');
expect(classifyBashCommand('npm i', {}).result).toBe('allow');
expect(classifyBashCommand('npm ci', {}).result).toBe('allow');
expect(classifyBashCommand('npm run build', {}).result).toBe('allow');
// Critical: REPL and composer mutations remain hard-blocked
it.each([
['php artisan tinker', 'REPL = arbitrary PHP exec risk'],
['php artisan tinker --execute="exit"', 'tinker variant'],
['composer install', 'hard-blacklist'],
['composer require foo/bar', 'hard-blacklist'],
['composer update', 'hard-blacklist'],
['composer remove foo/bar', 'hard-blacklist'],
['php artisan migrate:install', 'unknown migrate subcommand outside whitelist set'],
])('still blocks %s (%s)', (cmd) => {
expect(classifyBashCommand(cmd, {}).result).toBe('block');
});
// Critical: existing pre-existing v3.8 keep behaviour
@@ -241,39 +226,6 @@ describe('SAFE_EXACT — Laravel dev workflow (whitelist expansion 2026-05-30)',
expect(classifyBashCommand('composer show', {}).result).toBe('allow');
expect(classifyBashCommand('composer outdated', {}).result).toBe('allow');
});
// graphify read-only subcommands (owner-authorized 2026-06-08 — #86 graphify, §5 п.14)
it('allows graphify read-only subcommands (query/explain/path)', () => {
expect(classifyBashCommand('graphify query "x"', {}).result).toBe('allow');
expect(classifyBashCommand('graphify explain "Node"', {}).result).toBe('allow');
expect(classifyBashCommand('graphify path "A" "B"', {}).result).toBe('allow');
});
// graphify mutating/expensive subcommands stay default-deny
it('still blocks graphify mutating subcommands (extract/export/hook)', () => {
expect(classifyBashCommand('graphify extract .', {}).result).toBe('block');
expect(classifyBashCommand('graphify export html', {}).result).toBe('block');
expect(classifyBashCommand('graphify hook install', {}).result).toBe('block');
});
// graphify allowlist is not bypassable via chained commands / subshells — they are
// caught by the gate architecture BEFORE the whitelist regex (per-segment whitelist +
// tokenizer subshell-block + redirect hard-blacklist), so the simple subcommand
// allowlist is safe (security review 2026-06-08 finding = false-positive: $VAR is
// var-expanded away by the tokenizer, not a command-injection vector).
it('blocks graphify chained commands and subshell payloads', () => {
expect(classifyBashCommand('graphify query x; id', {}).result).toBe('block');
expect(classifyBashCommand('graphify query x && rm y', {}).result).toBe('block');
expect(classifyBashCommand('graphify path A `id`', {}).result).toBe('block');
expect(classifyBashCommand('graphify query x | sh', {}).result).toBe('block');
});
// legit read-only graphify with quoted (Cyrillic) question + flags still allowed —
// guards against over-tightening that would reject Unicode queries (tokenizer strips
// quotes → Cyrillic args arrive as barewords).
it('still allows graphify query with quoted question and flags', () => {
expect(classifyBashCommand('graphify query "конфликт дубль" --dfs --budget 1500', {}).result).toBe('allow');
});
});
describe('SAFE_EXACT — narrow `cd app` whitelist (2026-05-31, owner-authorized)', () => {
@@ -319,27 +271,37 @@ describe('SAFE_EXACT — narrow `cd app` whitelist (2026-05-31, owner-authorized
});
});
describe('SAFE_EXACT — worktree cd (2026-06-02, owner-authorized worktree dev)', () => {
// Allowed: enter a project worktree dir (segment `worktree-` / `v4-stream-`) so
// git/pest can run there. Quoted absolute path; cwd-shift read-bypass stays contained
// because protected files remain blocked by name in the command (cat .env / runtime).
describe('SAFE_EXACT — npm ci (worktree dep restore, 2026-05-31)', () => {
// Allowed: npm ci installs exactly the committed lockfile (deterministic, no
// version drift) — needed to restore junction node_modules in a fresh worktree.
it.each([
'cd "C:\\моя\\проекты\\портал crm\\worktree-deals-city"',
'cd "C:\\моя\\проекты\\портал crm\\worktree-deals-city\\app"',
'cd "C:\\моя\\проекты\\портал crm\\v4-stream-A"',
])('allows cd into a worktree dir: %s', (cmd) => {
'npm ci',
'npm ci --no-audit',
'npm ci --prefer-offline',
])('allows %s', (cmd) => {
expect(classifyBashCommand(cmd, {}).result).toBe('allow');
});
// Scope: protected / non-worktree dirs stay default-deny (no `worktree-` marker, or
// `..` / protected segment present → cwd-shift read-bypass prevented).
// Critical: npm install / npm i remain hard-blacklisted (line 60) — they can
// pull new/updated versions, unlike ci which pins to the lockfile.
it.each([
'cd "C:\\Users\\Administrator\\.claude\\runtime"',
'cd "C:\\моя\\проекты\\портал crm\\worktree-x\\..\\..\\.claude"',
'cd "C:\\моя\\проекты\\портал crm\\Документация"',
])('still blocks cd into non-worktree / protected dir: %s', (cmd) => {
'npm install',
'npm i',
'npm install foo',
'npm i foo',
])('still blocks %s (hard-blacklist)', (cmd) => {
expect(classifyBashCommand(cmd, {}).result).toBe('block');
});
// Critical: word boundary — `npm cider` (or any ci-prefixed token) is NOT npm ci
it('does not allow ci-prefixed token (word boundary)', () => {
expect(classifyBashCommand('npm cider', {}).result).toBe('block');
});
// Critical: chain semantics still enforced — npm ci && rm x → block (rm mutating)
it('still blocks chain with mutating part after npm ci', () => {
expect(classifyBashCommand('npm ci && rm x', {}).result).toBe('block');
});
});
import { stripQuotedSpans } from './enforce-router-gate.mjs';
+13 -1
View File
@@ -24,6 +24,11 @@ import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.
const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
const RUNTIME_RE = /(^|\/)\.claude\/runtime(\/|$)/i;
// Transcript protection (Z Part 1): any *.jsonl under ~/.claude/projects/** is a
// session/subagent transcript. The tdd-gate credits a subagent's RED from its
// agent-<id>.jsonl, so these must be unforgeable by the Write tool. Memory files
// there are *.md and never match `.jsonl$`, so memory writes stay allowed.
const TRANSCRIPT_RE = /(^|\/)\.claude\/projects\/.*\.jsonl$/i;
/**
* Pure decision.
@@ -39,12 +44,19 @@ export function decide({ toolName, filePath, normalizeImpl = pathNormalize }) {
if (!fp) return { block: false };
let norm;
try { norm = normalizeImpl(fp); } catch { return { block: false }; } // cannot determine → fail-open
if (RUNTIME_RE.test(String(norm || ''))) {
const normStr = String(norm || '');
if (RUNTIME_RE.test(normStr)) {
return {
block: true,
reason: `Write to «${norm}» denied — ~/.claude/runtime is a protected side-channel (git-approval anchor). Hooks write it via Node fs, not the Write tool.`,
};
}
if (TRANSCRIPT_RE.test(normStr)) {
return {
block: true,
reason: `Write to «${norm}» denied — ~/.claude/projects/**/*.jsonl are session/subagent transcripts (tamper-protected; the tdd-gate trusts them). The harness writes transcripts, never the Write tool. Memory *.md there stays writable.`,
};
}
return { block: false };
}
+44
View File
@@ -52,3 +52,47 @@ describe('enforce-runtime-write-deny decide()', () => {
expect(r.block).toBe(true);
});
});
// Part 1 of Z (2026-05-31): close the transcript Write hole. The tdd-gate will
// (Part 2) credit a subagent's RED from its agent-<id>.jsonl; that transcript
// must therefore be unforgeable. The Write tool was the last ungated channel
// into ~/.claude/projects/**/*.jsonl (Bash/PowerShell/Read gates already cover
// it). Memory files there are .md and stay writable (they never match .jsonl$).
describe('enforce-runtime-write-deny — transcript .jsonl protection (Z Part 1)', () => {
it('blocks a Write to a subagent transcript under ~/.claude/projects', () => {
const p = join(HOME, '.claude', 'projects', 'slug', 'sess-uuid', 'subagents', 'agent-abc.jsonl');
expect(decide({ toolName: 'Write', filePath: p }).block).toBe(true);
});
it('blocks a Write to the controller session transcript itself', () => {
const p = join(HOME, '.claude', 'projects', 'slug', 'sess-uuid.jsonl');
expect(decide({ toolName: 'Write', filePath: p }).block).toBe(true);
});
it('blocks Edit/MultiEdit/NotebookEdit on a transcript .jsonl too', () => {
const p = join(HOME, '.claude', 'projects', 'slug', 'sess', 'subagents', 'agent-x.jsonl');
expect(decide({ toolName: 'Edit', filePath: p }).block).toBe(true);
expect(decide({ toolName: 'MultiEdit', filePath: p }).block).toBe(true);
expect(decide({ toolName: 'NotebookEdit', filePath: p }).block).toBe(true);
});
it('blocks the .-segment evasion into projects transcripts', () => {
const evasion = `${HOME_FWD}/.claude/projects/slug/./sess/subagents/agent-x.jsonl`;
expect(decide({ toolName: 'Write', filePath: evasion }).block).toBe(true);
});
it('ALLOWS a memory .md under ~/.claude/projects (never a .jsonl)', () => {
const p = join(HOME, '.claude', 'projects', 'slug', 'memory', 'feedback_x.md');
expect(decide({ toolName: 'Write', filePath: p }).block).toBe(false);
});
it('ALLOWS a .jsonl OUTSIDE ~/.claude/projects (e.g. repo observer episodes)', () => {
const p = join(HOME, 'repo', 'docs', 'observer', 'episodes-2026-05.jsonl');
expect(decide({ toolName: 'Write', filePath: p }).block).toBe(false);
});
it('ignores non-write tools on a transcript path', () => {
const p = join(HOME, '.claude', 'projects', 'slug', 'sess', 'subagents', 'agent-x.jsonl');
expect(decide({ toolName: 'Read', filePath: p }).block).toBe(false);
});
});
+75 -12
View File
@@ -27,6 +27,7 @@ import {
isProductionCodePath,
readRouterState,
} from './enforce-hook-helpers.mjs';
import { join, dirname, basename } from 'node:path';
const RULE_KEY_TDD = 'tdd-gate';
const RULE_KEY_PLAN = 'writing-plans-required';
@@ -108,11 +109,6 @@ function hasFailingTestRun(turn) {
// Numeric: "Tests N failed | M passed" with N>0
const m = txt.match(/Tests\s+(\d+)\s+failed/);
if (m && Number(m[1]) > 0) return true;
// JSON reporter (composer test / php artisan test → pest): {"result":"failed",...}
// or {"failed":N}/{"errors":N} with N>0. command-not-found / error REDs lack the
// English "Failed" keyword above, so recognise the structured marker too.
if (/"result"\s*:\s*"failed"/.test(txt)) return true;
if (/"(?:failed|errors)"\s*:\s*[1-9]/.test(txt)) return true;
}
}
}
@@ -137,8 +133,56 @@ function hasPlanIndicator(turn) {
return false;
}
const AGENT_ID_RE = /agentId:\s*([0-9a-f]+)/i;
/**
* Cross-actor (Z Part 2): extract agentIds of subagents spawned by a `Task`
* tool in the controller's current turn. The agentId comes from the harness-
* written Task tool_result text ("agentId: <hex>") the controller cannot forge
* a tool_result in its own transcript. Only hex ids are accepted, so a crafted
* "agentId: ../../x" cannot become a path-traversal into an arbitrary file.
*/
export function turnTaskAgentIds(turn) {
const taskUseIds = new Set();
for (const e of turn || []) {
const c = e && e.message && e.message.content;
if (!Array.isArray(c)) continue;
for (const b of c) {
if (b && b.type === 'tool_use' && b.name === 'Task') taskUseIds.add(b.id);
}
}
const ids = [];
for (const e of turn || []) {
const c = e && e.message && e.message.content;
if (!Array.isArray(c)) continue;
for (const b of c) {
if (!b || b.type !== 'tool_result' || !taskUseIds.has(b.tool_use_id)) continue;
const txt = typeof b.content === 'string' ? b.content
: Array.isArray(b.content) ? b.content.map((p) => p && p.text).filter(Boolean).join('\n') : '';
const m = txt.match(AGENT_ID_RE);
if (m) ids.push(m[1]);
}
}
return ids;
}
/**
* Derive subagent transcript paths from the controller transcript path and a
* list of agentIds. Subagent transcripts live at
* <projects>/<slug>/<controller-session>/subagents/agent-<agentId>.jsonl
* i.e. nested under the controller session's own directory (bound to it), while
* the controller transcript is <...>/<controller-session>.jsonl.
*/
export function subagentTranscriptPaths(controllerTranscriptPath, agentIds) {
const p = String(controllerTranscriptPath || '');
if (!p) return [];
const dir = dirname(p);
const base = basename(p).replace(/\.jsonl$/i, '');
return (agentIds || []).map((id) => join(dir, base, 'subagents', `agent-${id}.jsonl`));
}
export function decide({
toolName, filePath, transcriptEntries, classification, override, overridePlan,
toolName, filePath, transcriptEntries, classification, override, overridePlan, subagentEntriesList = [],
}) {
if (!['Edit', 'Write', 'MultiEdit'].includes(toolName)) return { block: false };
if (!isProductionCodePath(filePath)) return { block: false };
@@ -160,24 +204,31 @@ export function decide({
}
}
// Rule #3 — TDD gate.
// Rule #3 — TDD gate. Credit the controller's own turn OR a subagent that was
// spawned by a Task in this turn (cross-actor, Z Part 2). Subagent evidence is
// read from its agent-<id>.jsonl, which is tamper-protected by the transcript
// Write-deny (Z Part 1) — so crediting it does not open a forgery channel.
if (override) return { block: false };
const hasTest = hasMatchingTestEdit(turn, filePath);
const subList = Array.isArray(subagentEntriesList) ? subagentEntriesList : [];
const hasTest = hasMatchingTestEdit(turn, filePath) || subList.some((es) => hasMatchingTestEdit(es, filePath));
if (!hasTest) {
return {
block: true,
message: [
`[enforce-tdd-gate] Production code edit on "${filePath}" without preceding test edit.`,
`Write the failing test FIRST in the corresponding *.test.mjs / *.spec.ts / *Test.php.`,
`Write the failing test FIRST in the corresponding *.test.mjs / *.spec.ts / *Test.php`,
`(a subagent's test edit, if it was spawned by a Task in this turn, is also credited).`,
`Then run vitest/pest to confirm RED, then return to this prod-code Edit.`,
].join('\n'),
};
}
if (!hasFailingTestRun(turn)) {
const hasRed = hasFailingTestRun(turn) || subList.some((es) => hasFailingTestRun(es));
if (!hasRed) {
return {
block: true,
message: [
`[enforce-tdd-gate] Test was edited but no vitest/pest run with RED output observed in this turn.`,
`[enforce-tdd-gate] Test was edited but no vitest/pest run with RED output observed in this turn`,
`(nor in any in-turn subagent transcript).`,
`Run the test suite (vitest run <test-file> / composer test) to confirm RED before prod-code edit.`,
].join('\n'),
};
@@ -204,7 +255,19 @@ async function main() {
task_type: state.classification.task_type,
} : null;
const result = decide({ toolName, filePath, transcriptEntries: transcript, classification, override, overridePlan });
// Cross-actor (Z Part 2): read transcripts of subagents spawned by a Task in
// this turn, bound to the controller session via the derived path. Best-effort
// — a missing/unreadable subagent transcript just yields no extra credit
// (stricter), never an error.
let subagentEntriesList = [];
try {
const turn = lastTurnEntries(transcript);
const agentIds = turnTaskAgentIds(turn);
const paths = subagentTranscriptPaths(event.transcript_path, agentIds);
subagentEntriesList = paths.map((p) => readTranscript(p)).filter((e) => Array.isArray(e) && e.length);
} catch { subagentEntriesList = []; }
const result = decide({ toolName, filePath, transcriptEntries: transcript, classification, override, overridePlan, subagentEntriesList });
exitDecision(result);
} catch {
exitDecision({ block: false });
+75 -23
View File
@@ -1,5 +1,79 @@
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-tdd-gate.mjs';
import { decide, turnTaskAgentIds, subagentTranscriptPaths } from './enforce-tdd-gate.mjs';
// Z Part 2 (2026-05-31): the tdd-gate must credit a subagent's test edit + RED
// when that subagent was spawned by a Task in the controller's current turn.
// Pairs with the transcript Write-hole closed in enforce-runtime-write-deny.mjs
// (Z Part 1) so the credited agent-<id>.jsonl cannot be forged.
describe('enforce-tdd-gate Z cross-actor (pairs with enforce-runtime-write-deny Part 1)', () => {
const subagentRedRun = [
{ message: { role: 'user', content: 'write the failing test for foo and confirm RED' } },
{ message: { role: 'assistant', content: [
{ type: 'tool_use', id: 's1', name: 'Write', input: { file_path: 'tools/foo.test.mjs' } },
{ type: 'tool_use', id: 's2', name: 'Bash', input: { command: 'npx vitest run tools/foo.test.mjs' } },
] } },
{ message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 's2', content: 'Tests 1 failed | 0 passed' } ] } },
];
it('credits a subagent test edit + RED for the controller prod edit', () => {
const r = decide({
toolName: 'Edit',
filePath: 'tools/foo.mjs',
transcriptEntries: [
{ message: { role: 'user', content: 'delegate the test, then I implement' } },
{ message: { role: 'assistant', content: [ { type: 'tool_use', id: 't1', name: 'Task', input: { subagent_type: 'tester' } } ] } },
{ message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 't1', content: 'done. agentId: a1234abcd' } ] } },
],
subagentEntriesList: [subagentRedRun],
});
expect(r.block).toBe(false);
});
it('still blocks when subagent edited a test but NO RED exists anywhere', () => {
const subNoRed = [
{ message: { role: 'user', content: 'write test' } },
{ message: { role: 'assistant', content: [ { type: 'tool_use', id: 's1', name: 'Write', input: { file_path: 'tools/foo.test.mjs' } } ] } },
];
const r = decide({
toolName: 'Edit', filePath: 'tools/foo.mjs',
transcriptEntries: [ { message: { role: 'user', content: 'go' } } ],
subagentEntriesList: [subNoRed],
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/RED/);
});
it('preserves old behavior when no subagent entries (blocks without test)', () => {
const r = decide({
toolName: 'Edit', filePath: 'tools/foo.mjs',
transcriptEntries: [ { message: { role: 'user', content: 'go' } } ],
subagentEntriesList: [],
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/without preceding test edit/);
});
it('turnTaskAgentIds extracts a hex agentId from an in-turn Task tool_result', () => {
const turn = [
{ message: { role: 'assistant', content: [ { type: 'tool_use', id: 't1', name: 'Task', input: {} } ] } },
{ message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 't1', content: 'ok agentId: a1b2c3d4e5' } ] } },
];
expect(turnTaskAgentIds(turn)).toContain('a1b2c3d4e5');
});
it('turnTaskAgentIds ignores non-Task results and rejects non-hex ids (no path traversal)', () => {
const turn = [
{ message: { role: 'assistant', content: [ { type: 'tool_use', id: 'b1', name: 'Bash', input: {} } ] } },
{ message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 'b1', content: 'agentId: ../../evil' } ] } },
];
expect(turnTaskAgentIds(turn)).toHaveLength(0);
});
it('subagentTranscriptPaths derives <dir>/<sessbase>/subagents/agent-<id>.jsonl', () => {
const paths = subagentTranscriptPaths('/p/projects/slug/sessUUID.jsonl', ['a1b2']);
expect(paths[0].split('\\').join('/')).toBe('/p/projects/slug/sessUUID/subagents/agent-a1b2.jsonl');
});
});
function userMsg(text) {
return { message: { role: 'user', content: text } };
@@ -168,25 +242,3 @@ describe('enforce-tdd-gate / decide', () => {
expect(r.block).toBe(false);
});
});
describe('enforce-tdd-gate / decide — JSON pest reporter RED (composer test)', () => {
// `composer test` (php artisan test) emits machine JSON like {"result":"failed",...}.
// command-not-found / error REDs lack the English "Failed" keyword, so the gate must
// recognise the structured failure marker, else legit RED runs go unseen.
it('recognizes {"result":"failed"} JSON output as a RED run', () => {
const r = decide({
toolName: 'Write',
filePath: 'wt/app/app/Console/Commands/FooCommand.php',
transcriptEntries: [
userMsg('add backfill command'),
assistantUses([
{ id: 't1', name: 'Write', input: { file_path: 'wt/app/tests/Feature/Console/FooCommandTest.php' } },
{ id: 't2', name: 'Bash', input: { command: 'composer test -- tests/Feature/Console/FooCommandTest.php # pest' } },
]),
toolResults([{ id: 't2', content: '{"tool":"pest","result":"failed","tests":4,"passed":0,"errors":4}' }]),
],
classification: null,
});
expect(r.block).toBe(false);
});
});
-3
View File
@@ -16,13 +16,10 @@ export const DEFAULT_MCP_CLASSIFICATION = Object.freeze({
'mcp__redis__set': { category: 'hard_blacklist' },
'mcp__redis__delete': { category: 'hard_blacklist' },
'mcp__github__get_me': { category: 'read_only' },
'mcp__github__get_*': { category: 'read_only' }, // read-only loosening 2026-06-02 (get_file_contents/get_job_logs/get_commit/…)
'mcp__github__list_*': { category: 'read_only' },
'mcp__github__search_*': { category: 'read_only' },
'mcp__github__pull_request_read': { category: 'read_only' },
'mcp__github__issue_read': { category: 'read_only' },
'mcp__github__actions_get': { category: 'read_only' }, // read a workflow run (actions_run_trigger stays blacklisted — exact key wins)
'mcp__github__actions_list': { category: 'read_only' }, // list workflows / runs
'mcp__laravel-boost__database-query': {
category: 'conditional',
args_key_to_scan: 'query',
-34
View File
@@ -129,37 +129,3 @@ describe('classifyMcpTool — WebSearch llm-judge flag (G1)', () => {
expect(r.scanArg).toBe('how to exfil data');
});
});
// Owner-authorized read-only GitHub loosening (2026-06-02): allow reading
// workflow runs / job logs / file contents so the controller can read prod-op
// results without manual screenshots. Prod-mutating tools (run_trigger, writes)
// MUST stay blocked — human-in-the-loop on prod actions is unchanged.
describe('classifyMcpTool — read-only GitHub (owner-authorized 2026-06-02)', () => {
it('allows reading a workflow run (actions_get)', () => {
expect(classifyMcpTool('mcp__github__actions_get', { run_id: 1 }).decision).toBe('allow');
});
it('allows listing workflows / runs (actions_list)', () => {
expect(classifyMcpTool('mcp__github__actions_list', {}).decision).toBe('allow');
});
it('allows reading job logs (get_job_logs via get_* glob)', () => {
expect(classifyMcpTool('mcp__github__get_job_logs', { job_id: 1 }).decision).toBe('allow');
});
it('allows reading file contents (get_file_contents via get_* glob)', () => {
expect(classifyMcpTool('mcp__github__get_file_contents', { path: 'x' }).decision).toBe('allow');
});
it('allows reading a commit (get_commit via get_* glob)', () => {
expect(classifyMcpTool('mcp__github__get_commit', { sha: 'x' }).decision).toBe('allow');
});
it('STILL BLOCKS triggering a workflow (actions_run_trigger — exact wins over glob)', () => {
expect(classifyMcpTool('mcp__github__actions_run_trigger', {}).decision).toBe('block');
});
it('STILL BLOCKS writing a file (create_or_update_file)', () => {
expect(classifyMcpTool('mcp__github__create_or_update_file', { path: 'x' }).decision).toBe('block');
});
it('STILL BLOCKS push_files', () => {
expect(classifyMcpTool('mcp__github__push_files', {}).decision).toBe('block');
});
it('STILL BLOCKS update_pull_request (write)', () => {
expect(classifyMcpTool('mcp__github__update_pull_request', {}).decision).toBe('block');
});
});
+1 -1
View File
@@ -24,7 +24,7 @@ export function computeWorkspaceHash(workspacePath) {
return createHash('md5').update(String(workspacePath || ''), 'utf-8').digest('hex').slice(0, 12);
}
function isStale(record, now) {
export function isStale(record, now) {
if (!record || typeof record !== 'object') return true;
const ttl = typeof record.ttl_ms === 'number' ? record.ttl_ms : LOCK_DEFAULT_TTL_MS;
return now - (record.acquired_at || 0) > ttl;
+21
View File
@@ -6,6 +6,7 @@ import {
release,
refresh,
computeWorkspaceHash,
isStale,
LOCK_DEFAULT_TTL_MS,
} from './parallel-session-lock.mjs';
@@ -91,6 +92,26 @@ describe('parallel-session-lock pure module (Stream H Task 7)', () => {
});
});
// isStale is exported (B, 2026-05-31) so the wrapper's prune step reuses the
// EXACT same staleness definition — single source of truth, no divergence that
// could ever prune a still-fresh (active) lock.
describe('isStale (exported for prune support)', () => {
it('true when now - acquired_at exceeds ttl_ms', () => {
expect(isStale({ acquired_at: 0, ttl_ms: 100 }, 1000)).toBe(true);
});
it('false when still within ttl (active lock — never pruned)', () => {
expect(isStale({ acquired_at: 900, ttl_ms: 1000 }, 1000)).toBe(false);
});
it('true for a malformed/missing record', () => {
expect(isStale(null, 1000)).toBe(true);
expect(isStale(undefined, 1000)).toBe(true);
});
it('uses the default TTL when ttl_ms is absent', () => {
expect(isStale({ acquired_at: 0 }, LOCK_DEFAULT_TTL_MS + 1)).toBe(true);
expect(isStale({ acquired_at: 0 }, LOCK_DEFAULT_TTL_MS - 1)).toBe(false);
});
});
describe('computeWorkspaceHash (Stream H Task 7)', () => {
it('returns 12 hex chars', () => {
const h = computeWorkspaceHash('/some/path');
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env node
/**
* receipt-key-config резолв HMAC-ключа подписи расписок роутер-наставника.
* Зеркало llm-judge-config.mjs: keychain (service router-mentor-receipts) env null.
* Ключ провижинит владелец (как LLM-ключ судьи). Без ключа null подпись невозможна
* неподписанная расписка невалидна (fail-closed на стороне verifyReceipt).
*/
import { createRequire } from 'node:module';
const KEY_ENV = 'ROUTER_MENTOR_RECEIPT_KEY';
const KEYCHAIN_SERVICE = 'router-mentor-receipts';
const KEYCHAIN_ACCOUNT = 'default';
/**
* @param {object} [args]
* @param {object} [args.env] - окружение (default process.env)
* @param {Function} [args.keychainGet] - () => string|null (инъекция для тестов)
* @returns {string|null}
*/
export function resolveReceiptKey({ env = process.env, keychainGet = defaultKeychainGet } = {}) {
let chainKey = null;
try {
const v = keychainGet();
chainKey = v ? String(v) : null;
} catch {
chainKey = null;
}
const envKey = env[KEY_ENV] ? String(env[KEY_ENV]) : null;
return chainKey || envKey || null;
}
/** Lazy keytar-ридер; null если keytar отсутствует или записи нет. Никогда не бросает. */
export function defaultKeychainGet() {
try {
const require = createRequire(import.meta.url);
const keytar = require('keytar');
return (keytar.getPasswordSync?.(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)) || null;
} catch {
return null;
}
}
export const _internals = { KEY_ENV, KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT };
-25
View File
@@ -1,25 +0,0 @@
// tools/receipt-key-config.test.mjs
import { describe, it, expect } from 'vitest';
import { resolveReceiptKey } from './receipt-key-config.mjs';
describe('resolveReceiptKey', () => {
it('returns keychain key when present', () => {
const r = resolveReceiptKey({ env: {}, keychainGet: () => 'k-from-chain' });
expect(r).toBe('k-from-chain');
});
it('falls back to env ROUTER_MENTOR_RECEIPT_KEY when keychain empty', () => {
const r = resolveReceiptKey({ env: { ROUTER_MENTOR_RECEIPT_KEY: 'k-env' }, keychainGet: () => null });
expect(r).toBe('k-env');
});
it('prefers keychain over env', () => {
const r = resolveReceiptKey({ env: { ROUTER_MENTOR_RECEIPT_KEY: 'k-env' }, keychainGet: () => 'k-chain' });
expect(r).toBe('k-chain');
});
it('returns null when neither present', () => {
expect(resolveReceiptKey({ env: {}, keychainGet: () => null })).toBe(null);
});
it('never throws when keychainGet throws → null', () => {
const r = resolveReceiptKey({ env: {}, keychainGet: () => { throw new Error('boom'); } });
expect(r).toBe(null);
});
});
+5 -30
View File
@@ -164,13 +164,9 @@ const GIT_READONLY_SUB = new Set([
'rev-parse', 'merge-base', 'remote', 'stash', // stash list/show resolved below
'fetch', 'ls-remote', // ref-only, no working-tree mutation — Stream H pre-flight requires §15.2 sync
]);
// dev-safe (owner-authorized 2026-06-02 re-scope): allow без approval. GIT_HARD_PATTERNS
// (--no-verify / add -f / -c / force / --output / -o) пре-фильтруют опасные варианты ВЫШЕ.
const GIT_DEV_SUB = new Set([
'add', 'commit', 'branch', 'switch', 'checkout', 'stash', 'worktree',
]);
const GIT_CONDITIONAL_SUB = new Set([
'merge', 'rebase', 'reset', 'cherry-pick', 'revert', 'pull', 'clean',
'add', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch',
'branch', 'stash', 'cherry-pick', 'revert', 'pull', 'push', 'clean',
]);
// G5/G6 + force-push + add -f → always block (даже если "approved").
@@ -187,23 +183,14 @@ const GIT_HARD_PATTERNS = [
];
function gitSubcommand(command) {
// Skip leading global flags `-c <val>` and `-C <path>`. `git -C <dir> <sub>` is the
// cwd-independent way to operate on a worktree (the shell resets cwd each call), so the
// real subcommand must be found after `-C`. `-C` (uppercase, working-dir) is case-distinct
// from the blocked `-c` config-injection (GIT_HARD_PATTERNS still scans the full command).
const m = normalizeCommand(command).match(
/\bgit\s+(?:(?:-c\s+\S+|-C\s+(?:"[^"]*"|'[^']*'|\S+))\s+)*([a-z][\w-]*)/,
);
const m = normalizeCommand(command).match(/\bgit\s+(?:-c\s+\S+\s+)*([a-z][\w-]*)/);
return m ? m[1] : null;
}
export function classifyGitCommand(command, ctx = {}) {
// Strip a leading `git -C <path>` (worktree-dir flag) so every rule below sees the real
// subcommand+flags. Without this, position-anchored hard-patterns (--no-verify / --force /
// add -f) and the push-main-guard would be bypassed by interposing `-C <dir>`.
const norm = normalizeCommand(command).replace(/(\bgit)\s+-C\s+(?:"[^"]*"|'[^']*'|\S+)\s+/, '$1 ');
const norm = normalizeCommand(command);
if (!/\bgit\b/.test(norm)) return null;
const sub = gitSubcommand(norm);
const sub = gitSubcommand(command);
if (!sub) return null;
// 1. git-hard — block безусловно
@@ -225,18 +212,6 @@ export function classifyGitCommand(command, ctx = {}) {
return { result: 'block', reason: 'git remote (мутация) требует AskUser approval' };
}
// dev-safe git (owner-authorized 2026-06-02 re-scope): GIT_HARD_PATTERNS уже отсеяли
// опасные варианты (--no-verify / add -f / -c / force / --output / -o) на шаге 1.
if (GIT_DEV_SUB.has(sub)) return { result: 'allow', reason: `dev-safe git ${sub}` };
// push: фичевые ветки — allow; main/master — клик владельца (force уже заблокирован hard).
if (sub === 'push') {
if (/\b(?:main|master)\b/.test(norm)) {
return { result: 'block', reason: 'git push в main/master — клик владельца' };
}
return { result: 'allow', reason: 'git push в фичевую ветку' };
}
// 3. conditional → approve check
if (GIT_CONDITIONAL_SUB.has(sub)) {
const approved = isApproved(command, ctx.approvedGitOps, ctx.now ?? Date.now());
+24 -65
View File
@@ -167,78 +167,37 @@ describe('classifyGitCommand — readonly', () => {
);
});
describe('classifyGitCommand — conditional (still needs approval after 2026-06-02 re-scope)', () => {
describe('classifyGitCommand — conditional after approve', () => {
const now = 2_000_000;
it('blocks unapproved rebase/reset/merge/cherry-pick/revert/pull/clean', () => {
for (const cmd of ['git rebase main', 'git reset --hard', 'git merge feat',
'git cherry-pick abc', 'git revert abc', 'git pull', 'git clean -fd']) {
expect(classifyGitCommand(cmd, { approvedGitOps: [], now }).result).toBe('block');
}
it('blocks unapproved git commit', () => {
const r = classifyGitCommand('git commit -m "x"', { approvedGitOps: [], now });
expect(r.result).toBe('block');
expect(r.reason).toMatch(/approve/i);
});
it('allows approved git merge', () => {
const r = classifyGitCommand('git merge feat', {
approvedGitOps: [{ command: 'git merge feat', ts: now }],
it('allows approved git commit', () => {
const r = classifyGitCommand('git commit -m "x"', {
approvedGitOps: [{ command: 'git commit -m "x"', ts: now }],
now,
});
expect(r.result).toBe('allow');
});
});
describe('classifyGitCommand — dev-allow (owner-authorized 2026-06-02 re-scope)', () => {
const na = { approvedGitOps: [], now: 2_000_000 };
it('allows commit/add/branch/switch/checkout/stash/worktree without approval', () => {
for (const cmd of [
'git commit -m "x"', 'git add .', 'git branch feature-x',
'git switch -c feature-x', 'git switch feature-x', 'git checkout -b feature-x',
'git stash push -m wip', 'git stash pop',
'git worktree add ../wt -b feat origin/main',
]) {
expect(classifyGitCommand(cmd, na).result).toBe('allow');
}
it.each(['git rebase main', 'git reset --hard', 'git switch main', 'git stash pop', 'git push origin feat'])(
'blocks unapproved %s',
(cmd) => {
expect(classifyGitCommand(cmd, { approvedGitOps: [], now }).result).toBe('block');
},
);
it('blocks unapproved git add (v4 Stream G addition)', () => {
const r = classifyGitCommand('git add .claude/settings.json', { approvedGitOps: [], now });
expect(r.result).toBe('block');
expect(r.reason).toMatch(/approve/i);
});
it('still blocks commit --no-verify and add -f (hard patterns survive dev-allow)', () => {
expect(classifyGitCommand('git commit --no-verify -m x', na).result).toBe('block');
expect(classifyGitCommand('git add -f ignored.txt', na).result).toBe('block');
});
});
describe('classifyGitCommand — push main-guard (owner-authorized 2026-06-02 re-scope)', () => {
const na = { approvedGitOps: [], now: 2_000_000 };
it('allows push to a feature branch / bare push', () => {
expect(classifyGitCommand('git push origin worktree-lead-region-tails', na).result).toBe('allow');
expect(classifyGitCommand('git push', na).result).toBe('allow');
expect(classifyGitCommand('git push -u origin feature-x', na).result).toBe('allow');
});
it('blocks push to main/master (owner click)', () => {
expect(classifyGitCommand('git push origin main', na).result).toBe('block');
expect(classifyGitCommand('git push origin HEAD:main', na).result).toBe('block');
expect(classifyGitCommand('git push origin master', na).result).toBe('block');
});
it('blocks force-push (hard pattern unchanged)', () => {
expect(classifyGitCommand('git push --force origin feature-x', na).result).toBe('block');
expect(classifyGitCommand('git push origin feature-x --force-with-lease', na).result).toBe('block');
});
});
describe('classifyGitCommand — git -C <path> (worktree dev, 2026-06-02)', () => {
const na = { approvedGitOps: [], now: 4_000_000 };
// git -C points git at another working tree (cwd resets each shell call, so this is
// the cwd-independent way to commit in a worktree). Classify by the REAL subcommand
// after -C, with all hard-patterns / push-main-guard still applied to the full command.
it.each([
'git -C "C:\\моя\\проекты\\портал crm\\worktree-x" commit -m "y"',
'git -C "C:\\моя\\проекты\\портал crm\\worktree-x" add app/foo.php',
'git -C "/path/worktree-x" push origin feature-y',
'git -C /repo status',
])('classifies by real subcommand after -C: %s', (cmd) => {
expect(classifyGitCommand(cmd, na).result).toBe('allow');
});
it('still blocks push to main even with -C', () => {
expect(classifyGitCommand('git -C /repo push origin main', na).result).toBe('block');
});
it('still blocks --no-verify even with -C', () => {
expect(classifyGitCommand('git -C /repo commit --no-verify -m x', na).result).toBe('block');
it('allows approved git add', () => {
const r = classifyGitCommand('git add .claude/settings.json', {
approvedGitOps: [{ command: 'git add .claude/settings.json', ts: now }],
now,
});
expect(r.result).toBe('allow');
});
});