@event4u/agent-config 14.19.0 → 14.21.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 (161) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +351 -156
  5. package/README.md +1 -1
  6. package/agents/templates/.ai-council.yml.example +10 -0
  7. package/dist/agent-src/commands/agent-handoff.md +42 -41
  8. package/dist/agent-src/commands/analyze/inbox.md +1 -1
  9. package/dist/agent-src/commands/analyze/{reference-repo.md → repo.md} +247 -32
  10. package/dist/agent-src/commands/analyze/roadmap-repos.md +129 -0
  11. package/dist/agent-src/commands/analyze.md +12 -7
  12. package/dist/agent-src/commands/feature/plan.md +55 -16
  13. package/dist/agent-src/commands/feature/refactor.md +8 -3
  14. package/dist/agent-src/commands/humanize.md +48 -4
  15. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +22 -1
  16. package/dist/agent-src/contexts/execution/host-capability-manifest.md +11 -2
  17. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +35 -2
  18. package/dist/agent-src/contexts/execution/terminal-states.md +4 -1
  19. package/dist/agent-src/rules/evaluator-independence.md +4 -4
  20. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -2
  21. package/dist/agent-src/rules/untrusted-input-defense.md +0 -2
  22. package/dist/agent-src/skills/competitive-positioning/SKILL.md +2 -2
  23. package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +1 -1
  24. package/dist/agent-src/skills/humanizer/references/anti-aiisms.md +27 -11
  25. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +1 -1
  26. package/dist/agent-src/skills/markitdown/SKILL.md +2 -2
  27. package/dist/agent-src/skills/repomix-packer/SKILL.md +1 -1
  28. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +28 -6
  29. package/dist/agent-src/skills/technical-specification/SKILL.md +4 -0
  30. package/dist/agent-src/skills/test-driven-development/SKILL.md +1 -3
  31. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  32. package/dist/agent-src/templates/features.md +5 -0
  33. package/dist/agent-src/templates/roadmaps.md +29 -1
  34. package/dist/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  35. package/dist/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  36. package/dist/agent-src/templates/scripts/telemetry_report.ts +22 -2
  37. package/dist/cli/mcp/dispatch.js +10 -0
  38. package/dist/cli/mcp/stdio.js +33 -2
  39. package/dist/cli/mcp/telemetry.js +227 -0
  40. package/dist/cli-delegate/{chunk-KPBWFOPZ.js → chunk-2FTMENJL.js} +194 -37
  41. package/dist/cli-delegate/{chunk-PMEKGDED.js → chunk-2TAQ2K4V.js} +166 -1
  42. package/dist/cli-delegate/{chunk-GZTMRYCN.js → chunk-643Z7FIC.js} +79 -12
  43. package/dist/cli-delegate/{chunk-CYH5SP6M.js → chunk-I6RHFQEG.js} +151 -16
  44. package/dist/cli-delegate/{chunk-IDHUQIXP.js → chunk-J3RFNKG4.js} +41 -5
  45. package/dist/cli-delegate/{chunk-7PRE6ZV2.js → chunk-YS6XTLEK.js} +199 -4
  46. package/dist/cli-delegate/chunk-ZVBOHSVF.js +1109 -0
  47. package/dist/cli-delegate/cmd_conformance.js +88 -110
  48. package/dist/cli-delegate/cmd_doctor.js +3 -3
  49. package/dist/cli-delegate/cmd_export.js +2 -2
  50. package/dist/cli-delegate/cmd_handoff.js +42 -12
  51. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  52. package/dist/cli-delegate/cmd_prune.js +2 -2
  53. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  54. package/dist/cli-delegate/cmd_session_recycle.js +59 -902
  55. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  56. package/dist/cli-delegate/cmd_sync.js +2 -2
  57. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  58. package/dist/cli-delegate/cmd_validate.js +2 -2
  59. package/dist/discovery/deprecation-report.md +1 -1
  60. package/dist/discovery/discovery-manifest.json +62 -31
  61. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  62. package/dist/discovery/discovery-manifest.summary.md +3 -3
  63. package/dist/discovery/dormancy-report.md +1 -1
  64. package/dist/discovery/orphan-report.md +1 -1
  65. package/dist/discovery/packs.json +7 -6
  66. package/dist/discovery/trust-report.md +2 -2
  67. package/dist/discovery/workspaces.json +6 -5
  68. package/dist/hooks/dispatch.js +3801 -2803
  69. package/dist/install/install.mjs +396 -168
  70. package/dist/mcp/registry-manifest.json +3 -3
  71. package/dist/mcp/server.json +2 -2
  72. package/dist/mcp/server.mjs +809 -180
  73. package/dist/scripts/ai_council/content_ceiling.js +63 -0
  74. package/dist/scripts/ai_council/seat_policy.js +106 -0
  75. package/dist/scripts/ai_council/transport_resolver.js +21 -0
  76. package/dist/scripts/mcp_server/telemetry.js +148 -0
  77. package/docs/guidelines/agent-infra/naming.md +1 -1
  78. package/docs/guidelines/agent-infra/security-lint-containment.md +46 -6
  79. package/docs/guidelines/agent-infra/self-improvement-pipeline.md +4 -4
  80. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +1 -1
  81. package/docs/guidelines/redundancy-taxonomy.md +28 -0
  82. package/llms.txt +14 -14
  83. package/package.json +1 -1
  84. package/src/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  85. package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  86. package/src/agent-src/templates/scripts/telemetry_report.ts +22 -2
  87. package/src/config/assurance-capability-registry.json +9 -0
  88. package/src/config/gate-coverage.yml +55 -0
  89. package/src/config/gate-violation-baselines.json +18 -6
  90. package/src/config/release-gate-locality.yml +4 -3
  91. package/src/scripts/_cli/cmd_session_recycle.ts +51 -3
  92. package/src/scripts/_lib/audit_field_provenance.ts +44 -3
  93. package/src/scripts/_lib/blocked_by_marker.ts +189 -0
  94. package/src/scripts/_lib/host_capability.ts +76 -1
  95. package/src/scripts/_lib/install_reach_checks.ts +33 -1
  96. package/src/scripts/_lib/mcp_bridge.ts +222 -10
  97. package/src/scripts/_lib/mcp_consent_residual.ts +173 -0
  98. package/src/scripts/_lib/orchestration_record.ts +23 -1
  99. package/src/scripts/_lib/recycle_envelope_paths.ts +190 -0
  100. package/src/scripts/_lib/release_highlights.ts +19 -211
  101. package/src/scripts/_lib/release_material.ts +27 -77
  102. package/src/scripts/_lib/release_position.ts +96 -0
  103. package/src/scripts/_lib/review_skipped_record.ts +17 -1
  104. package/src/scripts/_lib/security_lint.ts +179 -7
  105. package/src/scripts/_lib/self_repair.ts +141 -6
  106. package/src/scripts/_lib/self_repair_store.ts +273 -7
  107. package/src/scripts/_lib/structured_ask.ts +117 -0
  108. package/src/scripts/_lib/subagent_capsule.ts +155 -3
  109. package/src/scripts/_lib/tty_prompt.ts +76 -0
  110. package/src/scripts/ai_council/api_transport.ts +163 -0
  111. package/src/scripts/ai_council/clients.ts +58 -63
  112. package/src/scripts/ai_council/config.ts +13 -0
  113. package/src/scripts/ai_council/content_ceiling.ts +79 -0
  114. package/src/scripts/ai_council/jury_aggregate.ts +178 -0
  115. package/src/scripts/ai_council/seat_policy.ts +137 -0
  116. package/src/scripts/ai_council/status_surface.ts +79 -0
  117. package/src/scripts/ai_council/transport_resolver.ts +59 -1
  118. package/src/scripts/ai_tells_rules.ts +446 -5
  119. package/src/scripts/ask_block_census.ts +499 -0
  120. package/src/scripts/bench_humanizer_eval.ts +264 -40
  121. package/src/scripts/bench_prose_tells_fp.ts +198 -0
  122. package/src/scripts/build_rule_trigger_matrix.ts +5 -0
  123. package/src/scripts/check_branch_work_committed.ts +234 -0
  124. package/src/scripts/check_mcp_doc_drift.ts +217 -0
  125. package/src/scripts/check_pack_size.ts +321 -3
  126. package/src/scripts/check_release_highlights.ts +0 -157
  127. package/src/scripts/council_cli.ts +18 -27
  128. package/src/scripts/detect_ai_tells.ts +142 -20
  129. package/src/scripts/handoff_context_hook.ts +54 -7
  130. package/src/scripts/harvest_reference_tokens.ts +698 -0
  131. package/src/scripts/hook_manifest.json +1 -1
  132. package/src/scripts/hook_manifest.yaml +31 -1
  133. package/src/scripts/hooks/concern_registry.ts +2 -0
  134. package/src/scripts/hooks/delegation_nudge_hook.ts +45 -1
  135. package/src/scripts/hooks/one_question_per_ask_hook.ts +191 -0
  136. package/src/scripts/hooks/session_eol_hook.ts +18 -7
  137. package/src/scripts/install-hooks.sh +52 -0
  138. package/src/scripts/install.ts +4 -4
  139. package/src/scripts/lint_agent_security.ts +316 -25
  140. package/src/scripts/lint_confusables.ts +42 -6
  141. package/src/scripts/lint_handoffs.ts +0 -132
  142. package/src/scripts/lint_hidden_unicode.ts +49 -7
  143. package/src/scripts/lint_instruction_smuggling.ts +42 -6
  144. package/src/scripts/lint_mcp_config_security.ts +42 -6
  145. package/src/scripts/lint_roadmap_blockers.ts +51 -1
  146. package/src/scripts/lint_roadmap_later_disposition.ts +217 -27
  147. package/src/scripts/lint_skill_frontmatter_safety.ts +43 -7
  148. package/src/scripts/mcp_server/resources.ts +41 -0
  149. package/src/scripts/mcp_server/telemetry.ts +19 -4
  150. package/src/scripts/migrate_command_suggestions.ts +1 -1
  151. package/src/scripts/orchestration_record.ts +12 -1
  152. package/src/scripts/probe_unblocked_ask.ts +135 -2
  153. package/src/scripts/release.ts +50 -95
  154. package/src/scripts/release_drill.ts +6 -10
  155. package/src/scripts/release_env.ts +1 -0
  156. package/src/scripts/release_publication.ts +40 -31
  157. package/src/scripts/self_repair_cli.ts +31 -1
  158. package/src/scripts/self_review_gate.ts +409 -15
  159. package/src/scripts/skill_scout.ts +69 -0
  160. package/dist/cli-delegate/chunk-2GNWGMAL.js +0 -172
  161. package/dist/cli-delegate/chunk-LPPWO5CE.js +0 -161
@@ -12,12 +12,16 @@ import * as fs from 'node:fs';
12
12
  import * as path from 'node:path';
13
13
 
14
14
  import {
15
+ ACTIVE_STATUSES,
15
16
  creationCapReached,
16
17
  type DefectFinding,
17
18
  type DefectRecord,
18
19
  type DefectSource,
19
20
  fingerprint,
21
+ isWellFormedTarget,
20
22
  mergeRecord,
23
+ parseTarget,
24
+ validateRecord,
21
25
  } from './self_repair.js';
22
26
 
23
27
  export const STORE_REL = path.join('agents', 'runtime', 'self-repair');
@@ -48,7 +52,16 @@ export function readRecord(root: string, fp: string): DefectRecord | null {
48
52
  }
49
53
  }
50
54
 
55
+ /**
56
+ * Write one record. VALIDATES first — a `parked` record with no wake condition
57
+ * or a malformed target never reaches the store, because a store that accepts
58
+ * an unwakeable park has the same defect the park was supposed to fix.
59
+ */
51
60
  export function writeRecord(root: string, record: DefectRecord): void {
61
+ const problems = validateRecord(record);
62
+ if (problems.length > 0) {
63
+ throw new Error(`refusing to write an invalid record: ${problems.join('; ')}`);
64
+ }
52
65
  const dir = storeDir(root);
53
66
  fs.mkdirSync(dir, { recursive: true });
54
67
  fs.writeFileSync(
@@ -71,7 +84,7 @@ export function listRecords(root: string): DefectRecord[] {
71
84
  // The overflow counter shares the directory and the extension but is not
72
85
  // a record. `isRecord` would reject it anyway; naming it here keeps the
73
86
  // exclusion intentional rather than incidental.
74
- if (!n.endsWith('.json') || n === OVERFLOW_FILE) {
87
+ if (!n.endsWith('.json') || n === OVERFLOW_FILE || n === TARGET_INDEX_FILE) {
75
88
  continue;
76
89
  }
77
90
  const rec = readRecord(root, n.slice(0, -'.json'.length));
@@ -83,8 +96,16 @@ export function listRecords(root: string): DefectRecord[] {
83
96
  return out;
84
97
  }
85
98
 
99
+ /**
100
+ * The records still in the queue. Since the status enum widened past
101
+ * `open | released`, "still queued" is `open` OR `candidate` — a record someone
102
+ * accepted but has not yet acted on has not left the queue, and a `declined`,
103
+ * `superseded`, `actioned` or `parked` one has.
104
+ */
86
105
  export function openRecords(root: string): DefectRecord[] {
87
- return listRecords(root).filter((r) => r.status === 'open');
106
+ return listRecords(root).filter((r) =>
107
+ (ACTIVE_STATUSES as readonly string[]).includes(r.status),
108
+ );
88
109
  }
89
110
 
90
111
  /**
@@ -134,29 +155,60 @@ export function upsertFinding(
134
155
  root: string,
135
156
  finding: DefectFinding,
136
157
  now: string,
158
+ // The tree the targets resolve against. Defaults to the store root, which
159
+ // is the production case (both are the repo). A test sandboxes the STORE in
160
+ // a tmp dir while still resolving `rule:scope-control` against the real
161
+ // tree, and folding the two roots together would make that untestable.
162
+ opts?: { treeRoot?: string },
137
163
  ): DefectRecord | null {
138
164
  const fp = fingerprint(finding.defect_class, finding.evidence);
165
+ // Targets are resolved against the tree HERE, at the write boundary: the
166
+ // pure half cannot read the filesystem, and validating anywhere later would
167
+ // mean a dangling id had already been stored.
168
+ const split = partitionTargets(opts?.treeRoot ?? root, [
169
+ ...(finding.target ?? []),
170
+ ...(finding.proposes ?? []),
171
+ ]);
172
+ const resolved: DefectFinding = {
173
+ ...finding,
174
+ ...(split.target.length > 0 ? { target: split.target } : {}),
175
+ ...(split.proposes.length > 0 ? { proposes: split.proposes } : {}),
176
+ };
177
+ if (split.target.length === 0) delete resolved.target;
178
+ if (split.proposes.length === 0) delete resolved.proposes;
139
179
  const existing = readRecord(root, fp);
140
180
  // The fold path never consults the cap: a fingerprint already in the store
141
181
  // adds no file, so capping it would silence a tracked defect for no bound.
142
- if (existing === null && creationCapReached(listRecords(root), finding, now)) {
143
- bumpOverflow(root, finding.source, now);
182
+ if (existing === null && creationCapReached(listRecords(root), resolved, now)) {
183
+ bumpOverflow(root, resolved.source, now);
144
184
  return null;
145
185
  }
146
- const merged = mergeRecord(existing, finding, now);
186
+ const merged = mergeRecord(existing, resolved, now);
147
187
  writeRecord(root, merged);
148
188
  return merged;
149
189
  }
150
190
 
151
- /** Mark a record released (a PR or issue now carries it). */
191
+ /**
192
+ * Mark a record released — a PR or issue now carries it.
193
+ *
194
+ * The stored status is `actioned`, not the retired `released`: releasing IS the
195
+ * action, and the widened enum has no separate slot for it. The function name
196
+ * is kept because the CALLER's event is still "a release happened".
197
+ */
152
198
  export function markReleased(root: string, fp: string, now: string): DefectRecord | null {
153
199
  const rec = readRecord(root, fp);
154
200
  if (rec === null) {
155
201
  return null;
156
202
  }
157
203
  // A successful release supersedes any earlier failed attempts.
158
- const next: DefectRecord = { ...rec, status: 'released', last_seen: now };
204
+ const next: DefectRecord = {
205
+ ...rec,
206
+ status: 'actioned',
207
+ resolution: rec.resolution ?? 'released — a pull request or issue now carries this record',
208
+ last_seen: now,
209
+ };
159
210
  delete next.release_errors;
211
+ delete next.parked_until;
160
212
  writeRecord(root, next);
161
213
  return next;
162
214
  }
@@ -186,3 +238,217 @@ export function attachReleaseErrors(
186
238
  writeRecord(root, next);
187
239
  return next;
188
240
  }
241
+
242
+ // target resolution
243
+ //
244
+ // `suggested_surface` is free text, so nothing could join a record to the asset
245
+ // it is about. A `target` entry is `<kind>:<id>` from a closed vocabulary AND it
246
+ // is resolved against the tree at WRITE time — an unresolvable entry is not
247
+ // rejected (a record proposing an asset that should exist is real information)
248
+ // and is not silently accepted either (a dangling id looks joined and joins to
249
+ // nothing). It is moved to `proposes`, where a reader can see it for what it is.
250
+
251
+ /** Where each target kind lives in the source tree. */
252
+ function _targetExists(root: string, kind: string, id: string): boolean {
253
+ if (kind === 'rule') {
254
+ return fs.existsSync(path.join(root, 'src', 'rules', `${id}.md`));
255
+ }
256
+ if (kind === 'skill') {
257
+ return fs.existsSync(path.join(root, 'src', 'skills', id, 'SKILL.md'));
258
+ }
259
+ if (kind === 'command') {
260
+ return _commandIds(root).has(id);
261
+ }
262
+ if (kind === 'hook') {
263
+ return _concernIds(root).has(id);
264
+ }
265
+ return false;
266
+ }
267
+
268
+ /**
269
+ * Command ids, read from the pack tree: every `command.md` under
270
+ * `src/domains/<pack>/…` contributes the name of its containing directory, plus
271
+ * the `parent-child` form a clustered command is referred to by.
272
+ */
273
+ function _commandIds(root: string): ReadonlySet<string> {
274
+ const out = new Set<string>();
275
+ const base = path.join(root, 'src', 'domains');
276
+ const walk = (dir: string, trail: string[]): void => {
277
+ let entries: fs.Dirent[];
278
+ try {
279
+ entries = fs.readdirSync(dir, { withFileTypes: true });
280
+ } catch {
281
+ return;
282
+ }
283
+ for (const ent of entries) {
284
+ if (ent.isDirectory()) {
285
+ walk(path.join(dir, ent.name), [...trail, ent.name]);
286
+ } else if (ent.name === 'command.md' && trail.length > 0) {
287
+ out.add(trail[trail.length - 1] as string);
288
+ if (trail.length > 1) {
289
+ out.add(trail.slice(-2).join('-'));
290
+ }
291
+ }
292
+ }
293
+ };
294
+ walk(base, []);
295
+ return out;
296
+ }
297
+
298
+ /**
299
+ * Hook-concern ids, read from the compiled manifest rather than re-parsing the
300
+ * YAML: `hook_manifest.json` is the artifact the dispatcher itself reads, so a
301
+ * concern that resolves here is a concern that actually exists at runtime.
302
+ */
303
+ function _concernIds(root: string): ReadonlySet<string> {
304
+ try {
305
+ const raw = fs.readFileSync(
306
+ path.join(root, 'src', 'scripts', 'hook_manifest.json'),
307
+ 'utf-8',
308
+ );
309
+ const parsed = JSON.parse(raw) as { manifest?: { concerns?: Record<string, unknown> } };
310
+ return new Set(Object.keys(parsed.manifest?.concerns ?? {}));
311
+ } catch {
312
+ return new Set<string>();
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Split a caller's target list into the entries that resolve in the tree and
318
+ * the entries that do not. Malformed entries go to `proposes` too — a string
319
+ * that is not `<kind>:<id>` cannot be resolved, and dropping it would lose the
320
+ * observation.
321
+ */
322
+ export function partitionTargets(
323
+ root: string,
324
+ values: readonly string[],
325
+ ): { target: string[]; proposes: string[] } {
326
+ const target: string[] = [];
327
+ const proposes: string[] = [];
328
+ for (const raw of values) {
329
+ const value = raw.trim();
330
+ if (value === '') continue;
331
+ const parsed = isWellFormedTarget(value) ? parseTarget(value) : null;
332
+ if (parsed !== null && _targetExists(root, parsed.kind, parsed.id)) {
333
+ if (!target.includes(value)) target.push(value);
334
+ } else if (!proposes.includes(value)) {
335
+ proposes.push(value);
336
+ }
337
+ }
338
+ return { target, proposes };
339
+ }
340
+
341
+ // per-target occurrence index
342
+ //
343
+ // `occurrences` counts a FINGERPRINT, which is class plus normalised evidence
344
+ // shape — so two different manifestations of one rule's weakness are two
345
+ // records with one occurrence each, and the third-recurrence escalation stated
346
+ // in `skill-improvement-pipeline` had nothing that could ever establish a third
347
+ // recurrence. This is a DERIVED read over the record files: regenerable,
348
+ // byte-stable, and gitignored with the rest of `agents/runtime/`.
349
+
350
+ // cache-invalidation: regenerated WHOLESALE from the record files on every run
351
+ // (`writeTargetIndex` recomputes from scratch — no incremental or partial
352
+ // state), and the output is deterministic by construction, so a stale read is
353
+ // impossible and a version namespace would name a format nothing reads across
354
+ // runs. The shape change that would matter is a shape change to the records
355
+ // themselves, which `validateRecord` already gates.
356
+ export const TARGET_INDEX_FILE = '_target-index.json';
357
+
358
+ export interface TargetCount {
359
+ target: string;
360
+ /** Sum of `occurrences` across every record naming this target. */
361
+ occurrences: number;
362
+ /** Fingerprints naming it — a per-target count of DISTINCT manifestations. */
363
+ records: string[];
364
+ }
365
+
366
+ function targetIndexPath(root: string): string {
367
+ return path.join(storeDir(root), TARGET_INDEX_FILE);
368
+ }
369
+
370
+ /** Aggregate occurrences by target. Pure over the store's record files. */
371
+ export function targetCounts(root: string): TargetCount[] {
372
+ const acc = new Map<string, { occurrences: number; records: string[] }>();
373
+ for (const rec of listRecords(root)) {
374
+ for (const value of rec.target ?? []) {
375
+ const prev = acc.get(value) ?? { occurrences: 0, records: [] };
376
+ prev.occurrences += Number.isFinite(rec.occurrences) ? rec.occurrences : 0;
377
+ if (!prev.records.includes(rec.fingerprint)) prev.records.push(rec.fingerprint);
378
+ acc.set(value, prev);
379
+ }
380
+ }
381
+ return [...acc.entries()]
382
+ .map(([target, v]) => ({
383
+ target,
384
+ occurrences: v.occurrences,
385
+ records: [...v.records].sort(),
386
+ }))
387
+ .sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0));
388
+ }
389
+
390
+ /**
391
+ * Write the derived index. Deterministic by construction — sorted keys, sorted
392
+ * fingerprints, no timestamp — so deleting it and rebuilding produces the same
393
+ * bytes. A timestamp here would make the artifact look fresh while saying
394
+ * nothing, and would destroy the one property that makes a rebuild routine.
395
+ */
396
+ export function writeTargetIndex(root: string): TargetCount[] {
397
+ const counts = targetCounts(root);
398
+ fs.mkdirSync(storeDir(root), { recursive: true });
399
+ fs.writeFileSync(targetIndexPath(root), `${JSON.stringify(counts, null, 2)}\n`, 'utf-8');
400
+ return counts;
401
+ }
402
+
403
+ // status transitions
404
+
405
+ /**
406
+ * Migrate `released` records — the retired status — to `actioned`, carrying a
407
+ * resolution line that says the migration is why. Idempotent: a record already
408
+ * on the new enum is left byte-identical, so running it twice over a store
409
+ * produces no second diff.
410
+ *
411
+ * Returns the fingerprints it changed.
412
+ */
413
+ export function migrateReleasedRecords(root: string): string[] {
414
+ const changed: string[] = [];
415
+ for (const rec of listRecords(root)) {
416
+ if ((rec.status as string) !== 'released') continue;
417
+ const next: DefectRecord = {
418
+ ...rec,
419
+ status: 'actioned',
420
+ resolution:
421
+ rec.resolution ??
422
+ 'migrated from the retired `released` status; the release itself is the action recorded',
423
+ };
424
+ writeRecord(root, next);
425
+ changed.push(rec.fingerprint);
426
+ }
427
+ return changed.sort();
428
+ }
429
+
430
+ /** Move a record to a terminal or parked status, validating the result. */
431
+ export function setStatus(
432
+ root: string,
433
+ fp: string,
434
+ next: DefectRecord['status'],
435
+ now: string,
436
+ extra?: { parked_until?: string; resolution?: string },
437
+ ): DefectRecord | null {
438
+ const rec = readRecord(root, fp);
439
+ if (rec === null) return null;
440
+ const updated: DefectRecord = {
441
+ ...rec,
442
+ status: next,
443
+ last_seen: now,
444
+ ...(extra?.parked_until !== undefined ? { parked_until: extra.parked_until } : {}),
445
+ ...(extra?.resolution !== undefined ? { resolution: extra.resolution } : {}),
446
+ };
447
+ if (next !== 'parked') delete updated.parked_until;
448
+ const problems = validateRecord(updated);
449
+ if (problems.length > 0) {
450
+ throw new Error(`refusing to write an invalid record: ${problems.join('; ')}`);
451
+ }
452
+ writeRecord(root, updated);
453
+ return updated;
454
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The structured-ask surface — one shared definition of "a host's own question
3
+ * tool", read by every consumer that needs it.
4
+ *
5
+ * Three consumers exist and they must not each invent their own answer:
6
+ *
7
+ * - `probe_unblocked_ask.ts` partitions hand-back ask turns into `text` and
8
+ * `native`, where `native` means the turn carried a call to such a tool.
9
+ * - `hooks/one_question_per_ask_hook.ts` denies a call carrying more than one
10
+ * question.
11
+ * - `_lib/host_capability.ts` carries the boolean `structured_ask` capability;
12
+ * the per-host SHAPE lives here rather than in the manifest, because every
13
+ * manifest field is a strict boolean that `asBool` coerces — a nested object
14
+ * put there would normalize silently to `false`.
15
+ *
16
+ * What is observed, and what is a shape guess — the line is drawn explicitly.
17
+ *
18
+ * `STRUCTURED_ASK_SHAPES` is **empty**. No host in this repository's registry
19
+ * carries an observed structured-ask tool, so there is no per-host tool name to
20
+ * match on, and inventing one from a vendor's documentation is exactly what
21
+ * `host-capability-manifest.md` § Observation protocol forbids.
22
+ *
23
+ * `STRUCTURED_ASK_TOOL_NAME_RE` is therefore a **name-shape pattern, not a host
24
+ * fact**. It exists so that if such a tool ever appears in a transcript, the
25
+ * probe MEASURES it rather than missing it — and so the number it reports today
26
+ * (zero) is a measurement rather than an absence of instrumentation. A reader
27
+ * must not read a match as evidence that a named host ships that tool; the only
28
+ * thing that establishes that is an observation written into
29
+ * `STRUCTURED_ASK_SHAPES` with the protocol's four-part citation.
30
+ *
31
+ * Consequence, stated rather than discovered later: `native` is expected to be
32
+ * **0** on every corpus this repo can scan today. That is the honest reading of
33
+ * a capability nobody has observed, not a broken detector.
34
+ */
35
+
36
+ /** The per-host shape of a structured-ask tool, once one has been OBSERVED. */
37
+ export interface StructuredAskShape {
38
+ /** The tool name as it appears in a transcript's `tool_use` block. */
39
+ readonly tool: string;
40
+ /** How many questions one call may carry. `1` is the shape this repo wants. */
41
+ readonly max_questions: number;
42
+ /** How many options one question may offer. */
43
+ readonly max_options_per_question: number;
44
+ /** Whether the host's picker admits a free-text answer alongside the options. */
45
+ readonly free_text: boolean;
46
+ }
47
+
48
+ /**
49
+ * Observed per-host structured-ask shapes.
50
+ *
51
+ * EMPTY, deliberately. A row is written only from an observation in a real
52
+ * session under `contexts/execution/host-capability-manifest.md` § Observation
53
+ * protocol — host · host version · transcript reference · date. Never from a
54
+ * host's documentation, and never by analogy to another host.
55
+ */
56
+ export const STRUCTURED_ASK_SHAPES: Readonly<Record<string, StructuredAskShape>> = {};
57
+
58
+ /** The observed shape for `hostId`, or `undefined` when none has been observed. */
59
+ export function structuredAskShape(
60
+ hostId: string | null | undefined,
61
+ ): StructuredAskShape | undefined {
62
+ if (hostId === null || hostId === undefined) {
63
+ return undefined;
64
+ }
65
+ return STRUCTURED_ASK_SHAPES[hostId];
66
+ }
67
+
68
+ /**
69
+ * Name-SHAPE pattern for a structured-ask tool. See the module header: this is
70
+ * a matcher, not a claim about any host.
71
+ */
72
+ export const STRUCTURED_ASK_TOOL_NAME_RE = /^(ask[_-]?user[_-]?question|user[_-]?question|ask[_-]?question)s?$/i;
73
+
74
+ /**
75
+ * Is `name` a structured-ask tool call?
76
+ *
77
+ * An observed per-host shape wins outright — that is a fact about the host. The
78
+ * name-shape pattern is the fallback, and it is why the probe can report a
79
+ * measured zero instead of nothing at all.
80
+ */
81
+ export function isStructuredAskTool(name: string, hostId?: string | null): boolean {
82
+ const shape = structuredAskShape(hostId);
83
+ if (shape !== undefined) {
84
+ return name === shape.tool;
85
+ }
86
+ return STRUCTURED_ASK_TOOL_NAME_RE.test(name);
87
+ }
88
+
89
+ /**
90
+ * How many questions a structured-ask tool payload carries.
91
+ *
92
+ * Returns `-1` when the payload's shape is not recognised at all — a caller
93
+ * that gates on this must treat `-1` as "cannot tell", never as "one". The two
94
+ * recognised shapes are the only ones a picker can plausibly use: an array of
95
+ * question objects under a `questions`-like key, or a single question field.
96
+ *
97
+ * A `questions` array of length 0 is 0, not 1: an empty ask is malformed and a
98
+ * gate should be able to say so.
99
+ */
100
+ export function countStructuredAskQuestions(input: unknown): number {
101
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
102
+ return -1;
103
+ }
104
+ const src = input as Record<string, unknown>;
105
+ for (const key of ['questions', 'question_list', 'items', 'prompts']) {
106
+ const v = src[key];
107
+ if (Array.isArray(v)) {
108
+ return v.length;
109
+ }
110
+ }
111
+ for (const key of ['question', 'prompt', 'header']) {
112
+ if (typeof src[key] === 'string' && (src[key] as string).trim() !== '') {
113
+ return 1;
114
+ }
115
+ }
116
+ return -1;
117
+ }
@@ -109,7 +109,39 @@ export interface WorkerCapsule {
109
109
  * affordable precisely here — an envelope is consume-once and expires in
110
110
  * hours, so the migration window is a session, not a release.
111
111
  */
112
- export const CAPSULE_SCHEMA_VERSION = 3;
112
+ export const CAPSULE_SCHEMA_VERSION = 4;
113
+
114
+ /**
115
+ * Versions a reader ACCEPTS. Version 4 is the first bump that is purely
116
+ * ADDITIVE, and that is why it widens the accepted set instead of replacing it.
117
+ *
118
+ * The compatibility contract is stated here rather than inferred.
119
+ *
120
+ * - **Scope.** The version is ENVELOPE-WIDE, not per-variant. One number
121
+ * describes the whole schema module, so a variant added later does not
122
+ * silently re-date records of another variant.
123
+ * - **New reader, old record.** A v3 record validates. Its required set is v3's
124
+ * — the fields added at v4 are not demanded of it, because demanding them
125
+ * would be a retroactive requirement on records already written.
126
+ * - **Old reader, new record.** A v3-only reader rejects a v4 record with the
127
+ * version violation named. That is the pre-existing loud-discard behaviour and
128
+ * is affordable for the same reason v3 gave: a record is consume-once and
129
+ * expires in {@link RECYCLE_MAX_AGE_HOURS} hours, so the migration window is a
130
+ * session, not a release.
131
+ * - **Unknown version.** Anything outside this set is rejected with the set
132
+ * printed. Absent is not a pass — an undated record is not "probably current".
133
+ * - **Unknown variant.** Rejected by `variant` check, never coerced. A reader
134
+ * that does not know a variant must not guess at its required fields.
135
+ * - **Rollback.** Reverting to a v3-only reader after v4 records exist discards
136
+ * those records loudly rather than mis-reading them; the loss is one session's
137
+ * resume, which is the same loss as no record at all.
138
+ *
139
+ * Adding a required field is what forced v3 to be a hard break. v4 adds
140
+ * `successful_approaches`, `open_questions` and `predecessor`, and requires the
141
+ * first and third **only of records that declare themselves v4** — so no
142
+ * previously valid record becomes invalid.
143
+ */
144
+ export const ACCEPTED_CAPSULE_VERSIONS: readonly number[] = [3, 4];
113
145
 
114
146
  /** The two CHECKPOINT variants sharing this schema. */
115
147
  export const CAPSULE_VARIANTS = ['worker', 'main_session'] as const;
@@ -280,6 +312,30 @@ export function validateCapsule(input: unknown): string[] {
280
312
  * wire gate; this variant is consumed from a FILE by
281
313
  * `handoff_context_hook`, so the module itself must be the strict gate.
282
314
  */
315
+ /**
316
+ * The four questions a resuming session asks, and the field that answers each —
317
+ * written down rather than left implicit, because the gap that produced
318
+ * `successful_approaches` stayed invisible for exactly as long as the mapping
319
+ * was unwritten: three of the four had a field, the fourth had nothing, and no
320
+ * reader of the schema had a reason to notice.
321
+ *
322
+ * *What was it about?* is answered directly by `task`, with `summary` carrying
323
+ * the one-line outcome. *What was the goal?* is answered only by PROXY:
324
+ * `acceptance_criteria` states what **done** means, which is the goal expressed
325
+ * as a test rather than as an intention, and `next_task` narrows it to the
326
+ * successor's first move. *What did NOT work?* is `failed_approaches`, direct,
327
+ * required, explicit `none`. *What DID work?* is `successful_approaches`,
328
+ * direct, required at version 4, explicit `none`.
329
+ *
330
+ * The one proxy is labelled a proxy on purpose. A goal can be met by criteria
331
+ * nobody wrote down, so reading `acceptance_criteria` as the goal itself is the
332
+ * error the label exists to prevent.
333
+ *
334
+ * Everything else the schema carries — `remaining`, `not_carried_forward`,
335
+ * `constraints`, `decisions`, `open_questions`, the drift anchor — answers a
336
+ * question the four do not ask. That is not surplus; it is simply outside this
337
+ * mapping.
338
+ */
283
339
  export interface MainSessionRecycleEnvelope {
284
340
  /** Must be {@link CAPSULE_SCHEMA_VERSION}. */
285
341
  capsule_version: number;
@@ -371,6 +427,59 @@ export interface MainSessionRecycleEnvelope {
371
427
  * successor re-burning a recorded dead end.
372
428
  */
373
429
  failed_approaches: string[];
430
+ /**
431
+ * Approaches this session tried and KEPT — "did X, it worked because Y".
432
+ *
433
+ * **The counterpart `failed_approaches` did not have**, and its absence was
434
+ * a structural defect rather than an omission: a record that can say what to
435
+ * avoid and cannot say what to repeat is pessimistic by construction, and a
436
+ * successor re-derives every working decision from scratch while being
437
+ * warned off the dead ends.
438
+ *
439
+ * Required at capsule version 4 with at least one entry, for exactly the
440
+ * reason `failed_approaches` is: a session that kept nothing writes the
441
+ * single entry `none` explicitly, so "nothing worked" stays distinguishable
442
+ * from "nobody wrote it down". An optional field would re-create the silence
443
+ * one layer down.
444
+ */
445
+ successful_approaches?: string[];
446
+ /**
447
+ * Questions this session could not settle — the successor's first reading
448
+ * list. The worker variant has carried `open_risks` since v1
449
+ * (`WorkerCapsule.open_risks`) and the handoff template asks for open
450
+ * questions in prose; the session variant had neither, so the one thing a
451
+ * resuming session most needs to not silently drop had no field.
452
+ *
453
+ * Optional, unlike `successful_approaches`: an empty question list is not
454
+ * the same asymmetry — a record with nothing open is a complete claim, and
455
+ * `remaining` already carries the work.
456
+ */
457
+ open_questions?: string[];
458
+ /**
459
+ * The session that WROTE this record.
460
+ *
461
+ * Added with `predecessor` and not separable from it: an edge needs two
462
+ * endpoints, and before v4 a record carried neither. `workspace` answers
463
+ * "which checkout", which is what the identity check needs; it cannot
464
+ * answer "which session", which is what a chain needs.
465
+ */
466
+ session_id?: string;
467
+ /**
468
+ * The session this one continues, or the explicit string `none`.
469
+ *
470
+ * Required at capsule version 4. Before it, `grep -rn
471
+ * "predecessor_session\|lineage_id" src/` returned 0 and the reader
472
+ * injected whatever record was lying at the path — which is not the same
473
+ * question as "which record is mine" that the session key answers. A key
474
+ * says whose record this is; a predecessor edge says which record this
475
+ * session should be reading.
476
+ *
477
+ * `none` is a STATED ABSENCE, never an empty string or a missing field. The
478
+ * first session in a workspace has no predecessor and says so, so a reader
479
+ * never waits on something that will not arrive — and a NAMED predecessor
480
+ * that is absent is a refusal, never a fall-through to a different record.
481
+ */
482
+ predecessor?: string;
374
483
  /**
375
484
  * Drift anchor — all three, or none of them is one. Identity is the
376
485
  * canonicalized remote (or the realpath of the common git dir when there
@@ -615,6 +724,10 @@ const RECYCLE_ENVELOPE_KEYS: ReadonlySet<string> = new Set([
615
724
  'next_task',
616
725
  'suggested_skills',
617
726
  'failed_approaches',
727
+ 'successful_approaches',
728
+ 'open_questions',
729
+ 'predecessor',
730
+ 'session_id',
618
731
  'repo_identity',
619
732
  'branch',
620
733
  'head',
@@ -644,9 +757,16 @@ export function validateRecycleEnvelope(input: unknown): string[] {
644
757
  }
645
758
  }
646
759
 
647
- if (e['capsule_version'] !== CAPSULE_SCHEMA_VERSION) {
648
- errors.push(`capsule_version must be ${CAPSULE_SCHEMA_VERSION}`);
760
+ const version = e['capsule_version'];
761
+ if (typeof version !== 'number' || !ACCEPTED_CAPSULE_VERSIONS.includes(version)) {
762
+ errors.push(`capsule_version must be one of ${ACCEPTED_CAPSULE_VERSIONS.join(' | ')}`);
649
763
  }
764
+ // Requiredness is version-conditional, and only in the additive direction:
765
+ // a v4 record owes the v4 fields, a v3 record owes exactly what v3 owed.
766
+ // Demanding the new fields of an already-written record would be a
767
+ // retroactive requirement, which is the one thing the recorded schema lock
768
+ // forbids ("add fields or variants, never repurpose or remove one").
769
+ const atV4 = version === 4;
650
770
  if (e['variant'] !== 'main_session') {
651
771
  errors.push("variant must be 'main_session'");
652
772
  }
@@ -697,6 +817,38 @@ export function validateRecycleEnvelope(input: unknown): string[] {
697
817
  );
698
818
  }
699
819
 
820
+ // Version 4 additions. `successful_approaches` mirrors `failed_approaches`
821
+ // exactly — required with >= 1 entry, `none` written explicitly — because
822
+ // the asymmetry between the two is the defect the field exists to remove,
823
+ // and an optional counterpart to a required field re-creates it.
824
+ checkList(errors, 'successful_approaches', e['successful_approaches'], MAX_LINE_CHARS, atV4);
825
+ if (Array.isArray(e['successful_approaches']) && e['successful_approaches'].length === 0) {
826
+ errors.push(
827
+ 'successful_approaches must carry at least one entry — write "none" explicitly, so ' +
828
+ 'silence and "nothing worked" stay distinguishable',
829
+ );
830
+ }
831
+ checkList(errors, 'open_questions', e['open_questions'], MAX_LINE_CHARS, false);
832
+
833
+ // The predecessor edge (Phase 2.3). Required at v4, and an EMPTY value is
834
+ // not an answer: `none` is a claim ("this session starts a chain"), absence
835
+ // is a reader waiting for something that will not arrive.
836
+ const predecessor = e['predecessor'];
837
+ if (predecessor === undefined) {
838
+ if (atV4) {
839
+ errors.push(
840
+ 'predecessor is required — name the session this one continues, or write "none" ' +
841
+ 'explicitly for the first session in a workspace',
842
+ );
843
+ }
844
+ } else if (!isShortLine(predecessor, MAX_REF_CHARS)) {
845
+ errors.push(`predecessor must be a single line of 1–${MAX_REF_CHARS} chars ("none" when there is no predecessor)`);
846
+ }
847
+
848
+ if (e['session_id'] !== undefined && !isShortLine(e['session_id'], MAX_REF_CHARS)) {
849
+ errors.push(`session_id must be a single line of 1–${MAX_REF_CHARS} chars`);
850
+ }
851
+
700
852
  // Drift anchor (Phase 3.2). Optional as a set — an older envelope simply
701
853
  // has none — but each present field must still be a short line.
702
854
  for (const field of ['repo_identity', 'branch', 'head', 'status_summary', 'last_verify'] as const) {