session-orchestrator 4.1.0 → 5.0.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 (230) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.agents/skills/session-start/SKILL.md +1 -1
  3. package/.agents/skills/ux-grill/SKILL.md +22 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +3 -2
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  8. package/.codex-plugin/skills/session-start/SKILL.md +1 -1
  9. package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
  10. package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
  11. package/.cursor/commands/ux-grill.md +14 -0
  12. package/.cursor/skills/session-plan/SKILL.md +1 -1
  13. package/.cursor/skills/session-start/SKILL.md +1 -1
  14. package/.cursor/skills/ux-grill/SKILL.md +13 -0
  15. package/.cursor-plugin/plugin.json +1 -1
  16. package/AGENTS.md +2 -1
  17. package/CHANGELOG.md +128 -1
  18. package/README.md +98 -86
  19. package/agents/dialectic-deriver.md +11 -0
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/close.md +3 -3
  22. package/commands/go.md +2 -0
  23. package/commands/memory-cleanup.md +4 -3
  24. package/commands/persona-panel.md +1 -1
  25. package/commands/session.md +3 -2
  26. package/commands/ux-grill.md +51 -0
  27. package/docs/README.md +4 -4
  28. package/docs/USER-GUIDE.md +117 -50
  29. package/docs/agent-authoring.md +2 -2
  30. package/docs/baseline.md +55 -1
  31. package/docs/ci-setup.md +1 -1
  32. package/docs/codex-setup.md +9 -0
  33. package/docs/components.md +9 -9
  34. package/docs/cursor-setup.md +1 -0
  35. package/docs/events-schema.md +13 -6
  36. package/docs/github-mirror-protection.md +61 -20
  37. package/docs/instruction-delivery.md +1 -1
  38. package/docs/memory-proposal-flow.md +3 -3
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/migration-v5.md +62 -0
  41. package/docs/owner-config-schema.md +74 -90
  42. package/docs/persona-panel.md +4 -4
  43. package/docs/pi-setup.md +1 -0
  44. package/docs/rule-authoring.md +13 -6
  45. package/docs/scope-collision-guard.md +16 -0
  46. package/docs/session-config-reference.md +55 -22
  47. package/docs/session-config-template.md +9 -5
  48. package/docs/vault-docs-architecture.md +4 -2
  49. package/hooks/_lib/hook-import-set.json +70 -3
  50. package/hooks/_lib/lock-bootstrap.mjs +84 -1
  51. package/hooks/_lib/vcs-create-matcher.mjs +401 -16
  52. package/hooks/enforce-scope.mjs +201 -0
  53. package/hooks/hooks-codex.json +1 -1
  54. package/hooks/hooks-cursor.json +5 -0
  55. package/hooks/hooks.json +7 -2
  56. package/hooks/on-session-start.mjs +171 -49
  57. package/hooks/post-bash-issue-budget-refund.mjs +375 -0
  58. package/hooks/pre-auq-clarity.mjs +70 -18
  59. package/hooks/pre-bash-issue-budget.mjs +170 -26
  60. package/hooks/subagent-telemetry.mjs +106 -20
  61. package/package.json +5 -4
  62. package/pi/prompts/ux-grill.md +12 -0
  63. package/scripts/baseline-archetypes.mjs +28 -0
  64. package/scripts/ci/assert-vitest-green.mjs +4 -2
  65. package/scripts/dialectic-deriver.mjs +32 -8
  66. package/scripts/emit-session.mjs +72 -1
  67. package/scripts/lib/agent-status.mjs +441 -9
  68. package/scripts/lib/auq/schema.mjs +10 -3
  69. package/scripts/lib/auto-dialectic.mjs +0 -68
  70. package/scripts/lib/baseline-archetypes.mjs +439 -0
  71. package/scripts/lib/build-live-signals.mjs +5 -6
  72. package/scripts/lib/ci-status-banner.mjs +29 -6
  73. package/scripts/lib/claude-md-budget-lint.mjs +52 -2
  74. package/scripts/lib/config/issue-budget.mjs +68 -8
  75. package/scripts/lib/config/private-config-dir.mjs +3 -2
  76. package/scripts/lib/config/remote-hosts.mjs +2 -2
  77. package/scripts/lib/config-schema.mjs +79 -0
  78. package/scripts/lib/config.mjs +12 -1
  79. package/scripts/lib/eval/engine.mjs +7 -1
  80. package/scripts/lib/file-lock.mjs +151 -8
  81. package/scripts/lib/git-porcelain.mjs +113 -0
  82. package/scripts/lib/instruction-budget-guard.mjs +415 -47
  83. package/scripts/lib/io.mjs +29 -4
  84. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  85. package/scripts/lib/issue-budget.mjs +412 -9
  86. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  87. package/scripts/lib/learnings/sizing-subject.mjs +44 -0
  88. package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
  89. package/scripts/lib/locks/state-md-lock.mjs +19 -41
  90. package/scripts/lib/maintenance-due-banner.mjs +450 -0
  91. package/scripts/lib/owner-config.example.yaml +29 -46
  92. package/scripts/lib/owner-yaml.mjs +14 -13
  93. package/scripts/lib/peer-cards/merger.mjs +143 -0
  94. package/scripts/lib/pre-dispatch-check.mjs +20 -14
  95. package/scripts/lib/project-hygiene.mjs +81 -30
  96. package/scripts/lib/quality-gate.mjs +27 -71
  97. package/scripts/lib/reconcile/engine.mjs +19 -1
  98. package/scripts/lib/reconcile/writer.mjs +278 -11
  99. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  100. package/scripts/lib/rules-sync.mjs +34 -4
  101. package/scripts/lib/scope-echo.mjs +346 -0
  102. package/scripts/lib/session-close-backfill.mjs +182 -40
  103. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  104. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  105. package/scripts/lib/session-lock.mjs +62 -2
  106. package/scripts/lib/session-record-repair.mjs +91 -0
  107. package/scripts/lib/session-schema/constants.mjs +6 -0
  108. package/scripts/lib/session-schema/filters.mjs +26 -1
  109. package/scripts/lib/session-schema/validator.mjs +20 -0
  110. package/scripts/lib/session-shape.mjs +558 -0
  111. package/scripts/lib/session-start-probes.mjs +429 -56
  112. package/scripts/lib/session-token-rollup.mjs +95 -10
  113. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  114. package/scripts/lib/state-md.mjs +1 -0
  115. package/scripts/lib/subagents-schema.mjs +77 -9
  116. package/scripts/lib/telemetry/pricing.mjs +197 -0
  117. package/scripts/lib/telemetry/sync.mjs +50 -1
  118. package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
  119. package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
  120. package/scripts/lib/tmux-layout/layouts.mjs +62 -4
  121. package/scripts/lib/ux-grill/collect.mjs +1163 -0
  122. package/scripts/lib/ux-grill/compare.mjs +285 -0
  123. package/scripts/lib/ux-grill/manifest.mjs +618 -0
  124. package/scripts/lib/ux-grill/measures.mjs +431 -0
  125. package/scripts/lib/ux-grill/paths.mjs +224 -0
  126. package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
  127. package/scripts/lib/ux-grill/reconcile.mjs +344 -0
  128. package/scripts/lib/ux-grill/run-record.mjs +316 -0
  129. package/scripts/lib/ux-grill/schema.mjs +321 -0
  130. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  131. package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
  132. package/scripts/lib/validate/check-unwired-features.mjs +56 -27
  133. package/scripts/lib/vault-mirror/process.mjs +2 -1
  134. package/scripts/lib/vault-status/board-lock.mjs +18 -0
  135. package/scripts/lib/vault-status/board-writer.mjs +8 -0
  136. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  137. package/scripts/lib/wave-resource-gate.mjs +23 -27
  138. package/scripts/lib/wave-sizing.mjs +10 -3
  139. package/scripts/materialize-wave-scope.mjs +68 -14
  140. package/scripts/mcp-server.sh +16 -1
  141. package/scripts/print-applicable-rules.mjs +7 -6
  142. package/scripts/print-learnings-index.mjs +3 -2
  143. package/scripts/release.mjs +7 -2
  144. package/scripts/session-shape.mjs +266 -0
  145. package/skills/_shared/config-reading.md +15 -9
  146. package/skills/_shared/private-capability-context.md +89 -0
  147. package/skills/bootstrap/SKILL.md +60 -209
  148. package/skills/bootstrap/_shared-template.md +99 -14
  149. package/skills/bootstrap/deep-template.md +36 -26
  150. package/skills/bootstrap/fast-template.md +44 -8
  151. package/skills/bootstrap/intensity-heuristic.md +10 -4
  152. package/skills/bootstrap/private-contract.md +119 -0
  153. package/skills/bootstrap/public-fallback.md +30 -18
  154. package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
  155. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
  156. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
  157. package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
  158. package/skills/bootstrap/standard-template.md +39 -24
  159. package/skills/claude-md-drift-check/SKILL.md +9 -2
  160. package/skills/claude-md-drift-check/checker.mjs +213 -21
  161. package/skills/discovery/SKILL.md +6 -173
  162. package/skills/discovery/probes/vault-staleness.mjs +35 -5
  163. package/skills/discovery/probes-docs.md +8 -4
  164. package/skills/discovery/probes-supply-chain.md +4 -2
  165. package/skills/discovery/probes-ui.md +8 -4
  166. package/skills/discovery/probes-vault.md +12 -4
  167. package/skills/discovery/references/discovery-interactive-triage.md +139 -0
  168. package/skills/discovery/references/discovery-triage-state.md +54 -0
  169. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  170. package/skills/eval/rubric-v1.md +13 -0
  171. package/skills/evolve/SKILL.md +2 -458
  172. package/skills/evolve/references/evolve-analyze-mode.md +360 -0
  173. package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
  174. package/skills/gitlab-ops/SKILL.md +3 -3
  175. package/skills/grill/SKILL.md +1 -1
  176. package/skills/memory-cleanup/SKILL.md +2 -2
  177. package/skills/plan/mode-new.md +9 -0
  178. package/skills/plan/mode-retro.md +4 -3
  179. package/skills/reconcile/SKILL.md +11 -1
  180. package/skills/session-end/SKILL.md +3 -2
  181. package/skills/session-end/drift-operations.md +20 -5
  182. package/skills/session-end/metrics-collection.md +1 -0
  183. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  184. package/skills/session-end/phase-3-6-tail.md +27 -67
  185. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  186. package/skills/session-end/references/phase-2-quality-gate.md +3 -3
  187. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  188. package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
  189. package/skills/session-end/session-metrics-write.md +33 -12
  190. package/skills/session-plan/SKILL.md +46 -180
  191. package/skills/session-plan/references/session-plan-task-classification.md +152 -0
  192. package/skills/session-plan/wave-template.md +8 -15
  193. package/skills/session-start/SKILL.md +41 -7
  194. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  195. package/skills/session-start/phase-8-5-express-path.md +12 -9
  196. package/skills/session-start/references/operations-contract.md +114 -0
  197. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  198. package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
  199. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  200. package/skills/session-start/soul.md +2 -2
  201. package/skills/test-runner/SKILL.md +1 -1
  202. package/skills/test-runner/rubric-v1.md +2 -2
  203. package/skills/tmux-layout/SKILL.md +3 -1
  204. package/skills/ux-grill/SKILL.md +211 -0
  205. package/skills/ux-grill/rubric-v2.md +201 -0
  206. package/skills/ux-grill/soul.md +76 -0
  207. package/skills/wave-executor/SKILL.md +32 -127
  208. package/skills/wave-executor/circuit-breaker.md +3 -1
  209. package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
  210. package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
  211. package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
  212. package/skills/wave-executor/references/wave-loop-review.md +19 -6
  213. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  214. package/templates/_shared/ux-manifest.template.md +149 -0
  215. package/templates/nextjs-minimal/package.json +1 -1
  216. package/templates/node-minimal/package.json +1 -1
  217. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  218. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  219. package/scripts/lib/owner-config/coerce.mjs +0 -29
  220. package/scripts/lib/owner-config/constants.mjs +0 -21
  221. package/scripts/lib/owner-config/defaults.mjs +0 -50
  222. package/scripts/lib/owner-config/error.mjs +0 -19
  223. package/scripts/lib/owner-config/index.mjs +0 -13
  224. package/scripts/lib/owner-config/merge.mjs +0 -52
  225. package/scripts/lib/owner-config/validate.mjs +0 -259
  226. package/scripts/lib/owner-config-loader.mjs +0 -170
  227. package/scripts/lib/owner-config.mjs +0 -28
  228. package/scripts/lib/soul-resolve.mjs +0 -130
  229. package/scripts/lib/vault-mirror/render.mjs +0 -8
  230. package/templates/_shared/journey-manifest.md +0 -114
@@ -0,0 +1,346 @@
1
+ /**
2
+ * scope-echo.mjs — the RECEIVE-side half of the FILE-SCOPE observability chain (#1092).
3
+ *
4
+ * `hooks/pre-task-scope-disjoint.mjs` observes the SEND side: it emits
5
+ * `orchestrator.wave_dispatch.scope_checked` describing what the guard saw in the
6
+ * prompt the coordinator handed to the dispatch tool. Nothing in this repo can
7
+ * observe the other half — whether the `FILE-SCOPE` block reached the agent's
8
+ * assembled context — because no platform boundary exposes the final prompt
9
+ * (`docs/scope-collision-guard.md` § 4.2).
10
+ *
11
+ * What IS feasible is a self-reported echo: the coordinator appends one
12
+ * instruction line naming the expected digest of the agent's own scope file, and
13
+ * the agent ends its report with `SCOPE-DIGEST: <8-hex>`. Post-wave, the
14
+ * coordinator compares the two and emits
15
+ * `orchestrator.wave_dispatch.scope_echo_checked`.
16
+ *
17
+ * CEILING (BV-004, named deliberately): this is a SOFT signal. The digest is
18
+ * handed to the agent in the prompt, so an agent that never read the scope block
19
+ * can still copy the line — it proves the report carried the digest the
20
+ * coordinator handed it, never that the model read or obeyed the scope. Revisit
21
+ * when the platform exposes a stable prompt-assembly boundary; at that point the
22
+ * digest can be computed against the real assembled prompt instead of echoed.
23
+ *
24
+ * Pure + stdlib only. Nothing here throws on malformed input — a broken echo
25
+ * check must never change a wave's outcome.
26
+ *
27
+ * Exports:
28
+ * scopeDigest(paths) → 8-hex string (deterministic, never throws)
29
+ * renderScopeEchoInstruction(paths) → the ONE prompt line to append
30
+ * extractScopeEcho(reportText) → { echoed, digest }
31
+ * checkScopeEcho({ scopeFilePath, reportText }) → { echoed, match, expected, actual, reason? }
32
+ *
33
+ * CLI:
34
+ * node scripts/lib/scope-echo.mjs --scope-file <path> --instruction
35
+ * node scripts/lib/scope-echo.mjs --scope-file <path> --report-file <path> \
36
+ * [--wave N --agent-id ID --emit]
37
+ * node scripts/lib/scope-echo.mjs --help
38
+ */
39
+
40
+ import { readFileSync } from 'node:fs';
41
+ import { resolve } from 'node:path';
42
+ import { fileURLToPath } from 'node:url';
43
+
44
+ import { digestSha256Short } from './crypto-digest-utils.mjs';
45
+
46
+ /** Marker the agent must emit. Case-sensitive by design — a lowercase lookalike is not an echo. */
47
+ export const SCOPE_ECHO_MARKER = 'SCOPE-DIGEST:';
48
+
49
+ /** Event name for the post-wave verdict. */
50
+ export const SCOPE_ECHO_EVENT = 'orchestrator.wave_dispatch.scope_echo_checked';
51
+
52
+ /** Max characters retained for `agent_id` in the payload (same clamp as the send-side hook). */
53
+ const AGENT_ID_MAX = 120;
54
+
55
+ /**
56
+ * The LAST `SCOPE-DIGEST: <8 hex>` occurrence wins. Optional surrounding backticks
57
+ * (agents like to fence the line) and trailing whitespace are tolerated. The
58
+ * negative lookahead is load-bearing: without it `SCOPE-DIGEST: 123456789` would
59
+ * match its first eight characters and report a false echo.
60
+ */
61
+ const ECHO_RE = /SCOPE-DIGEST:[ \t]*`{0,3}([0-9a-fA-F]{8})(?![0-9a-fA-F])/g;
62
+
63
+ /**
64
+ * Normalize a declared scope into the canonical digest input: trimmed, empties
65
+ * dropped, deduplicated, sorted, joined with `\n`. Order and incidental
66
+ * whitespace therefore never change the digest — two coordinators that wrote the
67
+ * same set of paths in a different order agree.
68
+ *
69
+ * @param {unknown} paths
70
+ * @returns {string[]}
71
+ */
72
+ function normalizeScopePaths(paths) {
73
+ if (!Array.isArray(paths)) return [];
74
+ const out = new Set();
75
+ for (const entry of paths) {
76
+ if (typeof entry !== 'string') continue;
77
+ const trimmed = entry.trim();
78
+ if (trimmed) out.add(trimmed);
79
+ }
80
+ return [...out].sort();
81
+ }
82
+
83
+ /**
84
+ * Digest of a declared file scope: sha256 over the normalized paths, first 8 hex.
85
+ * An empty (or unusable) scope digests the empty string — deterministic, and it
86
+ * never throws, so a Discovery wave's empty scope has a stable answer rather than
87
+ * an error path.
88
+ *
89
+ * @param {unknown} paths array of path strings (anything else → empty scope).
90
+ * @returns {string} 8-character lowercase hex digest.
91
+ */
92
+ export function scopeDigest(paths) {
93
+ return digestSha256Short(normalizeScopePaths(paths).join('\n'));
94
+ }
95
+
96
+ /**
97
+ * The single line the coordinator appends to an agent prompt after the fenced
98
+ * `FILE-SCOPE` block. It names the expected digest outright, so the agent only
99
+ * has to echo it — the check is about the line surviving the round trip, not
100
+ * about making the agent compute a hash.
101
+ *
102
+ * @param {unknown} paths
103
+ * @returns {string}
104
+ */
105
+ export function renderScopeEchoInstruction(paths) {
106
+ return `End your final report with the line: ${SCOPE_ECHO_MARKER} ${scopeDigest(paths)}`;
107
+ }
108
+
109
+ /**
110
+ * Find the LAST scope-echo marker in an agent's report.
111
+ *
112
+ * The MARKER stays case-sensitive by design (a lowercase `scope-digest:` is not
113
+ * an echo), but the HEX PAYLOAD is accepted case-insensitively and normalized to
114
+ * lowercase: `0123ABCD` and `0123abcd` are the same 32 bits, and an agent that
115
+ * upper-cases the digest has demonstrably carried the line through — which is
116
+ * the only thing this signal measures.
117
+ *
118
+ * @param {unknown} reportText
119
+ * @returns {{ echoed: boolean, digest: string|null }}
120
+ */
121
+ export function extractScopeEcho(reportText) {
122
+ if (typeof reportText !== 'string' || reportText.length === 0) {
123
+ return { echoed: false, digest: null };
124
+ }
125
+ let last = null;
126
+ ECHO_RE.lastIndex = 0;
127
+ for (const match of reportText.matchAll(ECHO_RE)) last = match[1].toLowerCase();
128
+ return last === null ? { echoed: false, digest: null } : { echoed: true, digest: last };
129
+ }
130
+
131
+ /**
132
+ * Read a per-agent scope file (`<state-dir>/filescopes/wave-<N>/<agent-id>.json`,
133
+ * a JSON array of path strings — shape (a) of the two scope shapes, see
134
+ * CLAUDE.md / AGENTS.md § allowedPaths) and compare its digest against the agent's echo.
135
+ *
136
+ * Never throws: an unreadable or wrongly-shaped scope file yields
137
+ * `{ match: false, reason: 'scope-file-unreadable' }` so the caller logs an
138
+ * informational line instead of failing a wave.
139
+ *
140
+ * An EMPTY declared scope (a Discovery wave, `[]`) is NOT a failed echo: no
141
+ * instruction line was ever injected, so the agent had nothing to echo. Those
142
+ * verdicts carry `applicable: false` + `reason: 'scope-empty'` so a consumer can
143
+ * exclude them from the echo-rate instead of counting every uninstructed agent
144
+ * as a miss. `applicable` is OMITTED (never `true`) on the instructed path — the
145
+ * same "absent is not zero" discipline `wave` follows.
146
+ *
147
+ * @param {{ scopeFilePath?: string, reportText?: string }} args
148
+ * @returns {{ echoed: boolean, match: boolean, expected: string|null, actual: string|null, applicable?: boolean, reason?: string }}
149
+ */
150
+ export function checkScopeEcho({ scopeFilePath, reportText } = {}) {
151
+ const { echoed, digest } = extractScopeEcho(reportText);
152
+ /** @type {string|null} */
153
+ let expected;
154
+ try {
155
+ const parsed = JSON.parse(readFileSync(String(scopeFilePath), 'utf8'));
156
+ if (!Array.isArray(parsed)) throw new TypeError('scope file is not a JSON array');
157
+ if (normalizeScopePaths(parsed).length === 0) {
158
+ return {
159
+ echoed,
160
+ match: false,
161
+ expected: null,
162
+ actual: digest,
163
+ applicable: false,
164
+ reason: 'scope-empty',
165
+ };
166
+ }
167
+ expected = scopeDigest(parsed);
168
+ } catch {
169
+ return { echoed, match: false, expected: null, actual: digest, reason: 'scope-file-unreadable' };
170
+ }
171
+ if (!echoed) return { echoed, match: false, expected, actual: null, reason: 'echo-absent' };
172
+ const match = digest === expected;
173
+ return { echoed, match, expected, actual: digest, ...(match ? {} : { reason: 'digest-mismatch' }) };
174
+ }
175
+
176
+ /**
177
+ * Build the telemetry payload for a verdict. Counts, closed enums and digests
178
+ * only — NO path and NO prompt text (issue #1092 acceptance criterion 3): this
179
+ * record also travels over the optional Clank Event-Bus webhook with no
180
+ * redaction, and declared paths carry private project slugs.
181
+ *
182
+ * NO path here means no path in THIS function's own object literal, before the
183
+ * standard `sessionAttribution` spread, which adds the session ids like every
184
+ * event — that spread is applied by the caller and is out of this promise's
185
+ * scope.
186
+ *
187
+ * `wave` follows the "absent is not zero" rule (`docs/events-schema.md`): it is
188
+ * omitted rather than defaulted when the caller has no wave number. `applicable`
189
+ * follows the same rule in the other direction — it is emitted ONLY as `false`,
190
+ * for a verdict whose agent was never instructed (empty declared scope), so a
191
+ * consumer computing an echo-rate can filter those rows out. Its absence means
192
+ * "instructed", never "unknown". (Events-schema row owned elsewhere: the
193
+ * `orchestrator.wave_dispatch.scope_echo_checked` catalogue entry needs an
194
+ * `applicable` field row.)
195
+ *
196
+ * @param {{ echoed: boolean, match: boolean, expected: string|null, actual: string|null, applicable?: boolean, reason?: string }} verdict
197
+ * @param {{ wave?: unknown, agentId?: unknown }} [meta]
198
+ * @returns {Record<string, unknown>}
199
+ */
200
+ export function scopeEchoPayload(verdict, meta = {}) {
201
+ const wave = Number(meta.wave);
202
+ const agentId = typeof meta.agentId === 'string' ? meta.agentId.slice(0, AGENT_ID_MAX) : null;
203
+ return {
204
+ ...(Number.isFinite(wave) && wave > 0 ? { wave } : {}),
205
+ ...(agentId ? { agent_id: agentId } : {}),
206
+ echoed: verdict.echoed,
207
+ match: verdict.match,
208
+ ...(verdict.applicable === false ? { applicable: false } : {}),
209
+ expected_digest: verdict.expected ?? null,
210
+ actual_digest: verdict.actual ?? null,
211
+ ...(verdict.reason ? { reason: verdict.reason } : {}),
212
+ };
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // CLI
217
+ // ---------------------------------------------------------------------------
218
+
219
+ /** `--help` text. Exit codes are named here per `.claude/rules/cli-design.md` § Discoverability. */
220
+ const USAGE = `Usage:
221
+ scope-echo --scope-file <path> --instruction
222
+ Print the ONE prompt line instructing an agent to echo its scope digest.
223
+ Prints nothing for an empty or unreadable scope file.
224
+
225
+ scope-echo --scope-file <path> [--report-file <path>] [--wave N]
226
+ [--agent-id ID] [--emit]
227
+ Compare the agent report's SCOPE-DIGEST echo against the scope file and
228
+ print the verdict payload as JSON on stdout. --emit additionally appends
229
+ an ${SCOPE_ECHO_EVENT} row to .orchestrator/metrics/events.jsonl
230
+ (best-effort: a failed emit never changes the verdict or the exit code).
231
+
232
+ scope-echo --help
233
+
234
+ Exit codes: 0 for every verdict, 1 for a missing --scope-file.
235
+ `;
236
+
237
+ /**
238
+ * Minimal `--flag value` / `--flag` parser (no dependency, same shape as the
239
+ * other `scripts/lib/*` CLIs in this repo).
240
+ *
241
+ * @param {string[]} argv
242
+ * @returns {Record<string, string|boolean>}
243
+ */
244
+ function parseArgv(argv) {
245
+ /** @type {Record<string, string|boolean>} */
246
+ const out = {};
247
+ for (let i = 0; i < argv.length; i += 1) {
248
+ const arg = argv[i];
249
+ if (!arg.startsWith('--')) continue;
250
+ const key = arg.slice(2);
251
+ const next = argv[i + 1];
252
+ if (next !== undefined && !next.startsWith('--')) {
253
+ out[key] = next;
254
+ i += 1;
255
+ } else {
256
+ out[key] = true;
257
+ }
258
+ }
259
+ return out;
260
+ }
261
+
262
+ /**
263
+ * @param {string[]} [argv]
264
+ * @returns {Promise<number>} process exit code: 0 for every verdict (this is an
265
+ * observability tool — a broken echo check never fails a wave), or 1 for a
266
+ * missing `--scope-file`, which is a USER error per `.claude/rules/cli-design.md`
267
+ * (1 = user/input error; 2 is reserved for system errors).
268
+ */
269
+ export async function main(argv = process.argv.slice(2)) {
270
+ const args = parseArgv(argv);
271
+ if (args.help) {
272
+ process.stdout.write(USAGE);
273
+ return 0;
274
+ }
275
+ const scopeFilePath = typeof args['scope-file'] === 'string' ? args['scope-file'] : '';
276
+ if (!scopeFilePath) {
277
+ process.stderr.write('scope-echo: --scope-file <path> is required\n');
278
+ process.stderr.write(USAGE);
279
+ return 1;
280
+ }
281
+
282
+ if (args.instruction) {
283
+ let paths = [];
284
+ try {
285
+ const parsed = JSON.parse(readFileSync(scopeFilePath, 'utf8'));
286
+ if (Array.isArray(parsed)) paths = parsed;
287
+ } catch {
288
+ // Unreadable scope file → no instruction. Silent on stdout, because the
289
+ // caller splices stdout straight into the prompt. But it must NOT be
290
+ // silent altogether: a corrupt scope file and a legitimate empty Discovery
291
+ // scope both produced exit 0 with no output at all, so the operator could
292
+ // not tell an injected-nothing-by-design run from a broken one. One stderr
293
+ // line separates them (#1092 review R3).
294
+ process.stderr.write(
295
+ `scope-echo: scope file unreadable (${scopeFilePath}) — no echo line injected\n`,
296
+ );
297
+ return 0;
298
+ }
299
+ if (paths.length === 0) return 0;
300
+ process.stdout.write(`${renderScopeEchoInstruction(paths)}\n`);
301
+ return 0;
302
+ }
303
+
304
+ const reportFile = typeof args['report-file'] === 'string' ? args['report-file'] : '';
305
+ /** @type {string} */
306
+ let reportText;
307
+ try {
308
+ reportText = reportFile ? readFileSync(reportFile, 'utf8') : '';
309
+ } catch {
310
+ reportText = '';
311
+ }
312
+
313
+ const verdict = checkScopeEcho({ scopeFilePath, reportText });
314
+ const payload = scopeEchoPayload(verdict, {
315
+ wave: args.wave,
316
+ agentId: typeof args['agent-id'] === 'string' ? args['agent-id'] : undefined,
317
+ });
318
+
319
+ if (args.emit) {
320
+ try {
321
+ const repoRoot = process.cwd();
322
+ const { emitEvent, sessionAttribution } = await import('./events.mjs');
323
+ await emitEvent(
324
+ SCOPE_ECHO_EVENT,
325
+ { ...payload, ...sessionAttribution(repoRoot) },
326
+ { repoRoot },
327
+ );
328
+ } catch (err) {
329
+ // Telemetry never changes the verdict (same discipline as the send-side
330
+ // hook): an unwritable ledger — a full disk, or `events.jsonl` existing as
331
+ // a DIRECTORY — still prints the verdict on stdout and still exits 0.
332
+ process.stderr.write(`scope-echo: emit failed — ${err?.message ?? err}\n`);
333
+ }
334
+ }
335
+
336
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
337
+ return 0;
338
+ }
339
+
340
+ const invokedAsCli =
341
+ process.argv[1] !== undefined &&
342
+ resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
343
+
344
+ if (invokedAsCli) {
345
+ process.exitCode = await main();
346
+ }