Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ce2f0058d | |||
| d35fefddd9 | |||
| e56ddd6a1b | |||
| 53407a77cd | |||
| 6577c04a1f | |||
| 7a469dc913 | |||
| be4e1a6123 |
+15
-140
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+5
-439
@@ -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",
|
||||
|
||||
@@ -51,8 +51,5 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-vue-next": "^1.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"keytar": "^7.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('Поиск по телефону');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,10 +1,7 @@
|
||||
{
|
||||
"2026-05": {
|
||||
"WIN_USER_PATH": 206,
|
||||
"WIN_USER_PATH": 123,
|
||||
"IPV4": 1,
|
||||
"RU_PHONE": 1
|
||||
},
|
||||
"2026-06": {
|
||||
"WIN_USER_PATH": 91
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 39–262):** five v3.9 hook blocks present at:
|
||||
|
||||
- PreToolUse[3] (lines 69–78) — `enforce-chain-recommendation` — REMOVE
|
||||
- PreToolUse[4] (lines 79–88) — `enforce-override-limit` — REMOVE
|
||||
- PreToolUse[7] (lines 119–128) — `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.
|
||||
- Не ослабляем дисциплину.
|
||||
@@ -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 });
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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); реклама фраз
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user