@event4u/agent-config 14.2.0 → 14.5.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 (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +33 -252
  3. package/agents/templates/.ai-council.yml.example +35 -0
  4. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
  5. package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
  6. package/dist/agent-src/scripts/resume_probe.ts +154 -9
  7. package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
  8. package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
  9. package/dist/agent-src/templates/agent-settings.md +70 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  12. package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  13. package/dist/cli/python/workspace_drive.js +8 -3
  14. package/dist/cli/python/workspace_drive.js.map +1 -1
  15. package/dist/cli/python/workspace_hosts.js +16 -12
  16. package/dist/cli/python/workspace_hosts.js.map +1 -1
  17. package/dist/cli/registry.js +2 -0
  18. package/dist/cli/registry.js.map +1 -1
  19. package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
  20. package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
  21. package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
  22. package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
  23. package/dist/cli-delegate/cmd_conformance.js +26 -11
  24. package/dist/cli-delegate/cmd_doctor.js +2 -2
  25. package/dist/cli-delegate/cmd_handoff.js +3 -3
  26. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  27. package/dist/cli-delegate/cmd_settings_set.js +17 -3
  28. package/dist/discovery/deprecation-report.md +1 -1
  29. package/dist/discovery/discovery-manifest.json +4 -4
  30. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  31. package/dist/discovery/discovery-manifest.summary.md +1 -1
  32. package/dist/discovery/dormancy-report.md +1 -1
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +2 -2
  35. package/dist/discovery/trust-report.md +1 -1
  36. package/dist/discovery/workspaces.json +2 -2
  37. package/dist/hooks/dispatch.js +3754 -1721
  38. package/dist/mcp/registry-manifest.json +2 -2
  39. package/dist/mcp/server.json +2 -2
  40. package/dist/mcp/server.mjs +1070 -408
  41. package/dist/scripts/_lib/host_launch.js +66 -0
  42. package/dist/scripts/_lib/host_launch.js.map +1 -0
  43. package/dist/server/io/yamlIO.js +175 -3
  44. package/dist/server/io/yamlIO.js.map +1 -1
  45. package/dist/server/routes/wizard.js +20 -1
  46. package/dist/server/routes/wizard.js.map +1 -1
  47. package/dist/shared/settingsConsent.js +25 -0
  48. package/dist/shared/settingsConsent.js.map +1 -1
  49. package/docs/contracts/settings-classes.md +11 -0
  50. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
  51. package/package.json +1 -1
  52. package/src/agent-src/scripts/resume_probe.ts +154 -9
  53. package/src/agent-src/scripts/roadmap_gates.ts +210 -6
  54. package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  55. package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  56. package/src/config/estate-count-budget.json +127 -0
  57. package/src/config/evaluator-budgets.json +4 -4
  58. package/src/config/gate-coverage.yml +32 -4
  59. package/src/config/gate-violation-baselines.json +63 -63
  60. package/src/config/gitignore-block.txt +5 -0
  61. package/src/config/hook-latency-budget.json +40 -1
  62. package/src/config/hook-token-budget.json +16 -0
  63. package/src/config/rule-stub-ceilings.json +11 -3
  64. package/src/scripts/_dispatch.bash +42 -1
  65. package/src/scripts/_lib/council_fallback_posture.ts +81 -0
  66. package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
  67. package/src/scripts/_lib/council_settings_block.ts +152 -0
  68. package/src/scripts/_lib/headless_invocation.ts +285 -0
  69. package/src/scripts/_lib/host_launch.ts +72 -0
  70. package/src/scripts/_lib/map_to_object.ts +16 -0
  71. package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
  72. package/src/scripts/_lib/run_checkpoint.ts +409 -0
  73. package/src/scripts/_lib/session_register.ts +32 -1
  74. package/src/scripts/_lib/stdin.ts +35 -1
  75. package/src/scripts/_lib/turn_end_refusals.ts +12 -4
  76. package/src/scripts/_lib/unattended_guard.ts +371 -0
  77. package/src/scripts/ai_council/config.ts +27 -1
  78. package/src/scripts/ai_council/events_log.ts +12 -1
  79. package/src/scripts/ai_council/fallback_config.ts +114 -0
  80. package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
  81. package/src/scripts/ai_council/orchestrator.ts +251 -292
  82. package/src/scripts/ai_council/py_parity.ts +139 -0
  83. package/src/scripts/ai_council/response_render.ts +99 -0
  84. package/src/scripts/ai_council/spend_gate.ts +123 -0
  85. package/src/scripts/ai_council/transport_resolver.ts +81 -13
  86. package/src/scripts/bench_hook_injection.ts +118 -7
  87. package/src/scripts/bench_hook_latency.ts +442 -16
  88. package/src/scripts/check_estate_count.ts +924 -0
  89. package/src/scripts/compile_hook_manifest.ts +67 -0
  90. package/src/scripts/council_cli.ts +100 -125
  91. package/src/scripts/decision_memo.ts +325 -0
  92. package/src/scripts/hook_manifest.json +1 -0
  93. package/src/scripts/hook_manifest.yaml +172 -25
  94. package/src/scripts/hooks/concern_registry.ts +6 -0
  95. package/src/scripts/hooks/dispatch_hook.ts +183 -10
  96. package/src/scripts/hooks/dispatch_issues.ts +26 -0
  97. package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
  98. package/src/scripts/hooks/hook_stdin.ts +111 -5
  99. package/src/scripts/hooks/host_semantics.ts +28 -0
  100. package/src/scripts/hooks/injection_budget.ts +731 -0
  101. package/src/scripts/hooks/payload_stub.ts +446 -0
  102. package/src/scripts/hooks/run_continuation_hook.ts +683 -0
  103. package/src/scripts/hooks/session_eol_hook.ts +52 -0
  104. package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
  105. package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
  106. package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
  107. package/src/scripts/hooks_doctor.ts +16 -2
  108. package/src/scripts/interruption_report.ts +330 -13
  109. package/src/scripts/lint_hook_manifest.ts +167 -0
  110. package/src/scripts/roadmap_progress_hook.ts +254 -46
  111. package/src/scripts/run_supervise.ts +623 -0
  112. package/src/scripts/session_register_hook.ts +146 -12
  113. package/src/scripts/sessions_cli.ts +18 -6
  114. package/src/scripts/telemetry_disclosure_hook.ts +244 -0
  115. package/src/server/io/yamlIO.ts +167 -3
  116. package/src/shared/settingsConsent.ts +49 -2
  117. package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
@@ -46,12 +46,56 @@ IS A RULE VIOLATION, NOT AN OVERSIGHT.
46
46
 
47
47
  ```
48
48
  A ROADMAP WITH `[~]` DEFERRED ITEMS NEVER AUTO-ARCHIVES SILENTLY.
49
- SURFACE EVERY DEFERRED STEP. ASK THE USER WHAT HAPPENS TO THE PLAN.
49
+ SURFACE EVERY DEFERRED STEP. RESOLVE IT — VIA THE COUNCIL WHERE THE
50
+ DISPOSITION PRESERVES THE ITEM, VIA THE USER WHERE IT DOES NOT.
50
51
  A SILENT ARCHIVE THAT BURIES PLANNED-FOR-LATER WORK
51
52
  IS A RULE VIOLATION, NOT A CONVENIENCE.
52
53
  ```
53
54
 
54
- Closure check fires (`count_open == 0` and `count_deferred > 0`) → enumerate every `[~]` step, present the numbered-options resolution menu (per [`user-interaction`](user-interaction.md)), and only after the user resolves the deferrals does the `git mv` to `archive/` run. Full option menu + migration mechanics: guideline + [`roadmap-management`](../skills/roadmap-management/SKILL.md).
55
+ Closure check fires (`count_open == 0` and `count_deferred > 0`) → enumerate every `[~]` step, present the numbered-options resolution menu (per [`user-interaction`](user-interaction.md)), and only after the deferrals are resolved does the `git mv` to `archive/` run. Full option menu + migration mechanics: guideline + [`roadmap-management`](../skills/roadmap-management/SKILL.md).
56
+
57
+ ### Who resolves it — the preservation test
58
+
59
+ ```
60
+ THE COUNCIL MAY RESOLVE A `[~]` ONLY WHERE THE CHOSEN DISPOSITION KEEPS THE
61
+ ITEM ALIVE IN THE ACTIVE ESTATE. ANYTHING THAT DROPS, WEAKENS, OR PERMANENTLY
62
+ ACCEPTS THE LOSS OF IT REACHES THE USER — ALWAYS, AND NO MANDATE LIFTS THAT.
63
+ IN DOUBT, IT IS A USER DECISION. THE COUNCIL ADVISES ON HOW AND WHEN;
64
+ THE OWNER DECIDES WHETHER.
65
+ ```
66
+
67
+ One question decides the route, and it is answerable from the option itself
68
+ rather than from judgement: **does this disposition keep the criterion active
69
+ in the estate?**
70
+
71
+ | Disposition | Route |
72
+ |---|---|
73
+ | Fix the blocker now, in this change or its own PR | council |
74
+ | Carry item **and** blocker into a named follow-up roadmap created in the SAME change and estate-ratchet compliant | council |
75
+ | Merge the item into existing active work that already covers it | council |
76
+ | Restore to `[ ]` in this roadmap | council |
77
+ | Convert to `[-]` cancelled | **user** |
78
+ | Weaken the criterion, cut its scope, or accept the breakage permanently | **user** |
79
+ | Keep-in-archive (an intentional drop) | **user** |
80
+ | The item carries a `high_impact` / `user_required` classification | **user** |
81
+
82
+ - **"Immediately active" is not a promise.** A follow-up counts only if created
83
+ in the same change and estate-ratchet compliant. A verdict naming a roadmap
84
+ that does not exist yet **fails closed to the user**.
85
+ - **Recorded, or it did not happen.** At the item: criterion verbatim ·
86
+ blocker id · every option · verdict + one-sentence rationale · dissent ·
87
+ destination when carried · what closes it. A verdict with no record is a
88
+ silent drop wearing a procedure.
89
+ - **The residual hole, stated not papered over.** Both authoring seats named
90
+ it in their own strongest counter: a carried follow-up can still become an
91
+ indefinite deferral, so this test bounds *who decides*, not *whether the work
92
+ happens*. Only fix-now discharges it. A carried item untouched at the next
93
+ task boundary is raised again per [`active-remediation`](active-remediation.md).
94
+ - **Why it changed.** Adopted 2026-08-19, unanimous 2/2 council (blind peer
95
+ review). The prior text — *"Wait for the user"* — handed back a fully
96
+ analysed choice with four costed options, the low-value interruption
97
+ [`no-cheap-questions`](no-cheap-questions.md) forbids. The gate protects the
98
+ item, not the maintainer's attention.
55
99
 
56
100
  ## Later disposition — blocked-for-later roadmaps are parked, never left active
57
101
 
@@ -95,9 +139,9 @@ Before sending any reply that landed roadmap work:
95
139
  4. Did `count_open` reach 0?
96
140
  - **No (real open work remains)** → continue normally.
97
141
  - **Yes + `count_deferred == 0`** → the roadmap is **complete**. Archive it — `git mv` to `archive/` + migrate inbound refs + regen, same reply — or let the next `/create-pr` § 1c sweep do it deterministically. Either way it must never be pushed to the trunk unarchived (§ PR-gate; the `--check` backstop enforces it).
98
- - **Yes + `count_deferred > 0`** → STOP. Run the Iron Law 3 deferred-resolution flow (surface items + numbered options + wait). Archive only after resolution.
142
+ - **Yes + `count_deferred > 0`** → STOP. Run the Iron Law 3 deferred-resolution flow: surface every item, apply the preservation test above, and resolve — council where the disposition keeps the item alive, user where it does not. Archive only after resolution, and only with the resolution recorded at the item.
99
143
 
100
- Any "no" at step 2 → reply is incomplete. Do not send. A skipped step 3 regen is fine when cadence permits — checkbox truth lives in the markdown file. Skipping the deferred-resolution gate at step 4 is **never** acceptable; it is the canonical "lost-information" failure mode this rule exists to prevent.
144
+ Any "no" at step 2 → reply is incomplete. Do not send. A skipped step 3 regen is fine when cadence permits — checkbox truth lives in the markdown file. Skipping the deferred-resolution gate at step 4 is **never** acceptable; it is the canonical "lost-information" failure mode this rule exists to prevent. Note what the council path does and does not change here: it changes WHO resolves a preserving disposition, never whether the gate runs, never whether the resolution is recorded, and never the user's ownership of a drop.
101
145
 
102
146
  Body migrated to `guideline:agent-infra/roadmap-progress-mechanics` (per P4 of `road-to-kernel-and-router.md`) — glyph semantics, regen cadence, deferred-resolution menu, later-disposition procedure, PR-gate prose, plus the long-form failure-mode catalog, Copilot fallback, and hook + CI defence-in-depth.
103
147
  Trigger-set above activates this routing on demand, independent of the discipline profile (ADR-110).
@@ -16,14 +16,19 @@
16
16
  *
17
17
  * ## What it can and cannot decide, stated plainly
18
18
  *
19
- * A resume condition is prose. Two shapes are machine-decidable and the rest
20
- * are not, so the verdict set has three values rather than two:
19
+ * A resume condition is prose. TWO shapes are machine-decidable — a roadmap
20
+ * reference, and a single repo-relative file path under an existence predicate
21
+ * — and the rest are not, so the verdict set has three values rather than two:
21
22
  *
22
23
  * - `fired` — every roadmap the condition names has archived, or the
23
- * named step in a still-active roadmap is ticked.
24
+ * named step in a still-active roadmap is ticked; or the
25
+ * single path it names under `exists` is there.
24
26
  * - `unmet` — at least one named roadmap is still active with the
25
- * named step (or the whole file) still open.
26
- * - `undecidable` — the condition names no roadmap this tree can resolve.
27
+ * named step (or the whole file) still open; or the single
28
+ * path it names under `exists` is absent.
29
+ * - `undecidable` — the condition names neither, names both (a conjunction
30
+ * this probe can only half-weigh), names two of either, or
31
+ * states a predicate other than existence over its path.
27
32
  *
28
33
  * `undecidable` is reported as its own count and never folded into "nothing
29
34
  * fired". A probe that reads 44 files, understands 12 of them and announces
@@ -84,6 +89,31 @@ const COMPOUND_RE = /\bboth\b|\band\b|\([1-9]\)|\([a-e]\)|`[ \t]*\+|\+[ \t]*`/i;
84
89
  const FIELD_LABEL_RE = /\*\*[^*]+:\*\*/;
85
90
  /** A roadmap slug as written in prose, with or without the `.md` suffix. */
86
91
  const ROADMAP_REF_RE = /\broad-to-[a-z0-9][a-z0-9-]*\b/g;
92
+
93
+ /**
94
+ * A repo-relative path named inside backticks — the second decidable form.
95
+ *
96
+ * `PARK-PROBEABLE` (`road-to-estate-drawdown` Phase 2) means the resume
97
+ * condition can be tested by a script, and until this existed the probe could
98
+ * decide exactly one phrasing: a roadmap slug. Measured 2026-08-19 on the live
99
+ * estate, 42 of 44 park notes were `undecidable`, and the batch-1 sweep could
100
+ * only add six more — a verdict whose name promised a probe that did not read
101
+ * the conditions it was producing.
102
+ *
103
+ * Deliberately narrow in three ways, because a false FIRED un-parks live work:
104
+ * the path must be **inside backticks** (prose that merely contains a slash is
105
+ * not a condition), it must start at a **known top-level repo directory** and
106
+ * end in a **file extension**, and the clause must name **exactly one** — two
107
+ * paths are a conjunction this function cannot weigh, and `COMPOUND_RE` does
108
+ * not catch a comma.
109
+ *
110
+ * The extension requirement is what keeps a *directory* out. `agents/evidence/`
111
+ * exists in every checkout, so "recorded under `agents/evidence/`" is not a
112
+ * condition a probe can decide — and since `_exists` tests `isFile()`, matching
113
+ * it would have reported "does not exist" about a directory that does. A
114
+ * directory-shaped condition stays `undecidable`, which is the honest verdict.
115
+ */
116
+ const REPO_PATH_RE = /`((?:agents|docs|src|tests|internal|scripts|evals|\.github)\/[A-Za-z0-9._\-/]*[A-Za-z0-9_\-]\.[A-Za-z0-9]{2,5})`/g;
87
117
  /**
88
118
  * A step reference: `P2.1`, `Phase 2.1`, or a bare `2.1`.
89
119
  *
@@ -231,6 +261,37 @@ function _blankFencedCode(text: string): string {
231
261
  );
232
262
  }
233
263
 
264
+ /**
265
+ * The head of `s` up to its first sentence terminator OUTSIDE backticks.
266
+ *
267
+ * A naive character-class scan that stops at any dot cuts inside `foo.md` or
268
+ * inside a step id like `3.3`, which truncates the clause mid-span and hides
269
+ * the very path or step the condition names. Measured 2026-08-19: three of the six park notes written by
270
+ * `road-to-estate-drawdown` Phase 2 batch 1 lost their backticked path this
271
+ * way, and the probe then reported "names no roadmap this tree can resolve"
272
+ * about a condition that named a file.
273
+ */
274
+ function _sentenceHead(s: string): string {
275
+ // An ODD backtick count means the span never closes, and toggling on it
276
+ // would leave `inCode` stuck true for the whole remainder — the commentary
277
+ // paragraph then becomes the clause, and a path mentioned only in that
278
+ // commentary could decide the verdict. Fall back to the plain scan, which
279
+ // cuts at the first terminator and is the conservative direction.
280
+ const balanced = (s.match(/`/g) ?? []).length % 2 === 0;
281
+ let inCode = false;
282
+ for (let i = 0; i < s.length; i++) {
283
+ const ch = s[i] as string;
284
+ if (ch === '`' && balanced) {
285
+ inCode = !inCode;
286
+ continue;
287
+ }
288
+ if (!inCode && (ch === '.' || ch === '—' || ch === '–')) {
289
+ return s.slice(0, i);
290
+ }
291
+ }
292
+ return s;
293
+ }
294
+
234
295
  /**
235
296
  * The condition CLAUSE inside the condition text.
236
297
  *
@@ -260,13 +321,63 @@ function conditionClause(condition: string): string {
260
321
  // (`**Resume when** that pruning lands`), so take the span plus the
261
322
  // remainder of its sentence — up to an em dash or a full stop.
262
323
  const after = condition.slice(m.index + m[0].length);
263
- const tail = /^[^—–.]*/.exec(after)?.[0] ?? '';
264
- return `${span}${tail}`;
324
+ return `${span}${_sentenceHead(after)}`;
265
325
  }
266
326
  }
267
327
  return condition;
268
328
  }
269
329
 
330
+ /**
331
+ * The clause's predicate is EXISTENCE, not some property of the file's content.
332
+ *
333
+ * Without this the path branch would answer a question the condition did not
334
+ * ask. Measured 2026-08-19 while writing it: `road-to-catalogue-host-fit` waits
335
+ * until `agents/evidence/metrics/skill-catalogue.jsonl` *holds at least 20
336
+ * observations*; the file exists today with 7 lines, so an existence test would
337
+ * have reported FIRED and un-parked a roadmap whose bar is 13 observations away.
338
+ * A content bar is decidable — by `capture_skill_catalogue --cadence` — but not
339
+ * by this probe, and `undecidable` is the honest verdict for it.
340
+ *
341
+ * The predicate is bound POSITIONALLY to the path rather than tested as a word
342
+ * anywhere in the clause, because word-presence is not a predicate: `\`x.md\` no
343
+ * longer exists` would have reported FIRED while the file was still there, and
344
+ * `blocked until a workaround exists, see \`docs/x.md\`` would have paired the
345
+ * word with a path it does not govern. Both shapes are regression tests.
346
+ *
347
+ * KNOWN LIMIT, stated rather than papered over: this probe reads the condition
348
+ * AFTER `_truncateAtNextField`, so a content bar an author moves into a sibling
349
+ * bolded field is invisible to it and the note decides as bare existence. That
350
+ * is an authoring bypass no check here can see — the guard is that a resume
351
+ * condition states its whole bar inside the condition, and it is model-carried.
352
+ */
353
+ const EXISTENCE_PREDICATE_RE = /`(?:agents|docs|src|tests|internal|scripts|evals|\.github)\/[A-Za-z0-9._\-/]+`[ \t]*(?:currently[ \t]+)?exists\b/i;
354
+
355
+ /**
356
+ * The single repo-relative path a condition names, or `null`.
357
+ *
358
+ * `null` for zero paths (nothing to test) and for two or more (a conjunction
359
+ * this probe cannot weigh — reporting FIRED on the first would be the
360
+ * partial-match resume the compound guard already refuses).
361
+ */
362
+ function referencedPath(condition: string): string | null {
363
+ if (!EXISTENCE_PREDICATE_RE.test(condition)) {
364
+ return null;
365
+ }
366
+ const paths = _pathsIn(condition);
367
+ return paths.length === 1 ? (paths[0] as string) : null;
368
+ }
369
+
370
+ /** Every distinct repo-relative file path a clause names, predicate or not. */
371
+ function _pathsIn(condition: string): string[] {
372
+ REPO_PATH_RE.lastIndex = 0;
373
+ const found = new Set<string>();
374
+ let m: RegExpExecArray | null;
375
+ while ((m = REPO_PATH_RE.exec(condition)) !== null) {
376
+ found.add(m[1] as string);
377
+ }
378
+ return [...found].sort();
379
+ }
380
+
270
381
  /** Roadmap slugs named by the condition, minus the parked file's own slug. */
271
382
  function referencedRoadmaps(condition: string, ownSlug: string): string[] {
272
383
  const out = new Set<string>();
@@ -310,7 +421,10 @@ function stepIsDone(text: string, stepId: string): boolean | null {
310
421
  * tree rather than the live estate — a probe whose only test is the live tree
311
422
  * stops testing anything the day the live tree changes.
312
423
  */
313
- function probeLater(roadmapRoot: string): ResumeFinding[] {
424
+ function probeLater(roadmapRoot: string, repoRootArg?: string): ResumeFinding[] {
425
+ // Defaults to the repo that owns `agents/roadmaps/`. Passed explicitly by
426
+ // the tests, whose fixture tree IS the roadmap root and has no repo above it.
427
+ const repoRoot = repoRootArg ?? path.resolve(roadmapRoot, '..', '..');
314
428
  const laterDir = path.join(roadmapRoot, 'later');
315
429
  let entries: string[];
316
430
  try {
@@ -353,13 +467,43 @@ function probeLater(roadmapRoot: string): ResumeFinding[] {
353
467
  continue;
354
468
  }
355
469
  const refs = referencedRoadmaps(clause, ownSlug);
470
+ // A clause naming a roadmap AND a path is a conjunction, and deciding it
471
+ // on the roadmap half alone is the partial-match resume `COMPOUND_RE`
472
+ // already refuses for two roadmaps. `COMPOUND_RE` does not close it —
473
+ // a comma joins the two without an `and`, which is the same hole the
474
+ // two-paths rule in `referencedPath` exists for. Refused symmetrically.
475
+ if (refs.length > 0 && _pathsIn(clause).length > 0) {
476
+ findings.push({
477
+ file: `later/${name}`,
478
+ condition,
479
+ refs,
480
+ verdict: 'undecidable',
481
+ why: 'names a roadmap and a repo path — a conjunction the probe can only half-weigh',
482
+ });
483
+ continue;
484
+ }
356
485
  if (refs.length === 0) {
486
+ // Second decidable form: a single repo-relative path. `exists` is
487
+ // the whole test — the condition says the artefact will appear, so
488
+ // its appearance IS the firing, and no content check is implied.
489
+ const rel = referencedPath(clause);
490
+ if (rel !== null) {
491
+ const there = _exists(path.join(repoRoot, rel));
492
+ findings.push({
493
+ file: `later/${name}`,
494
+ condition,
495
+ refs,
496
+ verdict: there ? 'fired' : 'unmet',
497
+ why: there ? `${rel} exists` : `${rel} does not exist`,
498
+ });
499
+ continue;
500
+ }
357
501
  findings.push({
358
502
  file: `later/${name}`,
359
503
  condition,
360
504
  refs,
361
505
  verdict: 'undecidable',
362
- why: 'the condition names no roadmap this tree can resolve',
506
+ why: 'the condition names no roadmap or repo path this tree can resolve',
363
507
  });
364
508
  continue;
365
509
  }
@@ -414,6 +558,7 @@ export {
414
558
  conditionClause,
415
559
  extractCondition,
416
560
  probeLater,
561
+ referencedPath,
417
562
  referencedRoadmaps,
418
563
  roadmapDisposition,
419
564
  stepIsDone,
@@ -342,6 +342,201 @@ function render(
342
342
  * Returns '' when nothing is owned by the user, so the caller can append the
343
343
  * result unconditionally and get silence when silence is correct.
344
344
  */
345
+ /**
346
+ * Whether a recorded recommendation was drafted by the agent rather than decided
347
+ * by the maintainer.
348
+ *
349
+ * Read from the field's own text, because that is where the claim has to live: a
350
+ * drafted recommendation is written INTO the roadmap so every consumer
351
+ * (`gates`, `--reply`, the sheet) sees the same string, and a marker held only
352
+ * in the sheet would be invisible to the other two. The convention is a leading
353
+ * `(agent-drafted …)` — matched loosely on purpose, since the parenthetical also
354
+ * carries a date and a sentence of provenance.
355
+ */
356
+ export function isAgentDrafted(recommendation: string): boolean {
357
+ return /\(agent-drafted\b/i.test(recommendation);
358
+ }
359
+
360
+ /**
361
+ * How a row names its decision.
362
+ *
363
+ * `legacy` is the parser's PLACEHOLDER id for a `> Blocked until …` note, not an
364
+ * identifier anyone wrote — `renderEntry` already substitutes "blocked-until
365
+ * note" for exactly that reason, and the sheet printing the raw placeholder made
366
+ * the two views disagree about the same row.
367
+ */
368
+ export function sheetLabel(b: Blocker): string {
369
+ return isLegacy(b) ? 'blocked-until note' : `\`${b.id}\``;
370
+ }
371
+
372
+ /** First sentence of a passage, for the one-line `Default:` column. */
373
+ function firstSentence(text: string): string {
374
+ const trimmed = text.trim();
375
+ if (trimmed === '') {
376
+ return '';
377
+ }
378
+ const m = /^(.+?[.!?])(\s|$)/.exec(trimmed.replace(/\s+/g, ' '));
379
+ return m === null ? trimmed.replace(/\s+/g, ' ') : (m[1] as string);
380
+ }
381
+
382
+ /**
383
+ * The one-line question a sheet row leads with.
384
+ *
385
+ * `Question:` is an optional field and only 5 of the 21 user-owned entries carry
386
+ * one (measured 2026-08-18), so a sheet that printed it and nothing else would
387
+ * be blank for three quarters of the estate. Falling back to the first step of
388
+ * `What to do:` and then to `Blocks:` is deterministic and derived from the
389
+ * blocker's own text — which is why the row LABELS which of the three it used
390
+ * rather than presenting all three as the same kind of statement.
391
+ */
392
+ export function sheetQuestion(b: Blocker): { text: string; source: 'question' | 'todo' | 'blocks' } {
393
+ if (b.question.trim() !== '') {
394
+ return { text: firstSentence(b.question), source: 'question' };
395
+ }
396
+ const steps = regroupTodo(b.todo);
397
+ if (steps.length > 0 && (steps[0] as string).trim() !== '') {
398
+ return { text: firstSentence(steps[0] as string), source: 'todo' };
399
+ }
400
+ return { text: firstSentence(b.blocks), source: 'blocks' };
401
+ }
402
+
403
+ /**
404
+ * The consolidated decision sheet — every user-owned decision in ONE artefact.
405
+ *
406
+ * `road-to-estate-drawdown` Phase 0 exists because thirteen reading assignments
407
+ * spread across thirteen files did not happen once. The sheet is the whole of
408
+ * the human's contribution to that campaign, front-loaded: one document, sorted
409
+ * by how much each answer unblocks, with a default per item so that
410
+ * accept-all-defaults is a valid answer.
411
+ *
412
+ * This differs from `--all` in the two ways that matter for that job. `--all`
413
+ * renders every blocker grouped by owner and is a terminal view; the sheet is
414
+ * user-owned ONLY, and every row carries a `Default:` plus the PROVENANCE of
415
+ * that default. Provenance is the load-bearing column: a sheet that presented an
416
+ * agent-drafted default beside a maintainer-recorded one as the same thing would
417
+ * invite accept-all over exactly the items nobody has examined.
418
+ *
419
+ * No population figure is quoted here on purpose. The first version cited
420
+ * "14 of 21 record a `Recommendation:`, 7 do not" as the justification, and the
421
+ * change that added this renderer then drafted six of those seven — so the
422
+ * docstring was contradicted by the sheet committed beside it (R2 finding).
423
+ * The split is rendered in the sheet's own header from the live tree, which is
424
+ * the only place it cannot go stale.
425
+ *
426
+ * Deterministic by construction — the generator never invents a default. Where
427
+ * none is recorded it says so and names what the reader must supply, so
428
+ * regenerating the sheet cannot silently overwrite an answer with a guess.
429
+ */
430
+ function renderSheet(entries: readonly Entry[], now: Date): string {
431
+ const mine = entries.filter((e) => needsUser(e.blocker.owner));
432
+ const stamp = now.toISOString().slice(0, 10);
433
+ if (mine.length === 0) {
434
+ return (
435
+ `# Consolidated decision sheet — nothing owned by you (${stamp})\n\n` +
436
+ 'No open blocker in the active roadmap tree carries `Owner: user`. Nothing to answer.\n'
437
+ );
438
+ }
439
+
440
+ const noRec = mine.filter((e) => e.blocker.recommendation.trim() === '');
441
+ const legacyNotes = noRec.filter((e) => isLegacy(e.blocker)).length;
442
+ const missing = noRec.length - legacyNotes;
443
+ const drafted = mine.filter((e) => isAgentDrafted(e.blocker.recommendation)).length;
444
+ const maintainerRecorded = mine.length - noRec.length - drafted;
445
+ const out: string[] = [
446
+ `# Consolidated decision sheet — ${String(mine.length)} decisions owned by you`,
447
+ '',
448
+ `> Generated ${stamp} by \`agent-config gates --sheet\` over \`agents/roadmaps/\`.`,
449
+ '> Sorted by unblock count, descending. **Accept-all-defaults is a valid answer**',
450
+ '> (`road-to-estate-drawdown` blocker `b-consolidated-decision-sheet`, option (a));',
451
+ '> answering only the two largest-unblock items and deferring the rest is option (c),',
452
+ '> which the roadmap itself recommends. Whichever you pick, the agent writes the answers',
453
+ '> back into each roadmap at its own blocker — that is not your work.',
454
+ '>',
455
+ `> **Provenance of the ${String(mine.length)} defaults: ${String(maintainerRecorded)} maintainer-recorded ·`,
456
+ `> ${String(drafted)} \`agent-drafted\` · ${String(missing)} with no recommendation at all ·`,
457
+ `> ${String(legacyNotes)} legacy \`> Blocked until …\` note(s) that have no field to carry one.**`,
458
+ '> The distinction is in every row on purpose: an agent-drafted default is the',
459
+ '> least-examined thing on this sheet, and accept-all-defaults would accept those too.',
460
+ '>',
461
+ '> This file is DERIVED — every line above and below comes from the roadmaps themselves,',
462
+ '> so regenerating is deterministic and an answer written into this file would be lost.',
463
+ '> Answers go back into each roadmap at its own blocker; the agent does that.',
464
+ '',
465
+ '| # | Decision | Roadmap | Unblocks | Default source |',
466
+ '|---:|---|---|---:|---|',
467
+ ];
468
+ mine.forEach((e, i) => {
469
+ const source =
470
+ e.blocker.recommendation.trim() !== ''
471
+ ? isAgentDrafted(e.blocker.recommendation)
472
+ ? '`agent-drafted`'
473
+ : 'maintainer-recorded'
474
+ : isLegacy(e.blocker)
475
+ ? 'none — legacy note'
476
+ : 'none recorded';
477
+ out.push(
478
+ `| ${String(i + 1)} | ${sheetLabel(e.blocker)} | ${e.roadmapRel} | ` +
479
+ `${String(e.openSteps)} | ${source} |`,
480
+ );
481
+ });
482
+
483
+ mine.forEach((e, i) => {
484
+ const q = sheetQuestion(e.blocker);
485
+ const qLabel =
486
+ q.source === 'question'
487
+ ? 'recorded `Question:`'
488
+ : q.source === 'todo'
489
+ ? 'derived from the first `What to do:` step'
490
+ : 'derived from `Blocks:`';
491
+ const recorded = e.blocker.recommendation.trim();
492
+ out.push(
493
+ '',
494
+ `## ${String(i + 1)} · ${sheetLabel(e.blocker)}`,
495
+ '',
496
+ `- **Roadmap:** ${e.roadmapRel}`,
497
+ `- **Unblocks:** ${String(e.openSteps)} open step(s) — ${e.blocker.blocks || '(not recorded)'}`,
498
+ `- **Question** (${qLabel})**:** ${q.text || '(the blocker records no question, no steps and no Blocks: field)'}`,
499
+ );
500
+ if (recorded === '' && isLegacy(e.blocker)) {
501
+ // A legacy `> Blocked until …` note is not a `### blocker:` entry and
502
+ // has no field to carry a recommendation. Saying "agent-drafted, to
503
+ // be written in" would point at a slot that does not exist, so the
504
+ // row names the actual next action instead.
505
+ out.push(
506
+ '- **Default:** _this is a legacy `> Blocked until …` note, not a `### blocker:` entry,' +
507
+ ' so it has no `Recommendation:` field to read. Converting it into a real blocker' +
508
+ ' entry is what gives it a default._',
509
+ '- **Default source:** none — legacy note',
510
+ );
511
+ } else if (recorded === '') {
512
+ out.push(
513
+ '- **Default:** _none recorded — needs an agent-drafted default before this row can be' +
514
+ ' answered by accepting it._',
515
+ '- **Default source:** `agent-drafted` (to be written in below, and marked as such)',
516
+ );
517
+ } else {
518
+ const drafted = isAgentDrafted(recorded);
519
+ out.push(
520
+ `- **Default:** ${firstSentence(recorded)}`,
521
+ drafted
522
+ ? '- **Default source:** `agent-drafted` — written into the roadmap’s ' +
523
+ '`Recommendation:` field and marked there, NOT a maintainer decision'
524
+ : '- **Default source:** maintainer-recorded `Recommendation:` in the roadmap',
525
+ `- **Recommendation (full):** ${recorded.replace(/\s+/g, ' ')}`,
526
+ );
527
+ }
528
+ if (e.blocker.ifNothing.trim() !== '') {
529
+ out.push(`- **If you do nothing:** ${e.blocker.ifNothing.replace(/\s+/g, ' ')}`);
530
+ }
531
+ out.push(
532
+ `- **Done when:** ${e.blocker.resolvedWhen.replace(/\s+/g, ' ') || '(not recorded)'}`,
533
+ '- **Your answer:** _(accept default · override · defer)_',
534
+ );
535
+ });
536
+ out.push('');
537
+ return out.join('\n');
538
+ }
539
+
345
540
  function renderReply(entries: readonly Entry[]): string {
346
541
  const mine = entries.filter((e) => needsUser(e.blocker.owner));
347
542
  if (mine.length === 0) {
@@ -564,6 +759,7 @@ function main(argv?: readonly string[]): number {
564
759
  const json = args.includes('--json');
565
760
  const reply = args.includes('--reply');
566
761
  const pending = args.includes('--pending');
762
+ const sheet = args.includes('--sheet');
567
763
 
568
764
  // `--execute <id>` is answered FIRST, and it is the only mode that writes.
569
765
  // It used to sit after the `--pending` early return, so `--pending
@@ -643,13 +839,20 @@ function main(argv?: readonly string[]): number {
643
839
  // `--reply` deliberately does not carry the probe: ADR-222 fixes that form
644
840
  // at exactly ONE decision rendered in full, and a fired resume condition is
645
841
  // not a decision the reader owes anybody — it is a file that can move.
646
- const resumed = reply ? [] : probeLater(roadmapRoot);
842
+ // `--reply` and `--sheet` deliberately do not carry the probe: ADR-222 fixes
843
+ // the reply form at exactly ONE decision rendered in full, and a fired resume
844
+ // condition is not a decision the reader owes anybody — it is a file that can
845
+ // move. The sheet is the same argument for the same reason: it is the set of
846
+ // answers the user owes, and a movable file is not one of them.
847
+ const resumed = reply || sheet ? [] : probeLater(roadmapRoot, repoRoot);
647
848
  process.stdout.write(
648
- reply
649
- ? renderReply(entries)
650
- : json
651
- ? renderJson(entries, all, resumed)
652
- : render(entries, all, resumed),
849
+ sheet
850
+ ? renderSheet(entries, new Date())
851
+ : reply
852
+ ? renderReply(entries)
853
+ : json
854
+ ? renderJson(entries, all, resumed)
855
+ : render(entries, all, resumed),
653
856
  );
654
857
  return 0;
655
858
  }
@@ -687,6 +890,7 @@ export {
687
890
  renderJson,
688
891
  renderReply,
689
892
  renderResumed,
893
+ renderSheet,
690
894
  renderPending,
691
895
  renderPendingJson,
692
896
  };
@@ -293,8 +293,8 @@ After the last step of a roadmap is done, check completion status:
293
293
  |---|---|---|---|---|
294
294
  | ≥ 1 | 0 | 0 | 0 | **Auto-archive** (silent) — pure completion |
295
295
  | ≥ 1 | 0 | 0 | ≥ 1 | **Auto-archive** (silent) — done with explicit drops |
296
- | ≥ 1 | 0 | ≥ 1 | ≥ 0 | **STOP — Iron Law 3 flow.** Surface deferred items, present follow-up options, wait. See step 4b. |
297
- | 0 | 0 | ≥ 1 | ≥ 0 | **STOP — Iron Law 3 flow.** Was this a scope-drop or deferred-to-later? Same options as 4b. |
296
+ | ≥ 1 | 0 | ≥ 1 | ≥ 0 | **STOP — Iron Law 3 flow.** Surface deferred items, present the options, resolve by the preservation test (council where the item survives, user where it does not). See step 4b. |
297
+ | 0 | 0 | ≥ 1 | ≥ 0 | **STOP — Iron Law 3 flow.** Was this a scope-drop or deferred-to-later? Same options and same routing as 4b. |
298
298
  | 0 | 0 | 0 | ≥ 1 | **Auto-skip** (silent) — no work, all cancelled |
299
299
  | ≥ 0 | ≥ 1 | ≥ 0 | ≥ 0 | **Ask the user** — open work remains (step 4a) |
300
300
 
@@ -343,8 +343,13 @@ After the last step of a roadmap is done, check completion status:
343
343
  the active tree.
344
344
 
345
345
  4b. **Deferred items present (`count_deferred ≥ 1`, `count_open == 0`)** — Iron Law 3 flow.
346
- The archive is **blocked** until the user resolves the deferrals. Surface
347
- the plan and ask:
346
+ Archive **blocked** until resolved. WHO resolves is the preservation test in
347
+ [`roadmap-progress-sync § Who resolves it`](../../rules/roadmap-progress-sync.md),
348
+ never the mode — same menu either way, only the resolver differs:
349
+
350
+ - **1, 2, 4** keep the item alive → **council**, recorded at the item (2 only
351
+ if the follow-up lands in the SAME change).
352
+ - **3, 5** drop or weaken it → **user**, always. In doubt: user.
348
353
 
349
354
  ```
350
355
  📋 Roadmap closure check — deferred items must resolve before archive:
@@ -507,6 +507,68 @@ telemetry:
507
507
  # Append-only JSONL log. Path is relative to the project root.
508
508
  # Always gitignored (see config/gitignore-block.txt).
509
509
  path: .agent-engagement.jsonl
510
+ # --- Org telemetry (road-to-org-telemetry Phase 1) ---
511
+ #
512
+ # Class-A usage records: which skill was invoked, on which host, at which
513
+ # package version and rule tier, under a pseudonymous user hash and an
514
+ # hour-bucketed timestamp. Zero bytes of project content — the record type
515
+ # has no field capable of holding free text.
516
+ #
517
+ # `enabled: true` on its own does NOT switch this on. All three of
518
+ # `endpoint`, `org_id`, and `salt` must also carry a value, and none of
519
+ # them has a default. That is deliberate: this repository is public, so it
520
+ # ships the key NAMES and no values, and a clone cannot reach the write
521
+ # path by copying the tree. The values come from an org pack.
522
+ #
523
+ # Phase 1 writes records LOCALLY and nothing else. The outbound flush is
524
+ # Phase 2 and org-wide enablement is Phase 3; both are gated on open
525
+ # blockers in the roadmap.
526
+ remote:
527
+ # Master switch. Combined with the three required fields below — see
528
+ # `active` in scripts/telemetry/settings.ts.
529
+ enabled: false
530
+ # Where Phase 2 will flush to. Recorded now, never called in Phase 1.
531
+ endpoint: ""
532
+ # The org whose pack enabled this install; written into every record.
533
+ org_id: ""
534
+ # Org-pack secret. Salts the user and session hashes so a digest of a
535
+ # login name is not reversible by dictionary. Never written into a
536
+ # record, never logged.
537
+ #
538
+ # It must live in the SAME `.agent-settings.yml` the reader resolves —
539
+ # `read_remote_settings` opens one file and consults no override layer,
540
+ # so a salt placed in `.agent-settings.local.yml` leaves the install
541
+ # silently inactive. Keep that file out of version control, or supply it
542
+ # from the org pack at install time.
543
+ salt: ""
544
+ # `session-end` = flush at session end (Phase 2). `never` = no transport,
545
+ # which is a legitimate way to run the local half while the sink question
546
+ # is still open.
547
+ #
548
+ # `never` is NOT an unbounded steady state: the retention budget below
549
+ # bounds the log whether or not a flush is configured. The consequence
550
+ # worth knowing before choosing `never` is that the local file is then the
551
+ # only store, so a record retention evicts is a record that is never sent
552
+ # anywhere. Raise `retention.max_age_days` if you need a longer local
553
+ # history.
554
+ flush: session-end
555
+ # Growth budget for the log below (`scale-discipline` R-A7). Enforced by
556
+ # the writer itself, so there is no sweep to remember to run: after each
557
+ # append, records older than `max_age_days` are dropped, and the file is
558
+ # held under `max_bytes` from the oldest end regardless of dates.
559
+ #
560
+ # The defaults are measured, not guessed. The busiest machine this tree
561
+ # has observed writes 6.6 skill invocations a day at ~270 bytes each, so
562
+ # 90 days is roughly 600 records (~160 KiB) and is the cap that actually
563
+ # binds; 2 MiB is the backstop for a rate that has not been observed.
564
+ # Omit the section to take both defaults — there is no way to configure
565
+ # "unbounded", which is the state R-A7 forbids.
566
+ retention:
567
+ max_age_days: 90
568
+ max_bytes: 2097152
569
+ output:
570
+ # Append-only JSONL log, relative to the project root. Gitignored.
571
+ path: .agent-telemetry.jsonl
510
572
 
511
573
  # --- Linked projects (cross-repo awareness, local-only) ---
512
574
  #
@@ -602,6 +664,14 @@ the canonical narrative lives in
602
664
  | `telemetry.artifact_engagement.record.consulted` | `true`, `false` | `true` | When `true`: record artefacts loaded into context. |
603
665
  | `telemetry.artifact_engagement.record.applied` | `true`, `false` | `true` | When `true`: record artefacts cited or driving a decision. |
604
666
  | `telemetry.artifact_engagement.output.path` | path | `.agent-engagement.jsonl` | Append-only JSONL log path, relative to the project root. Always gitignored. |
667
+ | `telemetry.remote.enabled` | `true`, `false` | `false` | Opt-in for Class-A org usage records. **Not the switch on its own** — `endpoint`, `org_id`, and `salt` must all carry a value too, and none has a default, so a clone of the public repository cannot reach the write path. Not in the shipped template; a missing section means disabled. |
668
+ | `telemetry.remote.endpoint` | URL | *(none)* | Where Phase 2 will flush records to. Recorded now; no outbound call exists in Phase 1. Empty ⇒ inactive. |
669
+ | `telemetry.remote.org_id` | string | *(none)* | The org whose pack enabled this install; written into every record. Empty ⇒ inactive. |
670
+ | `telemetry.remote.salt` | string | *(none)* | Org-pack secret salting the user and session hashes — without it a digest of a login name is dictionary-reversible. Never written into a record, never logged. Must sit in the same `.agent-settings.yml` the reader resolves: it consults no override layer, so a salt in `.agent-settings.local.yml` leaves the install silently inactive. Empty ⇒ inactive. |
671
+ | `telemetry.remote.flush` | `session-end`, `never` | `session-end` | When Phase 2 flushes. `never` means no transport — a legitimate way to run the local half while the sink question is open. It is **not** an unbounded steady state: `retention` below bounds the log either way. Under `never` the local file is the only store, so a record retention evicts is never sent anywhere. |
672
+ | `telemetry.remote.retention.max_age_days` | positive integer | `90` | TTL for the local log (`scale-discipline` R-A7). Records older than this are dropped after an append. At the observed 6.6 invocations/day this is ~600 records (~160 KiB) and is the cap that actually binds. Zero, negative, and non-integer values fall back to the default — there is no way to configure "unbounded". |
673
+ | `telemetry.remote.retention.max_bytes` | positive integer | `2097152` (2 MiB) | Hard ceiling on the local log regardless of record age; the oldest records are dropped first, back to 75 % of the cap so the next prune is not immediate. Backstop for a write rate this tree has not observed. |
674
+ | `telemetry.remote.output.path` | path | `.agent-telemetry.jsonl` | Append-only JSONL log path, relative to the project root. Gitignored. Bounded by `retention` above. |
605
675
  | `linked_projects` | list of `{path, include}` | `[]` | IDE-attached sibling repos in scope for proactive cross-repo awareness. **Belongs in `.agent-settings.local.yml` (in agents/settings/)** (per-machine, gitignored). See [cross-repo guide](../../docs/guides/cross-repo-linked-projects.md) + ADR-032. |
606
676
  | `linked_projects_max_files` | integer | `20000` | File-count ceiling above which a detected sibling is flagged `large` (awareness only). Never excludes. |
607
677
  | `knowledge.global_sharing.enabled` | `true`, `false` | `true` | **User-global** (keep in `~/.event4u/agent-config/agent-settings.yml`). Master switch for the file-first global knowledge-card store (ADR-100; default-ON per ADR-119, the council-validated bounded-downside flip superseding ADR-103 — adversarially spot-checked redaction incl. hidden-unicode hardening, narrowest tier default, pre-registered demotion trigger). `false` fully no-ops the layer (single-key revert); v1 project-local cards unaffected. |