@event4u/agent-config 14.2.0 → 14.4.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 (116) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +286 -218
  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 +3684 -1719
  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 +120 -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 +100 -6
  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/injection_budget.ts +595 -0
  100. package/src/scripts/hooks/payload_stub.ts +446 -0
  101. package/src/scripts/hooks/run_continuation_hook.ts +627 -0
  102. package/src/scripts/hooks/session_eol_hook.ts +52 -0
  103. package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
  104. package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
  105. package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
  106. package/src/scripts/hooks_doctor.ts +16 -2
  107. package/src/scripts/interruption_report.ts +330 -13
  108. package/src/scripts/lint_hook_manifest.ts +167 -0
  109. package/src/scripts/roadmap_progress_hook.ts +254 -46
  110. package/src/scripts/run_supervise.ts +623 -0
  111. package/src/scripts/session_register_hook.ts +108 -8
  112. package/src/scripts/sessions_cli.ts +18 -6
  113. package/src/scripts/telemetry_disclosure_hook.ts +244 -0
  114. package/src/server/io/yamlIO.ts +167 -3
  115. package/src/shared/settingsConsent.ts +49 -2
  116. package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
@@ -121,6 +121,89 @@ export function roadmap_claim_rel(session_id: string | null | undefined): string
121
121
  return norm;
122
122
  }
123
123
 
124
+ /** Claim directory name, beside the session register in the git common dir. */
125
+ export const CLAIM_DIRNAME = 'agent-claims';
126
+
127
+ /**
128
+ * Where a roadmap claim lives: the git COMMON dir, which every worktree shares.
129
+ *
130
+ * ## The defect this fixes, measured 2026-08-19
131
+ *
132
+ * The run contract had two halves that resolved DIFFERENT roots.
133
+ * `sessions_cli.cmd_claim` joined the relative path below against
134
+ * `process.cwd()` — the worktree the operator stands in. The stop-slot concern
135
+ * joined it against `envelope.workspace_root`, which the dispatcher sets from
136
+ * `--project-dir`, i.e. the host's `CLAUDE_PROJECT_DIR` — the parent checkout.
137
+ * In a worktree those are different trees, so the concern found no contract,
138
+ * took its `contract absent -> no-op` rung, and wrote NO event.
139
+ *
140
+ * That last part is why it went unnoticed for a release: the ledger built to
141
+ * make `run-continuation` auditable is empty exactly when it never ran, and
142
+ * empty is also what a healthy idle run looks like. The dispatch integration
143
+ * test could not see it either — it passes the same root to writer and reader,
144
+ * the one arrangement in which the two agree. Two independent symptoms pointed
145
+ * at it: `run-continuation.jsonl` never appeared, and `run:supervise` listed a
146
+ * session as `roadmap=-` minutes after that session had claimed.
147
+ *
148
+ * ## Why the common dir, and not "make the hook resolve the worktree"
149
+ *
150
+ * A roadmap claim is repo-global BY INTENT — `sessions:claim` tells the
151
+ * operator it "becomes visible to other sessions", and the whole point is that
152
+ * a peer in another worktree sees it. `register_dir` already puts the session
153
+ * register in the common dir for exactly that reason, and the claim is the
154
+ * register's other half. The alternative — teaching every concern to distrust
155
+ * `--project-dir` — is a change to every concern rather than to one path.
156
+ *
157
+ * `null` when git cannot be read at all; the caller then falls back to the
158
+ * per-tree path, which is the pre-fix behaviour rather than a new failure.
159
+ */
160
+ export function claim_dir(workspace_root: string): string | null {
161
+ const common = git_common_dir(workspace_root);
162
+ return common === null ? null : path.join(common, CLAIM_DIRNAME);
163
+ }
164
+
165
+ /**
166
+ * Absolute path this session's claim is WRITTEN to.
167
+ *
168
+ * Shared-dir when git resolves, per-tree otherwise. One function for both
169
+ * halves of the contract — the writer and the reader call this, so they cannot
170
+ * disagree about the tree again.
171
+ */
172
+ export function claim_file(workspace_root: string, session_id: string | null | undefined): string {
173
+ const dir = claim_dir(workspace_root);
174
+ const rel = roadmap_claim_rel(session_id);
175
+ if (dir === null) return path.join(workspace_root, rel);
176
+ return path.join(dir, path.basename(rel));
177
+ }
178
+
179
+ /**
180
+ * Every path a claim may be READ from, newest convention first.
181
+ *
182
+ * The per-tree paths stay readable so a claim written before this change is
183
+ * not lost — measured at 17 such files in the main checkout on the day of the
184
+ * fix. A read-only fallback, never a write target: writing both would recreate
185
+ * the two-trees problem with extra steps.
186
+ */
187
+ export function claim_read_paths(
188
+ workspace_root: string,
189
+ session_id: string | null | undefined,
190
+ ): string[] {
191
+ const rel = roadmap_claim_rel(session_id);
192
+ const dir = claim_dir(workspace_root);
193
+ const out: string[] = [];
194
+ if (dir !== null) {
195
+ out.push(path.join(dir, path.basename(rel)));
196
+ if (rel !== ROADMAP_CLAIM_REL) {
197
+ out.push(path.join(dir, path.basename(ROADMAP_CLAIM_REL)));
198
+ }
199
+ }
200
+ out.push(path.join(workspace_root, rel));
201
+ if (rel !== ROADMAP_CLAIM_REL) {
202
+ out.push(path.join(workspace_root, ROADMAP_CLAIM_REL));
203
+ }
204
+ return out;
205
+ }
206
+
124
207
  /** The shape `sessions:claim` writes. `session_id` is absent on legacy files. */
125
208
  export interface RoadmapClaim {
126
209
  slug: string;
@@ -173,12 +256,23 @@ export function read_claimed_slug(
173
256
  ): string | null {
174
257
  const own = roadmap_claim_rel(session_id);
175
258
  if (own !== ROADMAP_CLAIM_REL) {
176
- const mine = _read_claim_file(path.join(workspace_root, own));
177
- if (mine !== null) {
178
- return mine.slug;
259
+ // Per-session, shared dir first, then the pre-fix per-tree path. The
260
+ // ORDER carries the migration: a session that has re-claimed since the
261
+ // fix wins over its own stale per-tree file.
262
+ for (const p of claim_read_paths(workspace_root, session_id)) {
263
+ if (path.basename(p) === path.basename(ROADMAP_CLAIM_REL)) continue;
264
+ const mine = _read_claim_file(p);
265
+ if (mine !== null) return mine.slug;
179
266
  }
180
267
  }
181
- const legacy = _read_claim_file(path.join(workspace_root, ROADMAP_CLAIM_REL));
268
+ // The legacy per-WORKTREE file, in the same two locations. Unchanged in
269
+ // meaning: it is a claim on the checkout, not on a session.
270
+ let legacy: RoadmapClaim | null = null;
271
+ for (const p of claim_read_paths(workspace_root, session_id)) {
272
+ if (path.basename(p) !== path.basename(ROADMAP_CLAIM_REL)) continue;
273
+ legacy = _read_claim_file(p);
274
+ if (legacy !== null) break;
275
+ }
182
276
  if (legacy === null) {
183
277
  return null;
184
278
  }
@@ -578,10 +672,16 @@ export function main(): number {
578
672
  // upgrade, unbounded. An id-less session owns that file by construction,
579
673
  // so its end is the honest moment to drop it. An identified session still
580
674
  // leaves it alone: it may be a peer's.
581
- try {
582
- fs.unlinkSync(path.join(root, roadmap_claim_rel(session_id)));
583
- } catch {
584
- /* absent or unwritable — a leftover claim expires with its record */
675
+ // Both locations: the shared dir a claim is written to now, and the
676
+ // per-tree path a pre-fix claim still sits at. Dropping only one
677
+ // would leave the other readable and reinstate the claim.
678
+ for (const p of claim_read_paths(root, session_id)) {
679
+ if (path.basename(p) !== path.basename(roadmap_claim_rel(session_id))) continue;
680
+ try {
681
+ fs.unlinkSync(p);
682
+ } catch {
683
+ /* absent or unwritable — a leftover claim expires with its record */
684
+ }
585
685
  }
586
686
  return 0;
587
687
  }
@@ -52,7 +52,7 @@ import {
52
52
  ttl_is_measured,
53
53
  ttl_seconds_for,
54
54
  } from './_lib/session_register.js';
55
- import { claim_is_stale, roadmap_claim_rel } from './session_register_hook.js';
55
+ import { claim_file, claim_is_stale, claim_read_paths } from './session_register_hook.js';
56
56
 
57
57
  function usage(): number {
58
58
  process.stderr.write(
@@ -401,17 +401,29 @@ export function claim_conflicts(
401
401
 
402
402
  function cmd_claim(argv: string[], root: string): number {
403
403
  const sid = env_session_id();
404
- const target = path.join(root, roadmap_claim_rel(sid));
404
+ // The claim lives in the git COMMON dir, so the writer here and the stop-slot
405
+ // reader agree on one tree even when they start from different ones. Before
406
+ // this, the CLI joined against `process.cwd()` (the operator's worktree) and
407
+ // the hook against `--project-dir` (the parent checkout) — in a worktree,
408
+ // two different files, and `run-continuation` silently never engaged. See
409
+ // `claim_dir` in session_register_hook.ts for the full account.
410
+ const target = claim_file(root, sid);
405
411
  if (argv.includes('--release')) {
406
412
  // Release only what this session could have written. An identified session
407
413
  // never writes the legacy file, so unlinking it would delete a PEER's claim
408
414
  // in a mixed-host checkout and leave that peer reporting nothing, with no
409
415
  // notice to either side (R2 finding 10). An unidentified session shares that
410
416
  // file by construction and releasing it is the only release available.
411
- try {
412
- fs.unlinkSync(target);
413
- } catch {
414
- /* nothing to release */
417
+ //
418
+ // Both locations — the shared dir and the pre-fix per-tree path — because
419
+ // a release that clears one and leaves the other readable does not release.
420
+ for (const p of claim_read_paths(root, sid)) {
421
+ if (path.basename(p) !== path.basename(target)) continue;
422
+ try {
423
+ fs.unlinkSync(p);
424
+ } catch {
425
+ /* nothing to release */
426
+ }
415
427
  }
416
428
  process.stdout.write(
417
429
  sid === null
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Org-telemetry disclosure — `session_start` concern.
4
+ *
5
+ * `road-to-org-telemetry` Phase 3, step 2, and the compensating control
6
+ * ADR-233 § D5 accepts in exchange for the `org-pack` consent class.
7
+ *
8
+ * WHY IT IS NOT OPTIONAL. Every other consent in this suite is given by the
9
+ * person it binds: `jit-answer` is a question they answered, `gui` is a human
10
+ * at the settings editor, `manual` is their own hand-edit. `org-pack` is the
11
+ * one grant made by somebody else — a human org administrator deciding for
12
+ * every install the pack reaches. That is a legitimate consent only if the
13
+ * person whose usage is being described can see that it was given. Without
14
+ * this line, an install would report on a user who was never told, which is
15
+ * the shape "a public repository that phones home reads as spyware" names in
16
+ * the roadmap's Risk 3, regardless of how content-free the payload is.
17
+ *
18
+ * WHAT IT SAYS AND WHAT IT DOES NOT. The line names the fact (pseudonymous
19
+ * usage data is reported), the recipient (`org_id`, and the endpoint host so
20
+ * "to whom" is answerable rather than gestured at), and where to read the
21
+ * local records. It carries no salt, no user hash, and no full endpoint URL
22
+ * with credentials — see the redaction in `_endpointHost`.
23
+ *
24
+ * ONCE, NOT EVERY SESSION — and re-disclosed when the fact changes. The step
25
+ * asks for the line "at first session start", so the concern records what it
26
+ * disclosed in `agents/runtime/state/telemetry-disclosure.json` and stays
27
+ * silent afterwards. The recorded key is the (org_id, endpoint) pair rather
28
+ * than a bare flag: a new sink or a new org is a NEW disclosure, and a design
29
+ * that showed the line once and then silently followed the data somewhere
30
+ * else would be worse than one that never showed it. A state file that cannot
31
+ * be written is treated as "not yet disclosed", so the failure mode is a
32
+ * repeated line rather than a suppressed one.
33
+ *
34
+ * SILENT ON EVERY INACTIVE SHAPE. `read_remote_settings` is fail-closed on
35
+ * each axis, and this concern adds nothing to it: no settings file, no
36
+ * section, `enabled: false`, or any of `endpoint` / `org_id` / `salt` missing
37
+ * all mean `active === false`, and an inactive install prints nothing and
38
+ * writes nothing.
39
+ */
40
+ import * as fs from 'node:fs';
41
+ import * as path from 'node:path';
42
+ import { pathToFileURL } from 'node:url';
43
+
44
+ import { readHookStdin } from './hooks/hook_stdin.js';
45
+ import { is_replay_mode } from './hooks/state_io.js';
46
+ import { _resolveRoot, resolveSettingsPath } from './hooks/telemetry_usage_hook.js';
47
+ import { read_remote_settings } from '../agent-src/templates/scripts/telemetry/settings.js';
48
+
49
+ const EXIT_ALLOW = 0;
50
+
51
+ /**
52
+ * Longest interpolated field the disclosure will print, per field.
53
+ *
54
+ * The per-concern injection budget is 1024 bytes and three settings-supplied
55
+ * values are interpolated into a fixed frame, one of them twice. Those values
56
+ * come from a settings file, so their length is an operator's choice rather
57
+ * than a bound this concern can assume — an over-budget line is dropped by the
58
+ * dispatcher, which would silently remove the disclosure. Truncating keeps the
59
+ * line and keeps it legible; the settings file remains the full record.
60
+ */
61
+ export const MAX_FIELD_CHARS = 120;
62
+
63
+ /** Clamp one interpolated field, marking the cut so it does not read as the value. */
64
+ export function _clamp(value: string): string {
65
+ return value.length <= MAX_FIELD_CHARS ? value : `${value.slice(0, MAX_FIELD_CHARS - 1)}…`;
66
+ }
67
+
68
+ /** Where the "already disclosed" note lives, relative to the project root. */
69
+ export const DISCLOSURE_STATE_REL = 'agents/runtime/state/telemetry-disclosure.json';
70
+
71
+ export interface DisclosureFacts {
72
+ readonly org_id: string;
73
+ readonly endpoint: string;
74
+ readonly log_path: string;
75
+ }
76
+
77
+ /**
78
+ * Host of the configured endpoint, or the raw value when it does not parse.
79
+ *
80
+ * The full URL can legitimately carry a path token, so only the host is
81
+ * disclosed: it answers "to whom" without turning a disclosure line into a
82
+ * place a credential can appear.
83
+ */
84
+ export function _endpointHost(endpoint: string): string {
85
+ try {
86
+ return new URL(endpoint).host || endpoint;
87
+ } catch {
88
+ return endpoint;
89
+ }
90
+ }
91
+
92
+ /** The identity of one disclosure — a change here is a new disclosure. */
93
+ export function disclosureKey(facts: DisclosureFacts): string {
94
+ return `${facts.org_id}\u0000${facts.endpoint}`;
95
+ }
96
+
97
+ /**
98
+ * Has this exact (org, endpoint) pair already been disclosed here?
99
+ *
100
+ * Every unreadable shape answers `false`. Re-showing a line the user has seen
101
+ * costs one line; suppressing one they have not seen costs the property this
102
+ * concern exists to provide.
103
+ */
104
+ export function alreadyDisclosed(root: string, facts: DisclosureFacts): boolean {
105
+ try {
106
+ const raw = fs.readFileSync(path.join(root, DISCLOSURE_STATE_REL), 'utf-8');
107
+ const parsed = JSON.parse(raw) as unknown;
108
+ if (parsed === null || typeof parsed !== 'object') {
109
+ return false;
110
+ }
111
+ return (parsed as Record<string, unknown>)['disclosed'] === disclosureKey(facts);
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ /** Record the disclosure. A write failure is swallowed — see the header. */
118
+ export function recordDisclosure(root: string, facts: DisclosureFacts, at: string): void {
119
+ try {
120
+ const p = path.join(root, DISCLOSURE_STATE_REL);
121
+ fs.mkdirSync(path.dirname(p), { recursive: true });
122
+ fs.writeFileSync(
123
+ p,
124
+ `${JSON.stringify({ disclosed: disclosureKey(facts), at }, null, 2)}\n`,
125
+ 'utf-8',
126
+ );
127
+ } catch {
128
+ // Intentionally silent: see the module header. An undisclosed state is
129
+ // recoverable (the line shows again); a blocked session start is not.
130
+ }
131
+ }
132
+
133
+ /** The disclosure line the user sees. One `<tag>`-wrapped block, no prose. */
134
+ export function buildDisclosure(facts: DisclosureFacts): string {
135
+ const org = _clamp(facts.org_id);
136
+ const sink = _clamp(_endpointHost(facts.endpoint));
137
+ const log = _clamp(facts.log_path);
138
+ return [
139
+ '<telemetry-disclosure>',
140
+ `This install reports pseudonymous usage data to your organisation `
141
+ + `"${org}" (sink: ${sink}), enabled by an org administrator rather than by you.`,
142
+ `What is sent: which skill ran, the host, the package version, the discipline `
143
+ + `profile, and salted digests of your machine+login and session. No prompt `
144
+ + `text, file path, file content, or project name — the record type has no `
145
+ + `field able to hold them.`,
146
+ `The records are readable locally at ${log}. See SECURITY.md § Telemetry.`,
147
+ '</telemetry-disclosure>',
148
+ ].join('\n');
149
+ }
150
+
151
+ export function run(
152
+ stdin: string,
153
+ options: { now?: string; settingsFile?: string } = {},
154
+ ): number {
155
+ let envelope: Record<string, unknown> = {};
156
+ if (stdin.trim() !== '') {
157
+ try {
158
+ const parsed = JSON.parse(stdin) as unknown;
159
+ if (parsed !== null && typeof parsed === 'object') {
160
+ envelope = parsed as Record<string, unknown>;
161
+ }
162
+ } catch {
163
+ return EXIT_ALLOW; // never act on a malformed envelope
164
+ }
165
+ }
166
+ // Positively matched, never defaulted. An envelope carrying no `event`
167
+ // used to be treated as a session start on whatever slot invoked it, so a
168
+ // shape this concern cannot identify would still disclose and write state.
169
+ // Fail-open on identification is the wrong default for a concern that
170
+ // mutates a file.
171
+ if (envelope['event'] !== 'session_start') {
172
+ return EXIT_ALLOW;
173
+ }
174
+
175
+ // A replayed or benchmarked envelope must not consume the one-shot: it
176
+ // would write the state note into the target root and spend the real
177
+ // user's first disclosure on a run they never saw. Same guard, same
178
+ // reason, as the sibling that writes the records.
179
+ if (is_replay_mode()) {
180
+ return EXIT_ALLOW;
181
+ }
182
+
183
+ // One resolver for both concerns of this feature. Reading only the
184
+ // envelope root made the sibling silently inactive when a host reported a
185
+ // `cwd` inside a subdirectory — and here the asymmetry was worse than
186
+ // inactivity: the usage hook walked up and recorded while this one did
187
+ // not walk up and stayed silent, i.e. telemetry with the disclosure
188
+ // switched off, which is the single shape ADR-233 § D5 forbids.
189
+ const settingsFile = options.settingsFile ?? resolveSettingsPath(_resolveRoot(envelope as never));
190
+ // The state note belongs beside the settings file that decided the
191
+ // disclosure, not beside the session cwd: otherwise one project discloses
192
+ // once per subdirectory a session happens to start in.
193
+ const root = path.dirname(settingsFile);
194
+
195
+ let settings;
196
+ try {
197
+ settings = read_remote_settings(settingsFile);
198
+ } catch {
199
+ return EXIT_ALLOW;
200
+ }
201
+ if (!settings.active) {
202
+ return EXIT_ALLOW;
203
+ }
204
+
205
+ const facts: DisclosureFacts = {
206
+ org_id: settings.org_id,
207
+ endpoint: settings.endpoint,
208
+ log_path: settings.log_path,
209
+ };
210
+ if (alreadyDisclosed(root, facts)) {
211
+ return EXIT_ALLOW;
212
+ }
213
+
214
+ process.stdout.write(
215
+ `${JSON.stringify({
216
+ decision: 'allow',
217
+ reason: `telemetry-disclosure: org "${facts.org_id}"`,
218
+ context: buildDisclosure(facts),
219
+ })}\n`,
220
+ );
221
+ recordDisclosure(root, facts, options.now ?? new Date().toISOString());
222
+ return EXIT_ALLOW;
223
+ }
224
+
225
+ export function main(argv?: string[]): number {
226
+ const args = argv ?? process.argv.slice(2);
227
+ void args;
228
+ return run(readHookStdin());
229
+ }
230
+
231
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
232
+ function _isCliEntry(): boolean {
233
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
234
+ return false;
235
+ }
236
+ if (process.argv[1] === undefined) {
237
+ return false;
238
+ }
239
+ return import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
240
+ }
241
+
242
+ if (_isCliEntry()) {
243
+ process.exit(main());
244
+ }
@@ -66,9 +66,25 @@ export function replaceScalar(template: string, dottedPath: string[], value: unk
66
66
  if (dottedPath.length === 0) return template;
67
67
  const sections = dottedPath.slice(0, -1);
68
68
  const key = dottedPath[dottedPath.length - 1];
69
- const targetIndent = ' '.repeat(sections.length);
70
69
 
71
70
  const lines = template.split('\n');
71
+ // The document's own width, the same read `upsertScalar` performs.
72
+ //
73
+ // R2 round 5, finding 1, and it is the second half of round 4's finding 7.
74
+ // That round taught `upsertScalar` to WRITE at the detected width and left
75
+ // this function — the probe that answers "does this key already exist?" —
76
+ // at a hardcoded two, in three places: the emitted indent, the `% 2`
77
+ // alignment test and the `/ 2` level. On a 4-space file `indentLen` 4
78
+ // passes `% 2` and computes level 2 for a depth-1 key, so the key is never
79
+ // found. The wizard then writes a correctly-indented line on the first
80
+ // toggle and, on the second, cannot see it and appends a DUPLICATE mapping
81
+ // key in the same block — two POSTs, both `{ok: true}`, and a config the
82
+ // parser rejects with "Map keys must be unique".
83
+ //
84
+ // A writer and its own existence-probe disagreeing about the format is the
85
+ // shape to look for whenever one of a pair is taught something new.
86
+ const width = detectIndentWidth(lines);
87
+ const targetIndent = ' '.repeat(width * sections.length);
72
88
  const currentPath: (string | null)[] = new Array<string | null>(sections.length).fill(null);
73
89
  const formatted = formatScalar(value);
74
90
 
@@ -78,8 +94,8 @@ export function replaceScalar(template: string, dottedPath: string[], value: unk
78
94
  const stripped = line.trim();
79
95
  if (stripped === '' || stripped.startsWith('#')) continue;
80
96
  const indentLen = line.length - line.trimStart().length;
81
- if (indentLen % 2 !== 0) continue;
82
- const level = indentLen / 2;
97
+ if (indentLen % width !== 0) continue;
98
+ const level = indentLen / width;
83
99
  if (level > sections.length) continue;
84
100
 
85
101
  const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*:/.exec(stripped);
@@ -105,6 +121,154 @@ export function replaceScalar(template: string, dottedPath: string[], value: unk
105
121
  return template;
106
122
  }
107
123
 
124
+ /**
125
+ * The indent width this document actually uses, or 2 when it has no nesting.
126
+ *
127
+ * The SMALLEST indent of any mapping line, not the first one seen. R2 round 6,
128
+ * finding 3: reading the first was wrong in two ways at once, and both were
129
+ * reachable on files this repository ships.
130
+ *
131
+ * - The key pattern excluded `-`, so a dashed key (`first-principles:`, and
132
+ * the shipped template has one) was skipped and the next, DEEPER line won:
133
+ * a 2-space document read as 4.
134
+ * - Even with the pattern fixed, "first" is not "representative" — a
135
+ * document whose first indented line sits at depth 3 read as 6.
136
+ *
137
+ * Either way every `replaceScalar` probe then misses and `upsertScalar`
138
+ * appends a duplicate mapping key the parser rejects, over a `{ok: true}`.
139
+ * The minimum is the width by construction: nesting is a multiple of it, so
140
+ * the shallowest indented line IS one unit.
141
+ *
142
+ * Tabs are not YAML indentation and are ignored. A list item (`- x`) is not a
143
+ * mapping line and does not vote.
144
+ */
145
+ export function detectIndentWidth(lines: readonly string[]): number {
146
+ let min = 0;
147
+ for (const line of lines) {
148
+ const trimmed = line.trim();
149
+ if (trimmed === '' || trimmed.startsWith('#') || trimmed.startsWith('-')) continue;
150
+ if (line.startsWith('\t')) continue;
151
+ const indentLen = line.length - line.trimStart().length;
152
+ if (indentLen === 0) continue;
153
+ // Hyphens and dots are legal in a YAML key and appear in this repo's
154
+ // own templates; excluding them is what let a deeper line win.
155
+ if (!/^[A-Za-z_][\w.-]*\s*:/.test(trimmed)) continue;
156
+ if (min === 0 || indentLen < min) min = indentLen;
157
+ }
158
+ return min === 0 ? 2 : min;
159
+ }
160
+
161
+ /**
162
+ * Set the scalar at `dottedPath`, CREATING the nesting when it is absent.
163
+ *
164
+ * R2 round 2, finding 6. `replaceScalar` returns the template unchanged when
165
+ * the path cannot be located, and the wizard's `set` helper never appended —
166
+ * so on every pre-existing `.ai-council.yml` written before a key existed, the
167
+ * wizard's toggle for that key returned 200 and wrote nothing. The user flips
168
+ * a switch, the server reports success, and the file is untouched.
169
+ *
170
+ * `mergeIntoTemplate` is not the fix for a NESTED key: its fallback appends a
171
+ * flat `a.b: value` line, which a YAML reader sees as a top-level key literally
172
+ * named "a.b" — so `doc['fallback']` stays undefined and the toggle is still
173
+ * inert, now with a line in the file suggesting otherwise.
174
+ *
175
+ * Insertion point is the end of the deepest EXISTING ancestor block, so an
176
+ * existing `fallback:` section gains a key rather than a second `fallback:`
177
+ * being appended — a duplicate mapping key is a YAML error in strict parsers
178
+ * and a silent last-wins in lenient ones.
179
+ *
180
+ * Comments and unrelated keys are preserved: nothing is rewritten, only
181
+ * inserted.
182
+ */
183
+ export function upsertScalar(template: string, dottedPath: string[], value: unknown): string {
184
+ if (dottedPath.length === 0) return template;
185
+ const replaced = replaceScalar(template, dottedPath, value);
186
+ if (replaced !== template) return replaced;
187
+
188
+ const sections = dottedPath.slice(0, -1);
189
+ const key = dottedPath[dottedPath.length - 1] as string;
190
+ const formatted = formatScalar(value);
191
+ const lines = template.split('\n');
192
+ // The document's OWN indent width, not an assumed two.
193
+ //
194
+ // R2 round 4, finding 7. Both the ancestor search and the emitted block
195
+ // hardcoded `' '.repeat(depth)`, so on a 4-space file the depth-2 walk
196
+ // round 3 added matched nothing at depth 1, `matched` stayed 0, and the
197
+ // whole chain was appended at EOF — producing a SECOND `fallback:` mapping
198
+ // key beside the existing one. `wizard.ts` writes the result without
199
+ // re-parsing and reports `{ok: true}`, so the user gets a 200 over a file
200
+ // a strict parser now rejects and a lenient one reads last-wins.
201
+ const width = detectIndentWidth(lines);
202
+ const pad = (depth: number): string => ' '.repeat(width * depth);
203
+
204
+ // How deep an existing ancestor chain runs, and where its block ends.
205
+ let matched = 0;
206
+ let insertAt = lines.length;
207
+ // The bounds of the ancestor matched at the previous depth. Each level
208
+ // searches only INSIDE its parent, so a nested key never binds to a
209
+ // same-named section under a different parent.
210
+ let parentStart = -1;
211
+ let parentEnd = lines.length;
212
+ for (let depth = 0; depth < sections.length; depth++) {
213
+ const want = sections[depth];
214
+ const indent = pad(depth);
215
+ let found = -1;
216
+ // R2 round 3, finding 5: this carried `depth === 0 ? 0 : insertAt ===
217
+ // lines.length ? 0 : 0` — every branch zero, i.e. a comment claiming a
218
+ // scoped search over an expression that does not scope anything. Made
219
+ // real: a nested section is searched only INSIDE its parent's block,
220
+ // so a depth-2 path cannot bind to a same-named key under a different
221
+ // parent. Latent today (the one caller is depth 1) and fixed rather
222
+ // than deleted, because the next caller is the one it would bite.
223
+ const from = depth === 0 ? 0 : parentStart + 1;
224
+ const until = depth === 0 ? lines.length : parentEnd;
225
+ for (let i = from; i < until; i++) {
226
+ const line = lines[i];
227
+ if (line === undefined) continue;
228
+ if (line.trim() === '' || line.trim().startsWith('#')) continue;
229
+ const indentLen = line.length - line.trimStart().length;
230
+ if (indentLen !== indent.length) continue;
231
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*:/.exec(line.trim());
232
+ if (m !== null && m[1] === want) {
233
+ found = i;
234
+ break;
235
+ }
236
+ }
237
+ if (found === -1) break;
238
+ matched = depth + 1;
239
+ // End of this block: the next line at or above its own indent level.
240
+ let end = lines.length;
241
+ for (let i = found + 1; i < lines.length; i++) {
242
+ const line = lines[i];
243
+ if (line === undefined) continue;
244
+ if (line.trim() === '' || line.trim().startsWith('#')) continue;
245
+ const indentLen = line.length - line.trimStart().length;
246
+ if (indentLen <= indent.length) {
247
+ end = i;
248
+ break;
249
+ }
250
+ }
251
+ insertAt = end;
252
+ parentStart = found;
253
+ parentEnd = end;
254
+ }
255
+
256
+ const block: string[] = [];
257
+ for (let depth = matched; depth < sections.length; depth++) {
258
+ block.push(`${pad(depth)}${sections[depth] as string}:`);
259
+ }
260
+ block.push(`${pad(sections.length)}${key}: ${formatted}`);
261
+
262
+ if (matched === 0) {
263
+ // No ancestor at all — append at EOF as its own block.
264
+ let body = template;
265
+ if (!body.endsWith('\n')) body += '\n';
266
+ return `${body}${block.join('\n')}\n`;
267
+ }
268
+ lines.splice(insertAt, 0, ...block);
269
+ return lines.join('\n');
270
+ }
271
+
108
272
  /**
109
273
  * Apply every leaf change from `newValues` to `templateBody`. Paths that
110
274
  * are not present in the template are appended at the end. Comments are