@event4u/agent-config 9.34.0 → 9.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +91 -0
- package/CONTRIBUTING.md +23 -14
- package/dist/agent-src/commands/roadmap/next.md +37 -5
- package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
- package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
- package/dist/cli/registry.js +2 -2
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-REMEZSVP.js → chunk-2TGMGLBL.js} +2 -2
- package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
- package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-H6F2CQFI.js} +1 -1
- package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-ZAXH3ZWU.js} +3 -0
- package/dist/cli-delegate/cmd_conformance.js +2 -2
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +259 -78
- package/dist/install/install.mjs +3 -0
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +74 -2
- package/dist/server/schemas/settings.js +1 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +3 -2
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +119 -4
- package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
- package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
- package/src/config/agent-settings.template.yml +11 -2
- package/src/config/quorum-attendance-budget.json +10 -2
- package/src/scripts/_dispatch.bash +6 -2
- package/src/scripts/_lib/session_register.ts +67 -1
- package/src/scripts/ai_council/config.ts +60 -0
- package/src/scripts/ai_council/events_log.ts +103 -4
- package/src/scripts/ai_council/quorum.ts +83 -6
- package/src/scripts/council_cli.ts +36 -1
- package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
- package/src/scripts/lint_framework_leakage.ts +195 -28
- package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
- package/src/scripts/memory_lookup.ts +1 -2
- package/src/scripts/schemas/command.schema.json +4 -0
- package/src/scripts/schemas/skill.schema.json +4 -0
- package/src/scripts/session_register_hook.ts +238 -18
- package/src/scripts/sessions_cli.ts +377 -12
- package/src/server/schemas/settings.ts +3 -0
|
@@ -1,152 +1,160 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"_doc": "Each entry: { file: relative path from repo root,
|
|
3
|
+
"_doc": "Each entry: { file: relative path from repo root, anchor: a substring of the exempted line (the ONLY per-line key — position keys are refused by validate_allowlist), or lines: \"*\" for the whole file, reason: short justification }. Use sparingly — first ask whether the file should be neutralized or carved out. Policy cap: > 20 exempted FILES means the linter (or the content) is wrong — fix the tool or the text, never grow the list. The cap counts files because that is the unit it was written in, when one entry meant one file; entries are now per-line, so the two numbers differ on purpose (15 files / 31 entries as of the anchor migration) and the entry count is not the cap. Rebuilt 2026-07-12 (road-to-opt-hygiene-and-debt Phase 1): 104 -> minimal set after heuristic upgrades + 25 content neutralizations + 2 carve-outs. Re-keyed to content anchors 2026-08-12: 8 of the 39 exempted lines were found to exempt nothing — three named blank or out-of-range lines, five named lines that produce no hit — and were dropped rather than carried forward; the gate now refuses both shapes.",
|
|
4
4
|
"entries": [
|
|
5
|
-
{
|
|
6
|
-
"file": "src/rules/code-comment-discipline.md",
|
|
7
|
-
"lines": [
|
|
8
|
-
100
|
|
9
|
-
],
|
|
10
|
-
"reason": "Worked PHP docblock demo inside the rule's own wrong/right example block — quoted teaching content."
|
|
11
|
-
},
|
|
12
5
|
{
|
|
13
6
|
"file": "src/skills/api-testing/SKILL.md",
|
|
14
|
-
"
|
|
15
|
-
218
|
|
16
|
-
],
|
|
7
|
+
"anchor": "- Don't test framework internals (e.g., \"does Laravel return",
|
|
17
8
|
"reason": "Quoted example of a framework-internal behavior explicitly marked as what NOT to test — meta content, not a mandate."
|
|
18
9
|
},
|
|
19
10
|
{
|
|
20
11
|
"file": "src/skills/learning-to-rule-or-skill/SKILL.md",
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
12
|
+
"anchor": "Decision: Create focused skill for Laravel route inspection ",
|
|
13
|
+
"reason": "Example learnings/decisions quoted verbatim in the promotion-flow walkthrough — meta content."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"file": "src/skills/learning-to-rule-or-skill/SKILL.md",
|
|
17
|
+
"anchor": "Learning: \"I forgot to run PHPStan once.\"",
|
|
25
18
|
"reason": "Example learnings/decisions quoted verbatim in the promotion-flow walkthrough — meta content."
|
|
26
19
|
},
|
|
27
20
|
{
|
|
28
21
|
"file": "src/skills/markitdown/SKILL.md",
|
|
29
|
-
"
|
|
30
|
-
74,
|
|
31
|
-
111
|
|
32
|
-
],
|
|
22
|
+
"anchor": "uv pip install 'markitdown-mcp==0.0.1a4'",
|
|
33
23
|
"reason": "Tool-intrinsic install lines — markitdown IS a pip package; the install command is the tool's identity, not stack leakage."
|
|
34
24
|
},
|
|
25
|
+
{
|
|
26
|
+
"file": "src/skills/markitdown/SKILL.md",
|
|
27
|
+
"anchor": "Plugins enable arbitrary code paths inside the conversion pi",
|
|
28
|
+
"reason": "Tool-intrinsic install lines — markitdown IS a pip package; the install command is the tool's identity, not stack leakage."
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"file": "src/skills/module-management/SKILL.md",
|
|
32
|
+
"anchor": "### Laravel HMVC carve-out",
|
|
33
|
+
"reason": "Canonical multi-stack module catalogue (Laravel HMVC / Symfony DDD / Node / Python sections) — keep-in-place is a recorded council decision; per-stack sections sit farther apart than the ±10-line window."
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"file": "src/skills/module-management/SKILL.md",
|
|
37
|
+
"anchor": "│ ├── api.php web.php console.php",
|
|
38
|
+
"reason": "Canonical multi-stack module catalogue (Laravel HMVC / Symfony DDD / Node / Python sections) — keep-in-place is a recorded council decision; per-stack sections sit farther apart than the ±10-line window."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"file": "src/skills/module-management/SKILL.md",
|
|
42
|
+
"anchor": "**Auto-loading:** `app/Providers/ModuleServiceProvider.php` ",
|
|
43
|
+
"reason": "Canonical multi-stack module catalogue (Laravel HMVC / Symfony DDD / Node / Python sections) — keep-in-place is a recorded council decision; per-stack sections sit farther apart than the ±10-line window."
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"file": "src/skills/module-management/SKILL.md",
|
|
47
|
+
"anchor": "`app/Modules/` and registers routes (`Routes/api.php` → `/ap",
|
|
48
|
+
"reason": "Canonical multi-stack module catalogue (Laravel HMVC / Symfony DDD / Node / Python sections) — keep-in-place is a recorded council decision; per-stack sections sit farther apart than the ±10-line window."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"file": "src/skills/module-management/SKILL.md",
|
|
52
|
+
"anchor": "`api` middleware; `Routes/web.php` → `web` middleware) plus ",
|
|
53
|
+
"reason": "Canonical multi-stack module catalogue (Laravel HMVC / Symfony DDD / Node / Python sections) — keep-in-place is a recorded council decision; per-stack sections sit farther apart than the ±10-line window."
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"file": "src/skills/module-management/SKILL.md",
|
|
57
|
+
"anchor": "// Routes/api.php — auto-prefixed /api",
|
|
58
|
+
"reason": "Canonical multi-stack module catalogue (Laravel HMVC / Symfony DDD / Node / Python sections) — keep-in-place is a recorded council decision; per-stack sections sit farther apart than the ±10-line window."
|
|
59
|
+
},
|
|
35
60
|
{
|
|
36
61
|
"file": "src/skills/module-management/SKILL.md",
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
115,
|
|
44
|
-
124,
|
|
45
|
-
156,
|
|
46
|
-
172
|
|
47
|
-
],
|
|
62
|
+
"anchor": "### Symfony DDD-lite carve-out",
|
|
63
|
+
"reason": "Canonical multi-stack module catalogue (Laravel HMVC / Symfony DDD / Node / Python sections) — keep-in-place is a recorded council decision; per-stack sections sit farther apart than the ±10-line window."
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"file": "src/skills/module-management/SKILL.md",
|
|
67
|
+
"anchor": "metadata in `pyproject.toml`.",
|
|
48
68
|
"reason": "Canonical multi-stack module catalogue (Laravel HMVC / Symfony DDD / Node / Python sections) — keep-in-place is a recorded council decision; per-stack sections sit farther apart than the ±10-line window."
|
|
49
69
|
},
|
|
50
70
|
{
|
|
51
71
|
"file": "src/skills/po-discovery/SKILL.md",
|
|
52
|
-
"
|
|
53
|
-
45
|
|
54
|
-
],
|
|
72
|
+
"anchor": "(\"upgrade Laravel to 11\") — discovery adds noise; skip.",
|
|
55
73
|
"reason": "Quoted example ticket title ('upgrade Laravel to 11') illustrating when discovery adds noise — meta content."
|
|
56
74
|
},
|
|
57
75
|
{
|
|
58
76
|
"file": "src/skills/repomix-packer/SKILL.md",
|
|
59
|
-
"
|
|
60
|
-
44
|
|
61
|
-
],
|
|
77
|
+
"anchor": "npm install -g repomix",
|
|
62
78
|
"reason": "Tool-intrinsic install line — repomix IS an npm package."
|
|
63
79
|
},
|
|
64
80
|
{
|
|
65
81
|
"file": "src/skills/rule-writing/SKILL.md",
|
|
66
|
-
"
|
|
67
|
-
78
|
|
68
|
-
],
|
|
82
|
+
"anchor": "description: \"Writing or reviewing PHP code — strict types, ",
|
|
69
83
|
"reason": "Quoted rule-description example (the php-coding rule's own description string) — meta content. Re-anchored 76→78 after the description-cap edit above it."
|
|
70
84
|
},
|
|
71
|
-
{
|
|
72
|
-
"file": "src/skills/security-audit/SKILL.md",
|
|
73
|
-
"lines": [
|
|
74
|
-
122
|
|
75
|
-
],
|
|
76
|
-
"reason": "Multi-ecosystem XSS row (Blade/JSX/Jinja peers on one line) + a quoted false-positive example ('Laravel's CSRF is already handled') — documentation, not a mandate."
|
|
77
|
-
},
|
|
78
85
|
{
|
|
79
86
|
"file": "src/skills/skill-reviewer/SKILL.md",
|
|
80
|
-
"
|
|
81
|
-
70
|
|
82
|
-
],
|
|
87
|
+
"anchor": "- ❌ Describing how Laravel middleware works (in a middlewar",
|
|
83
88
|
"reason": "Quoted anti-example ('❌ Describing how Laravel middleware works') — the line teaches what NOT to write."
|
|
84
89
|
},
|
|
85
90
|
{
|
|
86
91
|
"file": "src/skills/skill-writing/SKILL.md",
|
|
87
|
-
"
|
|
88
|
-
148,
|
|
89
|
-
456
|
|
90
|
-
],
|
|
92
|
+
"anchor": "Use when creating Laravel middleware for request filtering",
|
|
91
93
|
"reason": "Quoted description examples + the 'too-broad skill (Laravel skill)' anti-pattern — meta content."
|
|
92
94
|
},
|
|
93
95
|
{
|
|
94
96
|
"file": "src/skills/supply-chain-intake/SKILL.md",
|
|
95
|
-
"
|
|
96
|
-
80,
|
|
97
|
-
82
|
|
98
|
-
],
|
|
97
|
+
"anchor": "rg -n '\"[^\"]+\":\\s*\"(\\^|~|\\*|latest)' package.json",
|
|
99
98
|
"reason": "Per-ecosystem check commands in fenced blocks; the pip/composer siblings sit farther apart than the ±10-line window — genuine multi-ecosystem documentation."
|
|
100
99
|
},
|
|
101
100
|
{
|
|
102
|
-
"file": "src/skills/
|
|
103
|
-
"
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
"reason": "
|
|
101
|
+
"file": "src/skills/supply-chain-intake/SKILL.md",
|
|
102
|
+
"anchor": "[ -f package.json ] && [ ! -f package-lock.json ] && echo \"n",
|
|
103
|
+
"reason": "Per-ecosystem check commands in fenced blocks; the pip/composer siblings sit farther apart than the ±10-line window — genuine multi-ecosystem documentation."
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"file": "src/skills/testing-anti-patterns/SKILL.md",
|
|
107
|
+
"anchor": "Replacement: a `tests/Support/cleanupSession.php` helper or ",
|
|
108
|
+
"reason": "Multi-ecosystem capture-tools line and skip-marker grep spanning pytest/PHPUnit/JS in one pattern — documentation across stacks."
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"file": "src/skills/testing-anti-patterns/SKILL.md",
|
|
112
|
+
"anchor": "**Concrete capture tools** for recording the real shape: `cu",
|
|
113
|
+
"reason": "Multi-ecosystem capture-tools line and skip-marker grep spanning pytest/PHPUnit/JS in one pattern — documentation across stacks."
|
|
110
114
|
},
|
|
111
115
|
{
|
|
112
116
|
"file": "src/skills/testing-anti-patterns/SKILL.md",
|
|
113
|
-
"
|
|
114
|
-
87,
|
|
115
|
-
124,
|
|
116
|
-
185
|
|
117
|
-
],
|
|
117
|
+
"anchor": "rg -n '\\b(it|test|describe)\\.skip\\b|\\.only\\b|xit\\(|xdescribe",
|
|
118
118
|
"reason": "Multi-ecosystem capture-tools line and skip-marker grep spanning pytest/PHPUnit/JS in one pattern — documentation across stacks."
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
121
|
"file": "src/skills/test-driven-development/SKILL.md",
|
|
122
|
-
"
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
122
|
+
"anchor": "### Example A — PHP / Pest",
|
|
123
|
+
"reason": "Example A of a deliberate A/B pair: '### Example A — PHP / Pest' (L228) sits opposite '### Example B — TypeScript / Vitest' (L259). That is framework-neutrality's textbook 'multi-stack peers' shape, but the two headings are 31 lines apart and the cross-stack window is +/-10, so the heuristic cannot see the sibling. Allowlisted rather than widening the window for a single document — if a second per-stack A/B skill appears, build the section-aware window instead of adding a second entry here."
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"file": "src/skills/test-driven-development/SKILL.md",
|
|
127
|
+
"anchor": "// tests/Unit/EmailValidatorTest.php — RED",
|
|
128
|
+
"reason": "Example A of a deliberate A/B pair: '### Example A — PHP / Pest' (L228) sits opposite '### Example B — TypeScript / Vitest' (L259). That is framework-neutrality's textbook 'multi-stack peers' shape, but the two headings are 31 lines apart and the cross-stack window is +/-10, so the heuristic cannot see the sibling. Allowlisted rather than widening the window for a single document — if a second per-stack A/B skill appears, build the section-aware window instead of adding a second entry here."
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"file": "src/skills/test-driven-development/SKILL.md",
|
|
132
|
+
"anchor": "Run: `./vendor/bin/pest --filter='rejects empty email'` → fa",
|
|
133
|
+
"reason": "Example A of a deliberate A/B pair: '### Example A — PHP / Pest' (L228) sits opposite '### Example B — TypeScript / Vitest' (L259). That is framework-neutrality's textbook 'multi-stack peers' shape, but the two headings are 31 lines apart and the cross-stack window is +/-10, so the heuristic cannot see the sibling. Allowlisted rather than widening the window for a single document — if a second per-stack A/B skill appears, build the section-aware window instead of adding a second entry here."
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"file": "src/skills/test-driven-development/SKILL.md",
|
|
137
|
+
"anchor": "// app/Validators/EmailValidator.php — GREEN (minimum)",
|
|
128
138
|
"reason": "Example A of a deliberate A/B pair: '### Example A — PHP / Pest' (L228) sits opposite '### Example B — TypeScript / Vitest' (L259). That is framework-neutrality's textbook 'multi-stack peers' shape, but the two headings are 31 lines apart and the cross-stack window is +/-10, so the heuristic cannot see the sibling. Allowlisted rather than widening the window for a single document — if a second per-stack A/B skill appears, build the section-aware window instead of adding a second entry here."
|
|
129
139
|
},
|
|
130
140
|
{
|
|
131
141
|
"file": "src/skills/image-editing/SKILL.md",
|
|
132
|
-
"
|
|
133
|
-
48
|
|
134
|
-
],
|
|
142
|
+
"anchor": "`node_modules/@event4u/agent-config/src/scripts/ai-image/ada",
|
|
135
143
|
"reason": "npm install-location path of THIS package's own image-adapter scripts (node_modules/@event4u/agent-config/...) — the factual consumer path after the consumer-internal-ref decoupling (road-to-credible-install Phase 4), not a JS-stack mandate."
|
|
136
144
|
},
|
|
137
145
|
{
|
|
138
146
|
"file": "src/skills/image-generation/SKILL.md",
|
|
139
|
-
"
|
|
140
|
-
|
|
141
|
-
|
|
147
|
+
"anchor": "`node_modules/@event4u/agent-config/src/scripts/ai-image/ada",
|
|
148
|
+
"reason": "npm install-location path of THIS package's own image-adapter scripts (node_modules/@event4u/agent-config/...) — the factual consumer path after the consumer-internal-ref decoupling (road-to-credible-install Phase 4), not a JS-stack mandate."
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"file": "src/skills/prompt-engineering-image/SKILL.md",
|
|
152
|
+
"anchor": "`node_modules/@event4u/agent-config/src/scripts/ai-image/adapters/<provider>.sh`",
|
|
142
153
|
"reason": "npm install-location path of THIS package's own image-adapter scripts (node_modules/@event4u/agent-config/...) — the factual consumer path after the consumer-internal-ref decoupling (road-to-credible-install Phase 4), not a JS-stack mandate."
|
|
143
154
|
},
|
|
144
155
|
{
|
|
145
156
|
"file": "src/skills/prompt-engineering-image/SKILL.md",
|
|
146
|
-
"
|
|
147
|
-
74,
|
|
148
|
-
115
|
|
149
|
-
],
|
|
157
|
+
"anchor": "- `node_modules/@event4u/agent-config/src/scripts/ai-image/a",
|
|
150
158
|
"reason": "npm install-location path of THIS package's own image-adapter scripts (node_modules/@event4u/agent-config/...) — the factual consumer path after the consumer-internal-ref decoupling (road-to-credible-install Phase 4), not a JS-stack mandate."
|
|
151
159
|
}
|
|
152
160
|
]
|
|
@@ -529,8 +529,7 @@ function* _iter_knowledge_entries(): Generator<[string, Record<string, unknown>]
|
|
|
529
529
|
/**
|
|
530
530
|
* Naive relevance score: max over keys of (glob-match | substring).
|
|
531
531
|
*
|
|
532
|
-
*
|
|
533
|
-
* The `present` path returns a real score from agent-memory.
|
|
532
|
+
* Retrieval is best-effort by design — this is the only scoring path.
|
|
534
533
|
*
|
|
535
534
|
* DOCUMENTED FALLBACK (road-to-reachable-code-memory Phase 6 / ADR-129):
|
|
536
535
|
* this is the primary, always-available scorer. It has a known correctness
|
|
@@ -257,6 +257,10 @@
|
|
|
257
257
|
"type": "boolean",
|
|
258
258
|
"description": "AI OS Product UI allowlist (road-to-ai-os-product-ui.md Phase 1). When true, the command is exposed in the browser /tasks surface and may be spawned by POST /api/v1/task/run. Default false — commands that touch git, secrets, or run destructive migrations must stay false (terminal only). Per-command opt-in, never opt-out."
|
|
259
259
|
},
|
|
260
|
+
"requires_confirmation": {
|
|
261
|
+
"type": "boolean",
|
|
262
|
+
"description": "Optional, additive: the command's action STAGES rather than executes, so an irreversible step waits for an explicit human confirmation naming the exact object (non-destructive-by-default § Never act while asking). Twin of the same key in skill.schema.json's execution block; commands carry no execution block, so it sits top-level. A declaration only — it names the action class, never the object, which exists at stage time and not in frontmatter. Mechanism: stageAction/confirmOnce in work_engine/hooks/builtin/staged_confirmation.ts; enumerate with `agent-config gates --pending`. Nothing BINDS on it yet (blocker confirmation-degraded-host-semantics: 5 of 8 hosts have no pre_tool_use slot)."
|
|
263
|
+
},
|
|
260
264
|
"auto_detect": {
|
|
261
265
|
"type": "boolean",
|
|
262
266
|
"description": "6.1.0 orchestrator auto-detection opt-in (Step 1, non-interactive-contract). When true, the orchestrator auto-detects its sub-command per the confidence-tiered detection table instead of always showing the menu, honoring the non-interactive-contract (TTY/CI detection, --yes/--json, abort schemas). Per-orchestrator kill-switch: set false to always show the menu; overridable globally via commands.auto_detect in .agent-settings.yml and per-invocation via --no-auto-detect. Only orchestrators (type: orchestrator) carry it."
|
|
@@ -222,6 +222,10 @@
|
|
|
222
222
|
},
|
|
223
223
|
"description": "U3 (ecosystem harvest): optional deny-list layered UNDER allowed_tools — defense-in-depth; a tool matching here is refused even if a broad allow pattern would admit it. Supports the scoped-grant syntax (e.g. Bash(git push:*))."
|
|
224
224
|
},
|
|
225
|
+
"requires_confirmation": {
|
|
226
|
+
"type": "boolean",
|
|
227
|
+
"description": "Optional, additive: the skill's action STAGES rather than executes, so an irreversible step waits for an explicit human confirmation naming the exact object (non-destructive-by-default § Never act while asking). A declaration only — it names the action class, never the object, which exists at stage time and not in frontmatter. The mechanism is stageAction/confirmOnce in work_engine/hooks/builtin/staged_confirmation.ts; enumerate what is pending with `agent-config gates --pending`. Nothing BINDS on this flag yet: on 5 of 8 hosts there is no pre_tool_use slot, so a guard would degrade to prose while the flag reads like a control — that decision is deferred behind blocker confirmation-degraded-host-semantics."
|
|
228
|
+
},
|
|
225
229
|
"command": {
|
|
226
230
|
"type": "array",
|
|
227
231
|
"minItems": 1,
|
|
@@ -53,12 +53,15 @@ import { fileURLToPath } from 'node:url';
|
|
|
53
53
|
import { current_branch } from './_lib/git_common_dir.js';
|
|
54
54
|
import {
|
|
55
55
|
HEARTBEAT_REACHABLE_PLATFORMS,
|
|
56
|
+
type Collision,
|
|
56
57
|
type SessionRecord,
|
|
58
|
+
classify_collisions,
|
|
57
59
|
delete_record,
|
|
58
60
|
foreign_live_records,
|
|
59
61
|
iso_now,
|
|
60
62
|
read_own_record,
|
|
61
63
|
register_dir,
|
|
64
|
+
safe_stem,
|
|
62
65
|
stop_means_session_end,
|
|
63
66
|
ttl_is_measured,
|
|
64
67
|
ttl_seconds_for,
|
|
@@ -79,29 +82,181 @@ const REPLAY_ENV_VAR = 'AGENT_CONFIG_REPLAY';
|
|
|
79
82
|
* the claim lands at most one turn later and the model never needs to know the
|
|
80
83
|
* register's path or format.
|
|
81
84
|
*/
|
|
82
|
-
|
|
85
|
+
const _STATE_REL = path.join('agents', 'runtime', 'state');
|
|
86
|
+
|
|
87
|
+
export const ROADMAP_CLAIM_REL = path.join(_STATE_REL, 'roadmap-claim.json');
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Per-SESSION claim path. The legacy single file above is a claim on the
|
|
91
|
+
* WORKTREE, not on the session, and that was a measured defect rather than a
|
|
92
|
+
* theoretical one: four live records once carried one identical slug — naming a
|
|
93
|
+
* roadmap that was already archived — because every session in the same checkout
|
|
94
|
+
* read the last claim written there. A session inheriting a peer's claim reports
|
|
95
|
+
* work it is not doing, and a session whose claim was overwritten reports none.
|
|
96
|
+
*
|
|
97
|
+
* `session_id` is available on both paths: the hook gets it from the envelope,
|
|
98
|
+
* and `sessions:claim` resolves it from the host environment. When it is not
|
|
99
|
+
* resolvable the legacy path is still written and still read, so an older claim
|
|
100
|
+
* keeps working — the fallback degrades to the previous behaviour instead of
|
|
101
|
+
* losing the claim.
|
|
102
|
+
*/
|
|
103
|
+
export function roadmap_claim_rel(session_id: string | null | undefined): string {
|
|
104
|
+
const id = String(session_id ?? '').trim();
|
|
105
|
+
if (id === '') {
|
|
106
|
+
return ROADMAP_CLAIM_REL;
|
|
107
|
+
}
|
|
108
|
+
// `safe_stem` collapses `..` and both separators, so the stem cannot traverse.
|
|
109
|
+
// The containment assertion below is nonetheless real rather than ceremonial:
|
|
110
|
+
// the adversarial gate's finding was "the defense relies on caller honesty",
|
|
111
|
+
// and it is right that a guarantee living in another module's implementation is
|
|
112
|
+
// one refactor away from being untrue here. This one is local and mechanical —
|
|
113
|
+
// if the joined path ever leaves the state directory, fall back to the shared
|
|
114
|
+
// file rather than write outside it.
|
|
115
|
+
const rel = path.join(_STATE_REL, `roadmap-claim-${safe_stem(id)}.json`);
|
|
116
|
+
const norm = path.normalize(rel);
|
|
117
|
+
if (!norm.startsWith(`${_STATE_REL}${path.sep}`) || norm.includes('..')) {
|
|
118
|
+
return ROADMAP_CLAIM_REL;
|
|
119
|
+
}
|
|
120
|
+
return norm;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The shape `sessions:claim` writes. `session_id` is absent on legacy files. */
|
|
124
|
+
export interface RoadmapClaim {
|
|
125
|
+
slug: string;
|
|
126
|
+
written_at?: string;
|
|
127
|
+
session_id?: string | null;
|
|
128
|
+
}
|
|
83
129
|
|
|
84
130
|
function _is_replay_mode(): boolean {
|
|
85
131
|
return String(process.env[REPLAY_ENV_VAR] ?? '').trim() !== '';
|
|
86
132
|
}
|
|
87
133
|
|
|
88
|
-
|
|
89
|
-
export function read_claimed_slug(workspace_root: string): string | null {
|
|
134
|
+
function _read_claim_file(file: string): RoadmapClaim | null {
|
|
90
135
|
try {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const slug = (parsed as Record<string, unknown>)['slug'];
|
|
95
|
-
if (typeof slug === 'string' && slug.trim().length > 0) {
|
|
96
|
-
return slug.trim();
|
|
97
|
-
}
|
|
136
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
137
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
138
|
+
return null;
|
|
98
139
|
}
|
|
99
|
-
|
|
140
|
+
const rec = parsed as Record<string, unknown>;
|
|
141
|
+
const slug = rec['slug'];
|
|
142
|
+
if (typeof slug !== 'string' || slug.trim().length === 0) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const sid = rec['session_id'];
|
|
146
|
+
return {
|
|
147
|
+
slug: slug.trim(),
|
|
148
|
+
...(typeof sid === 'string' && sid.trim() !== '' ? { session_id: sid.trim() } : {}),
|
|
149
|
+
};
|
|
100
150
|
} catch {
|
|
101
151
|
return null;
|
|
102
152
|
}
|
|
103
153
|
}
|
|
104
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The roadmap slug THIS session has claimed, or `null`. Never throws.
|
|
157
|
+
*
|
|
158
|
+
* Two reads, in order, and the second one is why a peer's claim can no longer be
|
|
159
|
+
* inherited: the per-session file first, then the legacy per-worktree file —
|
|
160
|
+
* and the legacy file counts only when it carries no `session_id`, or one that
|
|
161
|
+
* matches. A legacy claim written by a DIFFERENT session in the same checkout is
|
|
162
|
+
* that session's claim, and reading it as this session's is exactly the defect
|
|
163
|
+
* that put one archived slug on four live records.
|
|
164
|
+
*/
|
|
165
|
+
export function read_claimed_slug(
|
|
166
|
+
workspace_root: string,
|
|
167
|
+
// REQUIRED, on the reviewer's point: an optional id means a caller that forgets
|
|
168
|
+
// it silently gets legacy semantics instead of a type error, and there is
|
|
169
|
+
// exactly one call site that matters (`build_record`). `null` stays a legal
|
|
170
|
+
// value — it is the host-exports-nothing case — but it has to be passed.
|
|
171
|
+
session_id: string | null,
|
|
172
|
+
): string | null {
|
|
173
|
+
const own = roadmap_claim_rel(session_id);
|
|
174
|
+
if (own !== ROADMAP_CLAIM_REL) {
|
|
175
|
+
const mine = _read_claim_file(path.join(workspace_root, own));
|
|
176
|
+
if (mine !== null) {
|
|
177
|
+
return mine.slug;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const legacy = _read_claim_file(path.join(workspace_root, ROADMAP_CLAIM_REL));
|
|
181
|
+
if (legacy === null) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
const id = String(session_id ?? '').trim();
|
|
185
|
+
// A session that CAN identify itself never writes the legacy path, so a legacy
|
|
186
|
+
// file it did not write is either a peer's (a host with no id, in this same
|
|
187
|
+
// checkout) or a claim from before this file was keyed on the session. Those
|
|
188
|
+
// two are indistinguishable to a reader, and only one of them can win:
|
|
189
|
+
// inheriting a peer's claim is the measured defect, while losing a
|
|
190
|
+
// pre-upgrade claim costs one re-run of `sessions:claim`. So the identified
|
|
191
|
+
// session declines it.
|
|
192
|
+
//
|
|
193
|
+
// The previous shape compared `legacy.session_id` and was unreachable —
|
|
194
|
+
// `cmd_claim` serialises `session_id: null` on that path and `_read_claim_file`
|
|
195
|
+
// drops a null, so the comparison never ran and every legacy claim was read as
|
|
196
|
+
// "mine". R2 finding 6; the test that covered it hand-wrote a record
|
|
197
|
+
// production cannot produce.
|
|
198
|
+
if (id !== '') {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
return legacy.slug;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Does a roadmap by this slug still exist as open work?
|
|
206
|
+
*
|
|
207
|
+
* A slug survives in a claim file after its roadmap is archived, and a stale slug
|
|
208
|
+
* is worse than no slug: it renders as an active claim, so a screening session
|
|
209
|
+
* reads "that roadmap is taken" about work that shipped, and — measured — reads
|
|
210
|
+
* "nobody claimed anything else" from four records that were all simply out of
|
|
211
|
+
* date. Only the ACTIVE tree counts; `archive/`, `later/` and `skipped/` are the
|
|
212
|
+
* dispositions that make a claim meaningless.
|
|
213
|
+
*/
|
|
214
|
+
export function claim_is_stale(
|
|
215
|
+
workspace_root: string,
|
|
216
|
+
slug: string | null,
|
|
217
|
+
peer_worktree?: string | null,
|
|
218
|
+
): boolean {
|
|
219
|
+
if (slug === null || slug.trim() === '') {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
const base = slug.trim().replace(/\.md$/, '');
|
|
223
|
+
// Containment, not a character blacklist. A blacklist answers "does this look
|
|
224
|
+
// hostile"; this answers the question that matters — "would the read leave the
|
|
225
|
+
// roadmaps directory" — and it is the same check for a traversal, an absolute
|
|
226
|
+
// path, and an encoding nobody enumerated. A slug that fails it is reported
|
|
227
|
+
// stale rather than resolved, so no read happens at all.
|
|
228
|
+
const rel = path.normalize(path.join('agents', 'roadmaps', `${base}.md`));
|
|
229
|
+
const inside = rel.startsWith(`agents${path.sep}roadmaps${path.sep}`) && !rel.includes('..');
|
|
230
|
+
if (!inside || path.isAbsolute(base)) {
|
|
231
|
+
return true; // not a slug this repo can hold; never render it as live work
|
|
232
|
+
}
|
|
233
|
+
// Resolved in the PEER's worktree when one is known, and only then in ours.
|
|
234
|
+
//
|
|
235
|
+
// R2 finding 1: worktrees share one register while sitting on different
|
|
236
|
+
// commits, so a checkout branched before the roadmap file existed would read a
|
|
237
|
+
// genuinely live peer claim as stale — and both consumers treat stale as "no
|
|
238
|
+
// claim", which disables the refusal and the warning in exactly the
|
|
239
|
+
// multi-worktree case this exists for. Asking the peer's own tree first makes
|
|
240
|
+
// the verdict a fact about the peer rather than about our checkout's age.
|
|
241
|
+
//
|
|
242
|
+
// Absent in BOTH trees still means stale: a slug no tree can resolve names no
|
|
243
|
+
// work. Present in either means live — the asymmetry is deliberate, because a
|
|
244
|
+
// false "live" costs one question and a false "stale" costs a duplicated PR.
|
|
245
|
+
const roots = [peer_worktree, workspace_root].filter(
|
|
246
|
+
(r): r is string => typeof r === 'string' && r.trim() !== '',
|
|
247
|
+
);
|
|
248
|
+
for (const root of roots) {
|
|
249
|
+
try {
|
|
250
|
+
if (fs.existsSync(path.join(root, rel))) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
return false; // unreadable tree — fail open, same as every other read here
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
105
260
|
/**
|
|
106
261
|
* Build this session's record from live state.
|
|
107
262
|
*
|
|
@@ -121,7 +276,7 @@ export function build_record(
|
|
|
121
276
|
platform,
|
|
122
277
|
worktree: workspace_root,
|
|
123
278
|
branch: current_branch(workspace_root),
|
|
124
|
-
roadmap_slug: read_claimed_slug(workspace_root),
|
|
279
|
+
roadmap_slug: read_claimed_slug(workspace_root, session_id),
|
|
125
280
|
started_at,
|
|
126
281
|
last_seen: iso_now(now),
|
|
127
282
|
};
|
|
@@ -164,23 +319,42 @@ export function foreign_sessions_block(
|
|
|
164
319
|
if (others.length === 0) return null;
|
|
165
320
|
|
|
166
321
|
const here = current_branch(workspace_root);
|
|
322
|
+
const my_slug = read_claimed_slug(workspace_root, session_id);
|
|
167
323
|
const lines: string[] = [];
|
|
168
|
-
|
|
324
|
+
|
|
325
|
+
// A stale slug is reported as stale and then treated as ABSENT for collision
|
|
326
|
+
// purposes: a claim naming an archived roadmap is not a claim, and counting
|
|
327
|
+
// it would fire the new warning on four records that were merely out of date.
|
|
328
|
+
const live_others: SessionRecord[] = [];
|
|
169
329
|
|
|
170
330
|
for (const r of others) {
|
|
171
331
|
const age_min = Math.max(0, Math.round((now.getTime() - Date.parse(r.last_seen)) / 60000));
|
|
172
|
-
const
|
|
332
|
+
const stale = claim_is_stale(workspace_root, r.roadmap_slug, r.worktree);
|
|
333
|
+
const slug =
|
|
334
|
+
r.roadmap_slug === null
|
|
335
|
+
? 'no roadmap claimed'
|
|
336
|
+
: stale
|
|
337
|
+
? `roadmap: ${r.roadmap_slug} (STALE — no such open roadmap; treat as no claim)`
|
|
338
|
+
: `roadmap: ${r.roadmap_slug}`;
|
|
173
339
|
const ttl_note = ttl_is_measured(r.platform)
|
|
174
340
|
? ''
|
|
175
341
|
: ` · unmeasured host, TTL falls back to ${Math.round(ttl_seconds_for(r.platform) / 3600)}h`;
|
|
176
342
|
lines.push(
|
|
177
343
|
`- ${r.platform} · branch \`${r.branch ?? 'detached'}\` · ${slug} · last seen ${age_min} min ago${ttl_note}\n worktree: ${r.worktree}`,
|
|
178
344
|
);
|
|
179
|
-
|
|
180
|
-
collision = r;
|
|
181
|
-
}
|
|
345
|
+
live_others.push(stale ? { ...r, roadmap_slug: null } : r);
|
|
182
346
|
}
|
|
183
347
|
|
|
348
|
+
const collisions: Collision[] = classify_collisions(live_others, {
|
|
349
|
+
branch: here,
|
|
350
|
+
roadmap_slug: claim_is_stale(workspace_root, my_slug) ? null : my_slug,
|
|
351
|
+
});
|
|
352
|
+
// ALL roadmap peers, not the first. `classify_collisions` returns every one on
|
|
353
|
+
// purpose and a test pins that; reporting one understated the count whenever
|
|
354
|
+
// several records described the same work (R2 finding 14).
|
|
355
|
+
const roadmap_hits = collisions.filter((c) => c.kind === 'roadmap');
|
|
356
|
+
const branch_hit = collisions.find((c) => c.kind === 'branch') ?? null;
|
|
357
|
+
|
|
184
358
|
const parts = [
|
|
185
359
|
'<session-register>',
|
|
186
360
|
'The following OTHER agent sessions are live on this repository right now.',
|
|
@@ -189,7 +363,36 @@ export function foreign_sessions_block(
|
|
|
189
363
|
...lines,
|
|
190
364
|
];
|
|
191
365
|
|
|
192
|
-
|
|
366
|
+
// Roadmap first, deliberately. This is the expensive collision — the work is
|
|
367
|
+
// being done twice and one of the two PRs is thrown away — and it is the one
|
|
368
|
+
// that went unreported twice while the cheap branch warning fired correctly.
|
|
369
|
+
//
|
|
370
|
+
// HONEST SCOPE (R2 finding 5): this block is emitted on `session_start`, where
|
|
371
|
+
// this session's own slug is normally `null` because the roadmap is picked
|
|
372
|
+
// later — so it fires only when a claim is already on disk at start, i.e. on a
|
|
373
|
+
// RESUMED session that claimed in an earlier run. The check that covers the
|
|
374
|
+
// picking moment is `sessions:claim` itself, which refuses before writing. This
|
|
375
|
+
// is the resumed-session half of the same guard, not the primary one, and
|
|
376
|
+
// deleting it would leave a resumed session unwarned.
|
|
377
|
+
if (roadmap_hits.length > 0) {
|
|
378
|
+
parts.push(
|
|
379
|
+
'',
|
|
380
|
+
`DUPLICATE WORK: ${roadmap_hits.length} other live session(s) claim the SAME roadmap \`${my_slug}\`.`,
|
|
381
|
+
...roadmap_hits.map(
|
|
382
|
+
(h) =>
|
|
383
|
+
` · branch \`${h.record.branch ?? 'detached'}\` in ${h.record.worktree}`,
|
|
384
|
+
),
|
|
385
|
+
'A different branch name does NOT make this a different task — measured twice,',
|
|
386
|
+
'two sessions built one roadmap phase under two branch names and one PR was wasted.',
|
|
387
|
+
'STOP before writing code. Ask the user ONCE, as numbered options:',
|
|
388
|
+
' 1. Take a different roadmap (`agent-config sessions:list` shows what is held)',
|
|
389
|
+
' 2. Split the roadmap explicitly — name which phases are yours',
|
|
390
|
+
' 3. Continue anyway, accepting that one of the two results is discarded',
|
|
391
|
+
'Never decide silently, in either direction.',
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (branch_hit !== null) {
|
|
193
396
|
parts.push(
|
|
194
397
|
'',
|
|
195
398
|
`COLLISION: another live session already holds branch \`${here}\`.`,
|
|
@@ -238,6 +441,23 @@ export function main(): number {
|
|
|
238
441
|
if (event === 'session_end' || (event === 'stop' && stop_means_session_end(platform))) {
|
|
239
442
|
const dir = register_dir(root);
|
|
240
443
|
if (dir !== null) delete_record(dir, session_id);
|
|
444
|
+
// The claim file dies with the session too. Keyed on a session id it is
|
|
445
|
+
// never read again once that session ends, so leaving it behind grows one
|
|
446
|
+
// file per session forever — the same unbounded-growth defect the register
|
|
447
|
+
// itself spends code avoiding (R2 finding 3). Only ever this session's own
|
|
448
|
+
// file: the shared legacy path may belong to a peer.
|
|
449
|
+
// Whichever file THIS session writes is the file it removes — the
|
|
450
|
+
// per-session one when it has an id, the shared one when it does not.
|
|
451
|
+
// The reviewer's second point: nothing removed the shared file, so a
|
|
452
|
+
// pre-change claim survived every release, every session end and every
|
|
453
|
+
// upgrade, unbounded. An id-less session owns that file by construction,
|
|
454
|
+
// so its end is the honest moment to drop it. An identified session still
|
|
455
|
+
// leaves it alone: it may be a peer's.
|
|
456
|
+
try {
|
|
457
|
+
fs.unlinkSync(path.join(root, roadmap_claim_rel(session_id)));
|
|
458
|
+
} catch {
|
|
459
|
+
/* absent or unwritable — a leftover claim expires with its record */
|
|
460
|
+
}
|
|
241
461
|
return 0;
|
|
242
462
|
}
|
|
243
463
|
|