@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.
Files changed (69) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +91 -0
  3. package/CONTRIBUTING.md +23 -14
  4. package/dist/agent-src/commands/roadmap/next.md +37 -5
  5. package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
  6. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
  7. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  8. package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
  9. package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
  10. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  11. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  12. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  13. package/dist/cli/registry.js +2 -2
  14. package/dist/cli/registry.js.map +1 -1
  15. package/dist/cli-delegate/{chunk-REMEZSVP.js → chunk-2TGMGLBL.js} +2 -2
  16. package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
  17. package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-H6F2CQFI.js} +1 -1
  18. package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-ZAXH3ZWU.js} +3 -0
  19. package/dist/cli-delegate/cmd_conformance.js +2 -2
  20. package/dist/cli-delegate/cmd_doctor.js +2 -2
  21. package/dist/cli-delegate/cmd_export.js +2 -2
  22. package/dist/cli-delegate/cmd_handoff.js +1 -1
  23. package/dist/cli-delegate/cmd_prune.js +2 -2
  24. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  25. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  26. package/dist/cli-delegate/cmd_sync.js +2 -2
  27. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  28. package/dist/cli-delegate/cmd_validate.js +2 -2
  29. package/dist/discovery/deprecation-report.md +1 -1
  30. package/dist/discovery/discovery-manifest.json +4 -4
  31. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  32. package/dist/discovery/discovery-manifest.summary.md +1 -1
  33. package/dist/discovery/dormancy-report.md +1 -1
  34. package/dist/discovery/orphan-report.md +1 -1
  35. package/dist/discovery/packs.json +2 -2
  36. package/dist/discovery/trust-report.md +1 -1
  37. package/dist/discovery/workspaces.json +2 -2
  38. package/dist/hooks/dispatch.js +259 -78
  39. package/dist/install/install.mjs +3 -0
  40. package/dist/mcp/registry-manifest.json +2 -2
  41. package/dist/mcp/server.json +2 -2
  42. package/dist/mcp/server.mjs +74 -2
  43. package/dist/server/schemas/settings.js +1 -0
  44. package/dist/server/schemas/settings.js.map +1 -1
  45. package/docs/contracts/settings-classes.md +3 -2
  46. package/package.json +1 -1
  47. package/src/agent-src/scripts/roadmap_gates.ts +119 -4
  48. package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
  49. package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
  50. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  51. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  52. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  53. package/src/config/agent-settings.template.yml +11 -2
  54. package/src/config/quorum-attendance-budget.json +10 -2
  55. package/src/scripts/_dispatch.bash +6 -2
  56. package/src/scripts/_lib/session_register.ts +67 -1
  57. package/src/scripts/ai_council/config.ts +60 -0
  58. package/src/scripts/ai_council/events_log.ts +103 -4
  59. package/src/scripts/ai_council/quorum.ts +83 -6
  60. package/src/scripts/council_cli.ts +36 -1
  61. package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
  62. package/src/scripts/lint_framework_leakage.ts +195 -28
  63. package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
  64. package/src/scripts/memory_lookup.ts +1 -2
  65. package/src/scripts/schemas/command.schema.json +4 -0
  66. package/src/scripts/schemas/skill.schema.json +4 -0
  67. package/src/scripts/session_register_hook.ts +238 -18
  68. package/src/scripts/sessions_cli.ts +377 -12
  69. 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, lines: [int,...] | \"*\" for whole file, reason: short justification }. Use sparingly — first ask whether the file should be neutralized or carved out. Policy cap: > 20 entries means the linter (or the content) is wrong — fix the tool or the text, never grow the list. Rebuilt 2026-07-12 (road-to-opt-hygiene-and-debt Phase 1): 104 → minimal set after heuristic upgrades + 25 content neutralizations + 2 carve-outs.",
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
- "lines": [
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
- "lines": [
22
- 320,
23
- 322
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
- "lines": [
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
- "lines": [
38
- 87,
39
- 99,
40
- 107,
41
- 108,
42
- 109,
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
- "lines": [
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
- "lines": [
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
- "lines": [
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
- "lines": [
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
- "lines": [
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
- "lines": [
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/test-driven-development/SKILL.md",
103
- "lines": [
104
- 197,
105
- 200,
106
- 208,
107
- 212
108
- ],
109
- "reason": "Worked Example A (PHP/Pest) paired with Example B (TypeScript/Jest) farther apart than the ±10-line window — canonical two-stack demo."
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
- "lines": [
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
- "lines": [
123
- 228,
124
- 231,
125
- 239,
126
- 243
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
- "lines": [
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
- "lines": [
140
- 45
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
- "lines": [
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
- * Good enough for the `absent` path where retrieval is best-effort.
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
- export const ROADMAP_CLAIM_REL = path.join('agents', 'runtime', 'state', 'roadmap-claim.json');
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
- /** The roadmap slug this session has claimed, or `null`. Never throws. */
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 raw = fs.readFileSync(path.join(workspace_root, ROADMAP_CLAIM_REL), 'utf-8');
92
- const parsed: unknown = JSON.parse(raw);
93
- if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
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
- return null;
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
- let collision: SessionRecord | null = null;
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 slug = r.roadmap_slug === null ? 'no roadmap claimed' : `roadmap: ${r.roadmap_slug}`;
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
- if (collision === null && here !== null && r.branch === here) {
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
- if (collision !== null) {
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