@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
@@ -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