@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +154 -145
- package/CONTRIBUTING.md +23 -14
- package/dist/agent-src/commands/agent-handoff.md +15 -3
- package/dist/agent-src/commands/roadmap/next.md +37 -5
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
- package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
- package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
- package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
- package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
- package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
- 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-PMQ6Z3T3.js → chunk-2TGMGLBL.js} +11 -4
- 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-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
- package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
- 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 +3 -3
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +7 -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 +13 -13
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +2 -2
- package/dist/discovery/dormancy-report.md +17 -0
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +3 -3
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +3 -3
- package/dist/hooks/dispatch.js +355 -85
- package/dist/install/install.mjs +3 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +192 -12
- 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/docs/guidelines/agent-infra/failure-signatures.md +47 -12
- package/package.json +2 -2
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -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 +11 -3
- package/src/scripts/_cli/cmd_session_recycle.ts +6 -1
- package/src/scripts/_dispatch.bash +6 -2
- package/src/scripts/_lib/env_kill_switch.ts +37 -0
- package/src/scripts/_lib/security_lint.ts +18 -4
- package/src/scripts/_lib/self_repair.ts +60 -0
- package/src/scripts/_lib/self_repair_store.ts +65 -5
- package/src/scripts/_lib/session_register.ts +67 -1
- package/src/scripts/_lib/subagent_capsule.ts +78 -1
- package/src/scripts/ai_council/config.ts +60 -0
- package/src/scripts/ai_council/events_log.ts +105 -6
- package/src/scripts/ai_council/orchestrator.ts +38 -0
- package/src/scripts/ai_council/prompts.ts +165 -3
- package/src/scripts/ai_council/quorum.ts +87 -0
- package/src/scripts/ai_team/review_gate.ts +2 -2
- package/src/scripts/build_discovery_manifest.ts +165 -0
- package/src/scripts/check_claims.ts +51 -2
- package/src/scripts/ci_time_ratio.ts +3 -1
- package/src/scripts/council_cli.ts +36 -1
- package/src/scripts/discovery_graph.ts +201 -27
- package/src/scripts/dispatch_r2_reviewer.ts +102 -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/lint_handoffs.ts +92 -2
- package/src/scripts/lint_hedge_words.ts +317 -0
- package/src/scripts/lint_mcp_config_security.ts +1 -1
- package/src/scripts/lint_skill_frontmatter_safety.ts +1 -1
- 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/self_repair_cli.ts +26 -1
- package/src/scripts/session_register_hook.ts +238 -18
- package/src/scripts/sessions_cli.ts +377 -12
- package/src/scripts/skill_linter.ts +58 -2
- 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
|
|
21
|
-
*
|
|
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
|
|
25
|
-
*
|
|
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 {
|
|
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]
|
|
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
|
-
|
|
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: ${
|
|
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
|
|
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(
|
|
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(
|
|
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({
|