@event4u/agent-config 9.33.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 (105) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +154 -145
  3. package/CONTRIBUTING.md +23 -14
  4. package/dist/agent-src/commands/agent-handoff.md +15 -3
  5. package/dist/agent-src/commands/roadmap/next.md +37 -5
  6. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  7. package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
  8. package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
  9. package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
  10. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
  11. package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
  12. package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
  13. package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
  14. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
  15. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
  16. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  17. package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
  18. package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
  19. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  20. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  21. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  22. package/dist/cli/registry.js +2 -2
  23. package/dist/cli/registry.js.map +1 -1
  24. package/dist/cli-delegate/{chunk-PMQ6Z3T3.js → chunk-2TGMGLBL.js} +11 -4
  25. package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
  26. package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-H6F2CQFI.js} +1 -1
  27. package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
  28. package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
  29. package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-ZAXH3ZWU.js} +3 -0
  30. package/dist/cli-delegate/cmd_conformance.js +2 -2
  31. package/dist/cli-delegate/cmd_doctor.js +2 -2
  32. package/dist/cli-delegate/cmd_export.js +2 -2
  33. package/dist/cli-delegate/cmd_handoff.js +3 -3
  34. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  35. package/dist/cli-delegate/cmd_prune.js +2 -2
  36. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  37. package/dist/cli-delegate/cmd_session_recycle.js +7 -2
  38. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  39. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  40. package/dist/cli-delegate/cmd_sync.js +2 -2
  41. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  42. package/dist/cli-delegate/cmd_validate.js +2 -2
  43. package/dist/discovery/deprecation-report.md +1 -1
  44. package/dist/discovery/discovery-manifest.json +13 -13
  45. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  46. package/dist/discovery/discovery-manifest.summary.md +2 -2
  47. package/dist/discovery/dormancy-report.md +17 -0
  48. package/dist/discovery/orphan-report.md +1 -1
  49. package/dist/discovery/packs.json +3 -3
  50. package/dist/discovery/trust-report.md +1 -1
  51. package/dist/discovery/workspaces.json +3 -3
  52. package/dist/hooks/dispatch.js +355 -85
  53. package/dist/install/install.mjs +3 -0
  54. package/dist/mcp/registry-manifest.json +3 -3
  55. package/dist/mcp/server.json +2 -2
  56. package/dist/mcp/server.mjs +192 -12
  57. package/dist/server/schemas/settings.js +1 -0
  58. package/dist/server/schemas/settings.js.map +1 -1
  59. package/docs/contracts/settings-classes.md +3 -2
  60. package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
  61. package/package.json +2 -2
  62. package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  63. package/src/agent-src/scripts/roadmap_gates.ts +119 -4
  64. package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
  65. package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
  66. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  67. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  68. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  69. package/src/config/agent-settings.template.yml +11 -2
  70. package/src/config/quorum-attendance-budget.json +11 -3
  71. package/src/scripts/_cli/cmd_session_recycle.ts +6 -1
  72. package/src/scripts/_dispatch.bash +6 -2
  73. package/src/scripts/_lib/env_kill_switch.ts +37 -0
  74. package/src/scripts/_lib/security_lint.ts +18 -4
  75. package/src/scripts/_lib/self_repair.ts +60 -0
  76. package/src/scripts/_lib/self_repair_store.ts +65 -5
  77. package/src/scripts/_lib/session_register.ts +67 -1
  78. package/src/scripts/_lib/subagent_capsule.ts +78 -1
  79. package/src/scripts/ai_council/config.ts +60 -0
  80. package/src/scripts/ai_council/events_log.ts +105 -6
  81. package/src/scripts/ai_council/orchestrator.ts +38 -0
  82. package/src/scripts/ai_council/prompts.ts +165 -3
  83. package/src/scripts/ai_council/quorum.ts +87 -0
  84. package/src/scripts/ai_team/review_gate.ts +2 -2
  85. package/src/scripts/build_discovery_manifest.ts +165 -0
  86. package/src/scripts/check_claims.ts +51 -2
  87. package/src/scripts/ci_time_ratio.ts +3 -1
  88. package/src/scripts/council_cli.ts +36 -1
  89. package/src/scripts/discovery_graph.ts +201 -27
  90. package/src/scripts/dispatch_r2_reviewer.ts +102 -1
  91. package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
  92. package/src/scripts/lint_framework_leakage.ts +195 -28
  93. package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
  94. package/src/scripts/lint_handoffs.ts +92 -2
  95. package/src/scripts/lint_hedge_words.ts +317 -0
  96. package/src/scripts/lint_mcp_config_security.ts +1 -1
  97. package/src/scripts/lint_skill_frontmatter_safety.ts +1 -1
  98. package/src/scripts/memory_lookup.ts +1 -2
  99. package/src/scripts/schemas/command.schema.json +4 -0
  100. package/src/scripts/schemas/skill.schema.json +4 -0
  101. package/src/scripts/self_repair_cli.ts +26 -1
  102. package/src/scripts/session_register_hook.ts +238 -18
  103. package/src/scripts/sessions_cli.ts +377 -12
  104. package/src/scripts/skill_linter.ts +58 -2
  105. package/src/server/schemas/settings.ts +3 -0
@@ -17,15 +17,27 @@
17
17
  *
18
18
  * ## Subcommands
19
19
  *
20
- * - `list [--json]` — live sessions on this repo. Human table by default; `--json`
21
- * emits the raw records for a scripted screen. Exits 0 with an empty result
20
+ * - `list [--json] [--branches]` — live sessions on this repo, plus the branches
21
+ * checked out in OTHER worktrees. Human table by default; `--json` emits the
22
+ * raw records for a scripted screen, and `--branches` adds the second axis to
23
+ * the JSON (the human output always shows it). Exits 0 with an empty result
22
24
  * when there is no register — its absence is the normal pre-first-session
23
25
  * state, never an error.
24
- * - `claim <slug>` — record that THIS worktree's session has taken a roadmap.
25
- * Writes the bridge file the next heartbeat lifts into the register.
26
+ * - `claim <slug>` — record that THIS SESSION has taken a roadmap. Writes the
27
+ * bridge file the next heartbeat lifts into the register, keyed on the host
28
+ * session id where one exists.
26
29
  * - `claim --release` — clear the claim.
30
+ *
31
+ * ## Why two axes and not one
32
+ *
33
+ * The slug axis needs a claim to have been written, and writing it is
34
+ * model-carried. The branch axis needs nothing: a worktree checkout is on disk
35
+ * from the first minute. Measured twice, the duplication was caught by the branch
36
+ * axis (`git branch`'s `+` marker) and missed entirely by the register, so
37
+ * reporting only the register is reporting the half that can be silent.
27
38
  */
28
39
 
40
+ import { spawnSync } from 'node:child_process';
29
41
  import * as fs from 'node:fs';
30
42
  import * as path from 'node:path';
31
43
  import process from 'node:process';
@@ -36,17 +48,22 @@ import {
36
48
  iso_now,
37
49
  read_live_records,
38
50
  register_dir,
51
+ safe_stem,
39
52
  ttl_is_measured,
40
53
  ttl_seconds_for,
41
54
  } from './_lib/session_register.js';
42
- import { ROADMAP_CLAIM_REL } from './session_register_hook.js';
55
+ import { claim_is_stale, roadmap_claim_rel } from './session_register_hook.js';
43
56
 
44
57
  function usage(): number {
45
58
  process.stderr.write(
46
59
  [
47
60
  'usage:',
48
- ' agent-config sessions:list [--json] live sessions on this repository',
61
+ ' agent-config sessions:list [--json] [--branches]',
62
+ ' live sessions, plus unmerged branches',
63
+ ' held by other worktrees (--branches adds',
64
+ ' that axis to the JSON form)',
49
65
  ' agent-config sessions:claim <slug> claim a roadmap for this session',
66
+ ' agent-config sessions:claim <slug> --force claim it even though a peer holds it',
50
67
  ' agent-config sessions:claim --release drop this session\'s roadmap claim',
51
68
  '',
52
69
  ].join('\n'),
@@ -54,18 +71,133 @@ function usage(): number {
54
71
  return 2;
55
72
  }
56
73
 
74
+ /**
75
+ * Branches checked out in ANOTHER worktree — the second axis, and the one that
76
+ * does not depend on a claim having been written.
77
+ *
78
+ * `git branch` marks these with a `+` prefix, and that prefix is what actually
79
+ * caught the duplication both times it happened, from a memory note rather than
80
+ * from any command. It is reported here because the register alone cannot see it:
81
+ * a session that never ran `sessions:claim` is invisible to the slug axis while
82
+ * its branch is already on disk, so a claim-only screen has a blind spot exactly
83
+ * where the first minutes of a duplicate live.
84
+ *
85
+ * `git worktree list --porcelain` is preferred over parsing `git branch` output:
86
+ * the `+` marker is presentation and the porcelain form is a contract.
87
+ */
88
+ export function other_worktree_branches(
89
+ root: string,
90
+ spawn: typeof spawnSync = spawnSync,
91
+ ): { branch: string; worktree: string }[] {
92
+ return other_worktree_branches_detailed(root, spawn).rows;
93
+ }
94
+
95
+ /**
96
+ * Same walk, plus whether the unmerged filter was actually applied.
97
+ *
98
+ * R2 finding 12: when `origin/main` is missing or un-fetched the filter silently
99
+ * drops and every foreign worktree is reported — while the human output still
100
+ * claimed "N unmerged branch(es)". In a tree with 30+ long-merged worktrees that
101
+ * is not a small overstatement, and the reader had no way to see the degradation.
102
+ */
103
+ export function other_worktree_branches_detailed(
104
+ root: string,
105
+ spawn: typeof spawnSync = spawnSync,
106
+ ): { rows: { branch: string; worktree: string }[]; filtered: boolean } {
107
+ let out = '';
108
+ try {
109
+ const r = spawn('git', ['worktree', 'list', '--porcelain'], {
110
+ cwd: root,
111
+ encoding: 'utf-8',
112
+ timeout: GIT_PROBE_TIMEOUT_MS,
113
+ });
114
+ if (r.status !== 0) {
115
+ return { rows: [], filtered: false };
116
+ }
117
+ out = String(r.stdout ?? '');
118
+ } catch {
119
+ return { rows: [], filtered: false };
120
+ }
121
+ // Unmerged branches only, in ONE extra git call. Without this filter the
122
+ // list is every worktree the repo ever had — 30+ here, all long merged — and
123
+ // a list that is mostly noise is a list nobody reads, which is the failure
124
+ // mode a false-positive-prone warning always ends in.
125
+ let unmerged: Set<string> | null = null;
126
+ try {
127
+ const r = spawn('git', ['branch', '--no-merged', 'origin/main', '--format=%(refname:short)'], {
128
+ cwd: root,
129
+ encoding: 'utf-8',
130
+ timeout: GIT_PROBE_TIMEOUT_MS,
131
+ });
132
+ if (r.status === 0) {
133
+ unmerged = new Set(
134
+ String(r.stdout ?? '')
135
+ .split('\n')
136
+ .map((s) => s.trim())
137
+ .filter((s) => s !== ''),
138
+ );
139
+ }
140
+ } catch {
141
+ unmerged = null; // no filter available — report everything rather than nothing
142
+ }
143
+
144
+ const rows: { branch: string; worktree: string }[] = [];
145
+ let wt: string | null = null;
146
+ for (const raw of out.split('\n')) {
147
+ const line = raw.trim();
148
+ if (line.startsWith('worktree ')) {
149
+ wt = line.slice('worktree '.length).trim();
150
+ } else if (line.startsWith('branch ') && wt !== null) {
151
+ const ref = line.slice('branch '.length).trim();
152
+ const branch = ref.replace(/^refs\/heads\//, '');
153
+ // The caller's own worktree is not a foreign checkout. Compared by
154
+ // realpath because /var vs /private/var on macOS would otherwise make
155
+ // every session report itself as a collision.
156
+ let same = false;
157
+ try {
158
+ same = fs.realpathSync(wt) === fs.realpathSync(root);
159
+ } catch {
160
+ same = wt === root;
161
+ }
162
+ if (!same && (unmerged === null || unmerged.has(branch))) {
163
+ rows.push({ branch, worktree: wt });
164
+ }
165
+ }
166
+ }
167
+ rows.sort((a, b) => a.branch.localeCompare(b.branch) || a.worktree.localeCompare(b.worktree));
168
+ return { rows, filtered: unmerged !== null };
169
+ }
170
+
57
171
  function cmd_list(argv: string[], root: string): number {
58
172
  const as_json = argv.includes('--json');
59
173
  const dir = register_dir(root);
60
174
  const records = dir === null ? [] : read_live_records(dir, { prune: true });
175
+ // Two git subprocesses with a 10 s timeout each, so they are not paid on the
176
+ // scripted path that discards them (R2 finding 8).
177
+ const wants_branches = !as_json || argv.includes('--branches');
178
+ const walk = wants_branches
179
+ ? other_worktree_branches_detailed(root)
180
+ : { rows: [], filtered: false };
181
+ const held = walk.rows;
182
+ const filtered = walk.filtered;
61
183
 
62
184
  if (as_json) {
63
- process.stdout.write(`${JSON.stringify(records, null, 2)}\n`);
185
+ // The record array stays the top-level shape it has always been when
186
+ // `--branches` is absent: a scripted screen pins it, and silently wrapping
187
+ // it in an object would break every existing caller.
188
+ process.stdout.write(
189
+ argv.includes('--branches')
190
+ ? `${JSON.stringify({ sessions: records, other_worktree_branches: held, unmerged_filter_applied: filtered }, null, 2)}\n`
191
+ : `${JSON.stringify(records, null, 2)}\n`,
192
+ );
64
193
  return 0;
65
194
  }
66
195
 
196
+ // One branch, not two: `write_held_branches([])` returns immediately, so the
197
+ // empty-and-empty case needed no special case of its own (R2 finding 7).
67
198
  if (records.length === 0) {
68
199
  process.stdout.write('No live sessions registered on this repository.\n');
200
+ write_held_branches(held, filtered);
69
201
  return 0;
70
202
  }
71
203
 
@@ -75,18 +207,28 @@ function cmd_list(argv: string[], root: string): number {
75
207
  const age_min = Math.max(0, Math.round((now - Date.parse(r.last_seen)) / 60000));
76
208
  const ttl_h = Math.round(ttl_seconds_for(r.platform) / 3600);
77
209
  const ttl_note = ttl_is_measured(r.platform) ? `${ttl_h}h` : `${ttl_h}h (unmeasured host)`;
210
+ // A slug naming no open roadmap is labelled rather than printed straight.
211
+ // Measured: four live records all carried one already-archived slug, and a
212
+ // screening session read that as "taken" about work that had shipped.
213
+ const roadmap =
214
+ r.roadmap_slug === null
215
+ ? '(none claimed)'
216
+ : claim_is_stale(root, r.roadmap_slug)
217
+ ? `${r.roadmap_slug} ← STALE, no such open roadmap; treat as no claim`
218
+ : r.roadmap_slug;
78
219
  process.stdout.write(
79
220
  [
80
221
  ` ${r.session_id}`,
81
222
  ` host: ${r.platform} · TTL ${ttl_note}`,
82
223
  ` branch: ${r.branch ?? '(detached)'}`,
83
- ` roadmap: ${r.roadmap_slug ?? '(none claimed)'}`,
224
+ ` roadmap: ${roadmap}`,
84
225
  ` worktree: ${r.worktree}`,
85
226
  ` seen: ${age_min} min ago`,
86
227
  '',
87
228
  ].join('\n'),
88
229
  );
89
230
  }
231
+ write_held_branches(held, filtered);
90
232
  process.stdout.write(
91
233
  'Advisory only — this register is not a lock, and an idle session\n' +
92
234
  'disappears from it after its TTL although its user may return.\n',
@@ -94,30 +236,253 @@ function cmd_list(argv: string[], root: string): number {
94
236
  return 0;
95
237
  }
96
238
 
239
+ /** snake_case like every other function here (R2 finding 15). */
240
+ function write_held_branches(
241
+ held: readonly { branch: string; worktree: string }[],
242
+ filtered: boolean,
243
+ ): void {
244
+ if (held.length === 0) {
245
+ return;
246
+ }
247
+ process.stdout.write(
248
+ filtered
249
+ ? `\n${held.length} unmerged branch(es) checked out in another worktree — a claim\n` +
250
+ 'may not have been written yet, so check these before picking work.\n' +
251
+ 'Limit: a branch created seconds ago carries no commits and is therefore\n' +
252
+ 'MERGED into main by definition, so it does not appear here — that first\n' +
253
+ 'minute is what the roadmap-slug axis above covers.\n'
254
+ : `\n${held.length} branch(es) checked out in another worktree. UNFILTERED —\n` +
255
+ '`git branch --no-merged origin/main` was unavailable (no origin/main, or\n' +
256
+ 'never fetched), so long-merged worktrees are included and this list\n' +
257
+ 'overstates what is live. `git fetch origin` and re-run for the filtered set.\n',
258
+ );
259
+ for (const h of held) {
260
+ process.stdout.write(` ${h.branch}\n ${h.worktree}\n`);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * This session's id, from the host environment, or `null`.
266
+ *
267
+ * The CLI has no envelope, so the environment is the only channel — and it is a
268
+ * real one: Claude Code exports `CLAUDE_CODE_SESSION_ID` into every shell it
269
+ * spawns (probed 2026-08-12). A host that exports nothing degrades to the legacy
270
+ * per-worktree claim rather than losing the claim, and `sessions:claim` says so
271
+ * on that path instead of implying a per-session guarantee it cannot give.
272
+ */
273
+ export function env_session_id(env: NodeJS.ProcessEnv = process.env): string | null {
274
+ // The package's own variable is checked FIRST — an override that loses to the
275
+ // host variable is not an override. Caught by a test that set it and still got
276
+ // the ambient Claude Code id back, which is also the shape a second host or a
277
+ // debugging run would hit.
278
+ for (const key of ['AGENT_CONFIG_SESSION_ID', 'CLAUDE_CODE_SESSION_ID']) {
279
+ const v = String(env[key] ?? '').trim();
280
+ if (v !== '') {
281
+ return v;
282
+ }
283
+ }
284
+ return null;
285
+ }
286
+
287
+ /**
288
+ * Ceiling for the two `git` probes here. Named rather than repeated: the value
289
+ * is a contract with the caller (a screen must not hang), and two literals drift.
290
+ */
291
+ const GIT_PROBE_TIMEOUT_MS = 10_000;
292
+
293
+ /** Same filesystem path, symlink-tolerant. `/var` vs `/private/var` on macOS. */
294
+ function _same_path(a: string, b: string): boolean {
295
+ try {
296
+ return fs.realpathSync(a) === fs.realpathSync(b);
297
+ } catch {
298
+ return a === b;
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Foreign-worktree branches whose NAME looks like this roadmap.
304
+ *
305
+ * Separated from `claim_conflicts` because it is a **heuristic** and its output is
306
+ * treated differently: a name match warns, it never refuses. R2 finding 4 caught
307
+ * the contradiction — the docstring called it "a reason to look, not a verdict"
308
+ * while a hit produced exit 1 and demanded `--force`.
309
+ *
310
+ * Two guards on the tail, both from that finding. A slug ending in a date
311
+ * (`road-to-inbox-harvest-2026-08`) yielded the tail `2026-08`, which cleared the
312
+ * old `length >= 4` check and matched every branch carrying that string; a tail
313
+ * whose tokens are all digits is therefore rejected. And a `road-to-` prefix
314
+ * stripped from a two-token slug can leave a generic word, so the tail must be at
315
+ * least 8 characters — measured against the real case, `dispatch-safety`.
316
+ */
317
+ export function branch_name_hits(
318
+ root: string,
319
+ slug: string,
320
+ ): { branch: string; worktree: string }[] {
321
+ const tokens = slug.replace(/^road-to-/, '').split('-').filter(Boolean);
322
+ const tail = tokens.slice(-2).join('-');
323
+ if (tail.length < 8 || /^[\d-]+$/.test(tail)) {
324
+ return [];
325
+ }
326
+ // Token-boundary match, not `includes`. The adversarial gate named the exact
327
+ // false positive: `dispatch-safety` is a substring of `redispatch-safety-valve`,
328
+ // which is a different task entirely. A branch name is `-` / `/` / `_`
329
+ // delimited, so requiring a delimiter (or an end) on both sides is the whole
330
+ // fix and it keeps `feat/dispatch-safety-confirmation` matching.
331
+ const bounded = new RegExp(`(^|[/_-])${tail.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([/_-]|$)`);
332
+ return other_worktree_branches(root).filter((b) => bounded.test(b.branch));
333
+ }
334
+
335
+ /**
336
+ * Who else is already on this slug — checked BEFORE the claim is written.
337
+ *
338
+ * This is the only point in the flow where the check can work, and finding that
339
+ * out was the load-bearing part of the repair. The register's context block is
340
+ * emitted on `session_start` **only**, and at session start this session's own
341
+ * slug is `null` by construction, because the roadmap is picked later. A
342
+ * roadmap-collision warning in that renderer can therefore never fire on the
343
+ * session doing the picking: the warning window sat entirely before the decision.
344
+ *
345
+ * `sessions:claim` is the decision. It runs in the foreground, its output reaches
346
+ * the model directly, and it needs no hook slot and no heartbeat delay.
347
+ *
348
+ * Two sources, because either can be silent alone: a live register record with
349
+ * the same slug, and an unmerged branch in another worktree whose NAME contains
350
+ * the slug's distinctive tail (a peer that never claimed). The name match is a
351
+ * heuristic and says so — it is a reason to look, not a verdict.
352
+ */
353
+ export function claim_conflicts(
354
+ root: string,
355
+ slug: string,
356
+ ): { kind: 'session' | 'branch'; detail: string }[] {
357
+ const out: { kind: 'session' | 'branch'; detail: string }[] = [];
358
+ const dir = register_dir(root);
359
+ const sid = env_session_id();
360
+ if (dir !== null) {
361
+ for (const r of read_live_records(dir, { prune: false })) {
362
+ // Self-exclusion by session id where we have one, and by WORKTREE where
363
+ // we do not (R2 finding 2): on a host that fills the hook envelope but
364
+ // exports no shell variable, `sid` is null, our own record would count
365
+ // as a peer, and re-claiming the same slug would exit 1 citing
366
+ // ourselves — non-idempotent on exactly the graceful-degradation path.
367
+ // Compared through `safe_stem`, because the library normalises ids and a
368
+ // raw comparison fails silently on any id the two spell differently
369
+ // (R2 finding 11).
370
+ if (sid !== null && safe_stem(r.session_id) === safe_stem(sid)) {
371
+ continue;
372
+ }
373
+ if (sid === null && _same_path(r.worktree, root)) {
374
+ continue;
375
+ }
376
+ if (r.roadmap_slug === slug && !claim_is_stale(root, r.roadmap_slug, r.worktree)) {
377
+ out.push({
378
+ kind: 'session',
379
+ detail: `${r.platform} session on branch \`${r.branch ?? 'detached'}\` in ${r.worktree}`,
380
+ });
381
+ }
382
+ }
383
+ }
384
+ for (const b of branch_name_hits(root, slug)) {
385
+ out.push({ kind: 'branch', detail: `branch \`${b.branch}\` in ${b.worktree}` });
386
+ }
387
+ // Deduplicated by rendered detail. Several records can describe the same
388
+ // peer — observed live: three sessions in one checkout all carrying one slug,
389
+ // which is the per-worktree claim defect this change also fixes. Three
390
+ // identical lines read as three peers and inflate the finding.
391
+ const seen = new Set<string>();
392
+ return out.filter((c) => {
393
+ const key = `${c.kind}\t${c.detail}`;
394
+ if (seen.has(key)) {
395
+ return false;
396
+ }
397
+ seen.add(key);
398
+ return true;
399
+ });
400
+ }
401
+
97
402
  function cmd_claim(argv: string[], root: string): number {
98
- const target = path.join(root, ROADMAP_CLAIM_REL);
403
+ const sid = env_session_id();
404
+ const target = path.join(root, roadmap_claim_rel(sid));
99
405
  if (argv.includes('--release')) {
406
+ // Release only what this session could have written. An identified session
407
+ // never writes the legacy file, so unlinking it would delete a PEER's claim
408
+ // in a mixed-host checkout and leave that peer reporting nothing, with no
409
+ // notice to either side (R2 finding 10). An unidentified session shares that
410
+ // file by construction and releasing it is the only release available.
100
411
  try {
101
412
  fs.unlinkSync(target);
102
413
  } catch {
103
414
  /* nothing to release */
104
415
  }
105
- process.stdout.write('Roadmap claim released.\n');
416
+ process.stdout.write(
417
+ sid === null
418
+ ? 'Roadmap claim released (shared worktree claim — this host exports no session id).\n'
419
+ : 'Roadmap claim released.\n',
420
+ );
106
421
  return 0;
107
422
  }
108
423
  const slug = argv.find((a) => !a.startsWith('-'));
109
424
  if (slug === undefined || slug.trim() === '') {
110
425
  return usage();
111
426
  }
427
+
428
+ const forced = argv.includes('--force');
429
+ const conflicts = forced ? [] : claim_conflicts(root, slug.trim());
430
+
431
+ // Split by evidence class, which R2 finding 4 is right to demand: a live peer
432
+ // record carrying this exact slug is a FACT and refuses the write; a branch
433
+ // whose name merely resembles the slug is a HEURISTIC and only warns. A
434
+ // heuristic that forces `--force` trains people to pass `--force`.
435
+ const hard = conflicts.filter((c) => c.kind === 'session');
436
+ const soft = conflicts.filter((c) => c.kind === 'branch');
437
+
438
+ if (hard.length > 0) {
439
+ process.stderr.write(
440
+ `sessions:claim: REFUSED — "${slug.trim()}" is already claimed by a live session.\n` +
441
+ hard.map((c) => ` · ${c.detail}\n`).join('') +
442
+ (soft.length > 0 ? soft.map((c) => ` · also, by name: ${c.detail}\n`).join('') : '') +
443
+ '\nA different branch name does not make it a different task. Pick another\n' +
444
+ 'roadmap, split this one explicitly with the peer, or re-run with --force\n' +
445
+ 'if you have decided to duplicate deliberately.\n' +
446
+ 'Advisory, not a lock: two sessions claiming simultaneously still race.\n',
447
+ );
448
+ return 1;
449
+ }
450
+
451
+ // A slug naming no open roadmap is a typo or an archived roadmap, and writing
452
+ // it manufactures exactly the stale claim this change teaches every reader to
453
+ // discount (R2 finding 13). The cheapest guard against creating one belongs at
454
+ // the only write site.
455
+ if (!forced && claim_is_stale(root, slug.trim())) {
456
+ process.stderr.write(
457
+ `sessions:claim: REFUSED — no open roadmap named "${slug.trim()}".\n` +
458
+ ' Expected agents/roadmaps/<slug>.md; archive/, later/ and skipped/ do not count.\n' +
459
+ ' Check the spelling, or use --force if the file is genuinely arriving later.\n',
460
+ );
461
+ return 1;
462
+ }
463
+
112
464
  try {
113
- write_atomic(target, `${JSON.stringify({ slug: slug.trim(), written_at: iso_now() }, null, 2)}\n`);
465
+ write_atomic(
466
+ target,
467
+ `${JSON.stringify({ slug: slug.trim(), written_at: iso_now(), session_id: sid }, null, 2)}\n`,
468
+ );
114
469
  } catch (exc) {
115
470
  process.stderr.write(`sessions:claim: could not write the claim — ${String(exc)}\n`);
116
471
  return 1; // the claim is the whole point of this verb; a silent no-op would lie
117
472
  }
118
473
  process.stdout.write(
119
474
  `Claimed "${slug.trim()}" for this session. It becomes visible to other\n` +
120
- 'sessions on the next turn, when the heartbeat lifts it into the register.\n',
475
+ 'sessions on the next turn, when the heartbeat lifts it into the register.\n' +
476
+ (sid === null
477
+ ? 'NOTE: this host exports no session id, so the claim is scoped to this\n' +
478
+ 'WORKTREE — a second session in the same checkout will overwrite it.\n'
479
+ : '') +
480
+ // The heuristic axis reports here rather than refusing above.
481
+ (soft.length > 0
482
+ ? `WARNING: ${soft.length} branch name(s) in other worktrees resemble this roadmap.\n` +
483
+ soft.map((c) => ` · ${c.detail}\n`).join('') +
484
+ ' A name match is a reason to look, not proof — check before you write code.\n'
485
+ : ''),
121
486
  );
122
487
  return 0;
123
488
  }
@@ -106,6 +106,44 @@ const SECTION_ALIASES: Record<string, Set<string>> = {
106
106
 
107
107
  const RECOMMENDED_SKILL_SECTIONS: string[] = [];
108
108
 
109
+ /**
110
+ * Sections `skill-writing` labels required for a NAMED SUBSET of skills, paired
111
+ * with the predicate that decides the subset.
112
+ *
113
+ * The predicate must be decidable from the filesystem, never from prose. That is
114
+ * what keeps the false-positive class empty by construction: a skill either ships
115
+ * a directory or it does not, so there is nothing for a heuristic to get wrong,
116
+ * and the check can ship as `error` on day one instead of as an open-ended
117
+ * advisory (the stage-vs-hedge argument in `check_suppression_hygiene.ts`).
118
+ *
119
+ * `skill-writing` carries two further section patterns — `Rationalizations to
120
+ * reject` and `Non-negotiable deliverable`. Neither is listed here, and neither
121
+ * is labelled required any more. Their populations ("security-stop-routed",
122
+ * "adjacent-technology cluster") are judgement calls that no property of the tree
123
+ * declares, so a predicate for them would be exactly the heuristic this table
124
+ * refuses; compliance stood at 0 of 289 skills while the label said required.
125
+ * Council 2026-08-11 (2/2, anthropic + openai) resolved the mismatch by
126
+ * reclassifying both to `recommended pattern` rather than by adding a declared
127
+ * frontmatter key — an authored opt-in cannot see the skill that should have
128
+ * opted in and did not, so it would buy a gate without buying the guarantee.
129
+ */
130
+ const CONDITIONAL_SKILL_SECTIONS: { section: string; when: (skillDir: string) => boolean; reason: string }[] = [
131
+ {
132
+ section: 'Security constraints',
133
+ // `isDirectory()`, not `existsSync` — a FILE named `scripts` would satisfy
134
+ // an existence test while shipping no script at all, and the predicate is
135
+ // only false-positive-free because it means what it says.
136
+ when: (skillDir) => {
137
+ try {
138
+ return fs.statSync(path.join(skillDir, 'scripts')).isDirectory();
139
+ } catch {
140
+ return false;
141
+ }
142
+ },
143
+ reason: 'the skill ships a scripts/ directory',
144
+ },
145
+ ];
146
+
109
147
  const RULE_BAD_SIGNS = ['## Procedure', '## Output format', '## Gotchas'];
110
148
 
111
149
  const FRUGALITY_WRITER_SKILLS = new Set([
@@ -1346,7 +1384,7 @@ function lint_execution_metadata(execution: ExecutionBlock): Issue[] {
1346
1384
  return issues;
1347
1385
  }
1348
1386
 
1349
- export function lint_skill(p: string, text: string): LintResult {
1387
+ export function lint_skill(p: string, text: string, absPath: string | null = null): LintResult {
1350
1388
  const issues: Issue[] = [];
1351
1389
  const suggestions: string[] = [];
1352
1390
 
@@ -1359,6 +1397,24 @@ export function lint_skill(p: string, text: string): LintResult {
1359
1397
  }
1360
1398
  }
1361
1399
 
1400
+ // `p` may be a repo-relative DISPLAY path, so it is not resolvable on its own;
1401
+ // `absPath` is the path actually read and `lint_file` always supplies it. A
1402
+ // caller that supplies neither gets no conditional check rather than a check
1403
+ // resolved against whatever the process cwd happens to be — a gate whose
1404
+ // verdict depends on the caller's directory is worse than an absent one.
1405
+ const skillDir = absPath !== null ? path.dirname(absPath) : path.isAbsolute(p) ? path.dirname(p) : null;
1406
+ for (const { section, when, reason } of CONDITIONAL_SKILL_SECTIONS) {
1407
+ if (skillDir !== null && when(skillDir) && !sectionMatches(section, sections)) {
1408
+ issues.push(
1409
+ new Issue(
1410
+ 'error',
1411
+ 'missing_conditional_section',
1412
+ `Missing required section: ${section} (${reason})`,
1413
+ ),
1414
+ );
1415
+ }
1416
+ }
1417
+
1362
1418
  for (const section of RECOMMENDED_SKILL_SECTIONS) {
1363
1419
  if (!sectionMatches(section, sections)) {
1364
1420
  issues.push(
@@ -3533,7 +3589,7 @@ export function lint_file(p: string, repoRoot: string | null = null): LintResult
3533
3589
 
3534
3590
  let result: LintResult;
3535
3591
  if (artifactType === 'skill') {
3536
- result = lint_skill(displayPath, text);
3592
+ result = lint_skill(displayPath, text, p);
3537
3593
  } else if (artifactType === 'rule') {
3538
3594
  result = lint_rule(displayPath, text);
3539
3595
  } else if (artifactType === 'command') {
@@ -530,6 +530,9 @@ export const settingsSchema = z.object({
530
530
  language: z.boolean().default(true).describe(
531
531
  'Detector B of the turn-end gate: refuse a turn whose reply is not in the pinned language (19 measured occurrences). Fenced code, inline code, quoted tool output, tables, URLs and paths are stripped first, and the remainder is then scored LEAD-FIRST by the same classifier that sets the pin — so the verdict usually comes from the reply opening, not from all of it. Sharing that classifier is deliberate: it stops the pin and the detector disagreeing about a turn. Accepted opt-in spellings for the switches below include YAML true/false and yes/no/on/off. Inert while hooks.turn_end_gate.enabled is false.',
532
532
  ),
533
+ verification: z.boolean().default(true).describe(
534
+ 'Detector C of the turn-end gate: refuse a turn that changed a file and then ran nothing that could have checked it — no test, type-check, lint or build call follows the LAST edit. Ordering is the point: a run before the final edit did not exercise it, which is the freshness clause verify-before-complete states. Read off the turn tool calls, not the prose, because a reply cannot say whether an edit was verified. Deliberately narrow on what counts as verification — a missed detection costs one unguarded turn, a false refusal teaches you to switch the gate off. No fire rate is quoted: none has been measured yet. Inert while hooks.turn_end_gate.enabled is false.',
535
+ ),
533
536
  }).default({}),
534
537
  }),
535
538
  decision_engine: z.object({