@event4u/agent-config 10.1.0 → 10.2.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 (115) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +109 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/roadmap/next.md +38 -0
  5. package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +22 -1
  6. package/dist/agent-src/contexts/contracts/frugality-charter.md +1 -1
  7. package/dist/agent-src/rules/context-hygiene.md +39 -0
  8. package/dist/agent-src/rules/token-budget-discipline.md +3 -2
  9. package/dist/agent-src/rules/token-efficiency.md +22 -0
  10. package/dist/agent-src/skills/ai-council/SKILL.md +28 -6
  11. package/dist/agent-src/skills/condense-memory/SKILL.md +1 -1
  12. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  13. package/dist/agent-src/skills/token-optimizer/SKILL.md +5 -1
  14. package/dist/agent-src/templates/agent-settings.md +12 -28
  15. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +2 -2
  16. package/dist/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +0 -1
  17. package/dist/cli/registry.js +1 -1
  18. package/dist/cli/registry.js.map +1 -1
  19. package/dist/cli-delegate/{chunk-3JUTT26I.js → chunk-6ENSXSBW.js} +1 -1
  20. package/dist/cli-delegate/{chunk-TVGXH7VA.js → chunk-6LOPHJLD.js} +1 -1
  21. package/dist/cli-delegate/{chunk-XT2YTJCZ.js → chunk-AYKQGIBG.js} +4 -0
  22. package/dist/cli-delegate/{chunk-HJSKTMIF.js → chunk-BV3WR67D.js} +3 -19
  23. package/dist/cli-delegate/{chunk-HTIU3W56.js → chunk-BWMXLD4B.js} +6 -2
  24. package/dist/cli-delegate/{chunk-2H5SE7DU.js → chunk-E4RUZKXB.js} +165 -38
  25. package/dist/cli-delegate/{chunk-XTC5ARB6.js → chunk-IR3IVRVC.js} +7 -2
  26. package/dist/cli-delegate/{chunk-LRXJK6GN.js → chunk-JMQZ7MM5.js} +1 -1
  27. package/dist/cli-delegate/{chunk-NX5AXSLD.js → chunk-X3VOARZD.js} +3 -3
  28. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  29. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  30. package/dist/cli-delegate/cmd_conformance.js +881 -204
  31. package/dist/cli-delegate/cmd_doctor.js +3 -3
  32. package/dist/cli-delegate/cmd_explain.js +2 -2
  33. package/dist/cli-delegate/cmd_export.js +4 -4
  34. package/dist/cli-delegate/cmd_handoff.js +6 -5
  35. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  36. package/dist/cli-delegate/cmd_migrate.js +2 -2
  37. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  38. package/dist/cli-delegate/cmd_prune.js +4 -4
  39. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  40. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  41. package/dist/cli-delegate/cmd_session_recycle.js +44 -9
  42. package/dist/cli-delegate/cmd_settings_get.js +2 -2
  43. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  44. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  45. package/dist/cli-delegate/cmd_sync.js +4 -4
  46. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  47. package/dist/cli-delegate/cmd_update.js +1 -1
  48. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  49. package/dist/cli-delegate/cmd_validate.js +4 -4
  50. package/dist/cli-delegate/cmd_versions.js +1 -1
  51. package/dist/discovery/deprecation-report.md +1 -1
  52. package/dist/discovery/discovery-manifest.json +11 -11
  53. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  54. package/dist/discovery/discovery-manifest.summary.md +1 -1
  55. package/dist/discovery/dormancy-report.md +1 -1
  56. package/dist/discovery/orphan-report.md +1 -1
  57. package/dist/discovery/packs.json +2 -2
  58. package/dist/discovery/trust-report.md +1 -1
  59. package/dist/discovery/workspaces.json +2 -2
  60. package/dist/hooks/dispatch.js +88 -20
  61. package/dist/install/install.mjs +9 -21
  62. package/dist/mcp/registry-manifest.json +1 -1
  63. package/dist/mcp/server.json +2 -2
  64. package/dist/mcp/server.mjs +1336 -489
  65. package/dist/scripts/_lib/agent_settings.js +25 -2
  66. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  67. package/dist/scripts/_lib/compile_time_toggles.js +4 -3
  68. package/dist/scripts/_lib/compile_time_toggles.js.map +1 -1
  69. package/dist/server/io/yamlIO.js +0 -2
  70. package/dist/server/io/yamlIO.js.map +1 -1
  71. package/dist/server/schemas/settings.js +3 -9
  72. package/dist/server/schemas/settings.js.map +1 -1
  73. package/dist/shared/settingsClasses.js +64 -2
  74. package/dist/shared/settingsClasses.js.map +1 -1
  75. package/dist/ui/assets/{index-B5qGYOJo.js → index-5fXOHoVh.js} +2 -2
  76. package/dist/ui/assets/index-5fXOHoVh.js.map +1 -0
  77. package/dist/ui/index.html +1 -1
  78. package/docs/contracts/settings-classes.md +307 -145
  79. package/docs/guidelines/agent-infra/layered-settings.md +1 -2
  80. package/docs/guidelines/agent-infra/symptom-driven-harvest-loop.md +104 -0
  81. package/package.json +1 -1
  82. package/src/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +0 -1
  83. package/src/config/agent-settings.template.yml +12 -30
  84. package/src/config/gate-violation-baselines.json +9 -3
  85. package/src/config/profiles/balanced.ini +0 -2
  86. package/src/config/profiles/full.ini +1 -3
  87. package/src/config/profiles/minimal.ini +0 -2
  88. package/src/scripts/_cli/cmd_session_recycle.ts +66 -9
  89. package/src/scripts/_cli/handoff_sessions.ts +3 -2
  90. package/src/scripts/_lib/agent_settings.ts +25 -2
  91. package/src/scripts/_lib/bench_telegraph.ts +1 -1
  92. package/src/scripts/_lib/cc_transcript.ts +25 -0
  93. package/src/scripts/_lib/compile_time_toggles.ts +4 -3
  94. package/src/scripts/ai_council/clients.ts +228 -33
  95. package/src/scripts/cache_realization_report.ts +3 -2
  96. package/src/scripts/check_rule_projection_integrity.ts +3 -1
  97. package/src/scripts/conformance_scan.ts +282 -9
  98. package/src/scripts/cost/track.mjs +10 -4
  99. package/src/scripts/council_cli.ts +33 -3
  100. package/src/scripts/external_sources_denylist.json +3 -1
  101. package/src/scripts/hook_manifest.yaml +17 -6
  102. package/src/scripts/hooks/block_unauthorized_git.ts +54 -8
  103. package/src/scripts/hooks/delegation_nudge_hook.ts +33 -8
  104. package/src/scripts/hooks/evidence_independence.ts +61 -9
  105. package/src/scripts/hooks/session_eol_hook.ts +123 -3
  106. package/src/scripts/hooks/turn_end_gate_hook.ts +31 -0
  107. package/src/scripts/lint_settings_classes.ts +273 -11
  108. package/src/scripts/lint_symptom_intake.ts +241 -0
  109. package/src/scripts/report_consultation_rate.ts +2 -1
  110. package/src/scripts/report_skill_activation.ts +6 -4
  111. package/src/scripts/report_skill_obligation_violations.ts +2 -1
  112. package/src/server/io/yamlIO.ts +0 -2
  113. package/src/server/schemas/settings.ts +3 -19
  114. package/src/shared/settingsClasses.ts +93 -2
  115. package/dist/ui/assets/index-B5qGYOJo.js.map +0 -1
@@ -15,7 +15,9 @@
15
15
  * 4. every B row's template default is a conservative value
16
16
  * (`false` / `""` / `0` / `[]` / `null` / `{}`) — the invariant that makes
17
17
  * "absent" and "declined" the same outcome in a sparse settings file;
18
- * 5. the contract's own Counts table matches the tallies computed here.
18
+ * 5. the contract's own Counts table matches the tallies computed here;
19
+ * 6. every `REMOVED_KEYS` entry names what decides instead, and no key is
20
+ * both removed and live in the template.
19
21
  *
20
22
  * **Landed at error, not advisory.** The authoring guideline's advisory-first
21
23
  * rule exists so a gate cannot be wired to error over a corpus whose hits are
@@ -48,16 +50,24 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
48
50
  import { load as parseYaml } from 'js-yaml';
49
51
 
50
52
  import {
53
+ dispositionOf,
51
54
  getSettingsLeaf,
52
55
  isConservativeDefault,
53
56
  isSettingsClass,
57
+ isSettingsDisposition,
54
58
  parseDeclaredClassCounts,
59
+ parseDeclaredDispositionCounts,
55
60
  parseSettingsClassRows,
61
+ replacementOf,
56
62
  SETTINGS_CLASSES,
63
+ SETTINGS_DISPOSITIONS,
57
64
  type SettingsClass,
58
65
  type SettingsClassRow,
66
+ type SettingsDisposition,
59
67
  settingsLeafPaths,
60
68
  } from '../shared/settingsClasses.js';
69
+ import { REMOVED_KEYS } from './_lib/agent_settings.js';
70
+ import { checkRatchet } from './_lib/gate_baseline.js';
61
71
  import { GateLedger } from './_lib/gate_ledger.js';
62
72
  import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
63
73
  import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
@@ -117,15 +127,27 @@ export function selfTest(): number {
117
127
  `\n## Counts\n\n| Class | Keys |\n|---|---|\n| A — preference | ${String(a)} |\n` +
118
128
  `| B — consent | ${String(b)} |\n| C — guarded | ${String(c)} |\n` +
119
129
  `| **Total** | **${String(total)}** |\n`;
120
- const table = (rows: string): string => `# Fixture\n${counts(0, 1, 1, 2)}\n## The table\n\n| Key | Class | Default | Why |\n|---|---|---|---|\n${rows}`;
130
+ // The disposition tallies, in the section the parser scopes to. Defaulted to
131
+ // the clean fixture's shape so a case that does not care about them says so
132
+ // by omission rather than by restating four rows.
133
+ const dispositions = (d: number, u: number, co: number, p: number, total: number): string =>
134
+ `\n## Dispositions\n\n| Disposition | Keys |\n|---|---|\n| derivable | ${String(d)} |\n` +
135
+ `| un-inferrable | ${String(u)} |\n| consent | ${String(co)} |\n| policy | ${String(p)} |\n` +
136
+ `| **Total** | **${String(total)}** |\n`;
137
+ const table = (rows: string, disp = dispositions(0, 1, 1, 0, 2)): string =>
138
+ `# Fixture\n${counts(0, 1, 1, 2)}\n${disp}\n## The table\n\n` +
139
+ `| Key | Class | Default | Why | Disposition |\n|---|---|---|---|---|\n${rows}`;
121
140
 
122
- const clean = table('| `alpha.one` | B | `false` | fixture |\n| `beta` | C | `"keep"` | fixture |\n');
141
+ const clean = table(
142
+ '| `alpha.one` | B | `false` | fixture | consent |\n' +
143
+ '| `beta` | C | `"keep"` | fixture | un-inferrable |\n',
144
+ );
123
145
 
124
146
  try {
125
147
  return runSelfTest({
126
148
  gate: GATE,
127
- minCases: 6,
128
- minRejectCases: 5,
149
+ minCases: 12,
150
+ minRejectCases: 10,
129
151
  cases: [
130
152
  {
131
153
  name: 'a template whose every leaf is classified passes',
@@ -140,7 +162,7 @@ export function selfTest(): number {
140
162
  seed(
141
163
  'unclassified',
142
164
  template,
143
- table('| `beta` | C | `"keep"` | fixture |\n') + counts(0, 0, 1, 1),
165
+ table('| `beta` | C | `"keep"` | fixture | un-inferrable |\n') + counts(0, 0, 1, 1),
144
166
  ),
145
167
  ),
146
168
  },
@@ -153,8 +175,9 @@ export function selfTest(): number {
153
175
  'stale-row',
154
176
  template,
155
177
  table(
156
- '| `alpha.one` | B | `false` | fixture |\n| `beta` | C | `"keep"` | fixture |\n' +
157
- '| `gamma.gone` | A | `1` | fixture |\n',
178
+ '| `alpha.one` | B | `false` | fixture | consent |\n' +
179
+ '| `beta` | C | `"keep"` | fixture | un-inferrable |\n' +
180
+ '| `gamma.gone` | A | `1` | fixture | policy |\n',
158
181
  ),
159
182
  ),
160
183
  ),
@@ -167,7 +190,90 @@ export function selfTest(): number {
167
190
  seed(
168
191
  'bad-class',
169
192
  template,
170
- table('| `alpha.one` | D | `false` | fixture |\n| `beta` | C | `"keep"` | fixture |\n'),
193
+ table(
194
+ '| `alpha.one` | D | `false` | fixture | consent |\n' +
195
+ '| `beta` | C | `"keep"` | fixture | un-inferrable |\n',
196
+ ),
197
+ ),
198
+ ),
199
+ },
200
+ {
201
+ name: 'a leaf with a class but no disposition is rejected',
202
+ expect: 'reject',
203
+ run: () =>
204
+ run(
205
+ seed(
206
+ 'no-disposition',
207
+ template,
208
+ table(
209
+ '| `alpha.one` | B | `false` | fixture | consent |\n' +
210
+ '| `beta` | C | `"keep"` | fixture |\n',
211
+ dispositions(0, 0, 1, 0, 2),
212
+ ),
213
+ ),
214
+ ),
215
+ },
216
+ {
217
+ name: 'a disposition outside the closed vocabulary is rejected',
218
+ expect: 'reject',
219
+ run: () =>
220
+ run(
221
+ seed(
222
+ 'bad-disposition',
223
+ template,
224
+ table(
225
+ '| `alpha.one` | B | `false` | fixture | consent |\n' +
226
+ '| `beta` | C | `"keep"` | fixture | obsolete |\n',
227
+ dispositions(0, 0, 1, 0, 2),
228
+ ),
229
+ ),
230
+ ),
231
+ },
232
+ {
233
+ name: 'a `derivable` row that names no replacement is rejected — the clause IS the falsifier',
234
+ expect: 'reject',
235
+ run: () =>
236
+ run(
237
+ seed(
238
+ 'derivable-without-replacement',
239
+ template,
240
+ table(
241
+ '| `alpha.one` | B | `false` | fixture | consent |\n' +
242
+ '| `beta` | C | `"keep"` | fixture | derivable |\n',
243
+ dispositions(1, 0, 1, 0, 2),
244
+ ),
245
+ ),
246
+ ),
247
+ },
248
+ {
249
+ name: 'a `derivable` row that names its replacement passes',
250
+ expect: 'accept',
251
+ run: () =>
252
+ run(
253
+ seed(
254
+ 'derivable-with-replacement',
255
+ template,
256
+ table(
257
+ '| `alpha.one` | B | `false` | fixture | consent |\n' +
258
+ '| `beta` | C | `"keep"` | fixture | derivable — the probe reads it |\n',
259
+ dispositions(1, 0, 1, 0, 2),
260
+ ),
261
+ ),
262
+ ),
263
+ },
264
+ {
265
+ name: 'a Dispositions table that disagrees with the rows is rejected',
266
+ expect: 'reject',
267
+ run: () =>
268
+ run(
269
+ seed(
270
+ 'bad-disposition-counts',
271
+ template,
272
+ table(
273
+ '| `alpha.one` | B | `false` | fixture | consent |\n' +
274
+ '| `beta` | C | `"keep"` | fixture | un-inferrable |\n',
275
+ dispositions(7, 7, 7, 7, 28),
276
+ ),
171
277
  ),
172
278
  ),
173
279
  },
@@ -191,8 +297,10 @@ export function selfTest(): number {
191
297
  seed(
192
298
  'bad-counts',
193
299
  template,
194
- `# Fixture\n${counts(9, 9, 9, 27)}\n## The table\n\n| Key | Class | Default | Why |\n|---|---|---|---|\n` +
195
- '| `alpha.one` | B | `false` | fixture |\n| `beta` | C | `"keep"` | fixture |\n',
300
+ `# Fixture\n${counts(9, 9, 9, 27)}\n${dispositions(0, 1, 1, 0, 2)}\n` +
301
+ '## The table\n\n| Key | Class | Default | Why | Disposition |\n|---|---|---|---|---|\n' +
302
+ '| `alpha.one` | B | `false` | fixture | consent |\n' +
303
+ '| `beta` | C | `"keep"` | fixture | un-inferrable |\n',
196
304
  ),
197
305
  ),
198
306
  },
@@ -201,6 +309,29 @@ export function selfTest(): number {
201
309
  expect: 'reject',
202
310
  run: () => run(seed('dead-root', null, clean)),
203
311
  },
312
+ {
313
+ name: 'a template leaf that is also a REMOVED_KEYS entry is rejected (check 6)',
314
+ expect: 'reject',
315
+ // `REMOVED_KEYS` is compiled into the loader, so a fixture root
316
+ // cannot seed it — but it can seed the other side of the
317
+ // contradiction. A fixture that declares `quality.wait_for_remote_ci`
318
+ // as a live, fully classified leaf is a tree where the loader
319
+ // would warn "ignored" about a value the schema honours, which
320
+ // is exactly the state check 6 exists to refuse. Classified and
321
+ // counted correctly on purpose: every other check passes here,
322
+ // so a rejection can only come from check 6.
323
+ run: () =>
324
+ run(
325
+ seed(
326
+ 'removed-and-live',
327
+ 'quality:\n wait_for_remote_ci: false\nbeta: "keep"\n',
328
+ table(
329
+ '| `quality.wait_for_remote_ci` | B | `false` | fixture | consent |\n' +
330
+ '| `beta` | C | `"keep"` | fixture | un-inferrable |\n',
331
+ ),
332
+ ),
333
+ ),
334
+ },
204
335
  ],
205
336
  });
206
337
  } finally {
@@ -331,6 +462,98 @@ function main(): number {
331
462
  ledger.complete(target);
332
463
  }
333
464
 
465
+ // --- the disposition axis -------------------------------------------------
466
+ // Orthogonal to A/B/C: that answers who may WRITE a key, this answers whether
467
+ // the key should EXIST. Checked against the same leaf set, in the same gate,
468
+ // so a key with no disposition fails the mechanism that already counts it —
469
+ // rather than sitting in a second artefact that can drift from the template.
470
+ const dispositionTally: Record<SettingsDisposition, number> = {
471
+ derivable: 0,
472
+ 'un-inferrable': 0,
473
+ consent: 0,
474
+ policy: 0,
475
+ };
476
+ for (const leaf of leaves) {
477
+ const row = rowByKey.get(leaf);
478
+ if (row === undefined) {
479
+ continue; // already reported as unclassified above
480
+ }
481
+ const label = dispositionOf(row.disposition);
482
+ if (label === null || label === '') {
483
+ findings.push(
484
+ `${CONTRACT_RELATIVE}:${String(row.line)} \`${leaf}\` has no disposition — add one of ` +
485
+ `${SETTINGS_DISPOSITIONS.join(' | ')}. A key with no disposition is the finding: ` +
486
+ 'nobody has said whether it should exist.',
487
+ );
488
+ continue;
489
+ }
490
+ if (!isSettingsDisposition(label)) {
491
+ findings.push(
492
+ `${CONTRACT_RELATIVE}:${String(row.line)} \`${leaf}\` has disposition '${label}' — ` +
493
+ `allowed: ${SETTINGS_DISPOSITIONS.join(' | ')}.`,
494
+ );
495
+ continue;
496
+ }
497
+ dispositionTally[label] += 1;
498
+ if (label === 'derivable' && replacementOf(row.disposition) === '') {
499
+ // The replacement clause is what makes `derivable` falsifiable. Without
500
+ // it the label degrades into a synonym for "inconvenient to keep", and
501
+ // the direction of this axis pushes every borderline row toward it.
502
+ findings.push(
503
+ `${CONTRACT_RELATIVE}:${String(row.line)} \`${leaf}\` is \`derivable\` but names no ` +
504
+ 'replacement — write `derivable — <what decides instead>`. A derivable row that ' +
505
+ 'cannot name its replacement is reclassified, not deleted.',
506
+ );
507
+ }
508
+ }
509
+
510
+ // The anti-regrowth ratchet. `derivable` is a deletion queue, so its size is
511
+ // a debt count and may only fall: a NEW key classified `derivable` is a key
512
+ // that should not have been added, and it raises the number. The 56-day
513
+ // non-stagnation clause in the shared baseline mechanism is the other half —
514
+ // a queue that never drains is a direction nobody is walking.
515
+ //
516
+ // Deliberately scoped to the real repo root. Under `--root` (self-test
517
+ // fixtures) the baseline describes a different tree entirely, and judging a
518
+ // two-key fixture against this repository's surface would make every fixture
519
+ // report a loose ratchet.
520
+ if (_rootOverride(process.argv.slice(2)) === null) {
521
+ const verdict = checkRatchet({
522
+ gate: `${GATE}:derivable-surface`,
523
+ actual: dispositionTally.derivable,
524
+ repoRoot: REPO_ROOT,
525
+ });
526
+ if (!verdict.ok) {
527
+ findings.push(verdict.message);
528
+ } else if (verdict.status === 'improved' && !QUIET) {
529
+ process.stdout.write(`ℹ️ ${verdict.message}\n`);
530
+ }
531
+ }
532
+
533
+ const declaredDisposition = parseDeclaredDispositionCounts(contractText);
534
+ const dispositionExpected: Array<[string, number | null, number]> = [
535
+ ['derivable', declaredDisposition.derivable, dispositionTally.derivable],
536
+ ['un-inferrable', declaredDisposition['un-inferrable'], dispositionTally['un-inferrable']],
537
+ ['consent', declaredDisposition.consent, dispositionTally.consent],
538
+ ['policy', declaredDisposition.policy, dispositionTally.policy],
539
+ ['Total', declaredDisposition.total, leaves.length],
540
+ ];
541
+ for (const [label, stated, actual] of dispositionExpected) {
542
+ if (stated === null) {
543
+ findings.push(
544
+ `${CONTRACT_RELATIVE} the Dispositions table has no '${label}' row — a published count ` +
545
+ 'with no row is a count nothing can check.',
546
+ );
547
+ continue;
548
+ }
549
+ if (stated !== actual) {
550
+ findings.push(
551
+ `${CONTRACT_RELATIVE} Dispositions says ${label} = ${String(stated)}, the table holds ` +
552
+ `${String(actual)} — a derived number beside a mechanism that can compute it.`,
553
+ );
554
+ }
555
+ }
556
+
334
557
  const declared = parseDeclaredClassCounts(contractText);
335
558
  const expected: Array<[string, number | null, number]> = [
336
559
  ['A', declared.A, tally.A],
@@ -354,6 +577,45 @@ function main(): number {
354
577
  }
355
578
  }
356
579
 
580
+ // Check 6 — the DELETION side of the surface.
581
+ //
582
+ // Checks 1-5 all police what is added: a new leaf needs a row, a class, a
583
+ // disposition, a conservative default. Nothing policed what is removed, and
584
+ // that is the asymmetry this axis lives on — the whole direction of the
585
+ // roadmap behind it is deletion, so the un-gated half was the half doing the
586
+ // work. Two contradictions are decidable here and neither is visible to the
587
+ // loader that prints the warning:
588
+ //
589
+ // a. a reason that names no replacement. The `derivable` rows above are
590
+ // already held to "name what decides instead" because that clause is the
591
+ // falsifier; a deleted key is the same claim after the fact, and a
592
+ // warning that says only "removed" sends the reader looking for a
593
+ // successor key that does not exist.
594
+ // b. a key that is BOTH removed and live. Re-adding a leaf while its
595
+ // `REMOVED_KEYS` entry stands makes the loader warn "ignored" about a
596
+ // value the schema now honours — the reader is told the opposite of what
597
+ // happens.
598
+ //
599
+ // What this does NOT check, stated because the step it serves asks for it:
600
+ // that the replacement mechanism actually EXISTS, and that its commit
601
+ // precedes the deletion. Neither is decidable from these two files; both stay
602
+ // model-carried, exactly as the A/B/C judgement itself does.
603
+ const liveLeaves = new Set(leaves);
604
+ for (const [removedKey, reason] of REMOVED_KEYS) {
605
+ if (reason.trim() === '') {
606
+ findings.push(
607
+ `src/scripts/_lib/agent_settings.ts \`${removedKey}\` is in REMOVED_KEYS with an empty ` +
608
+ 'reason — name what decides instead, not that it is gone.',
609
+ );
610
+ }
611
+ if (liveLeaves.has(removedKey)) {
612
+ findings.push(
613
+ `${TEMPLATE_RELATIVE} \`${removedKey}\` is a live template leaf AND a REMOVED_KEYS ` +
614
+ 'entry — the loader would warn that a honoured value is ignored. Drop one side.',
615
+ );
616
+ }
617
+ }
618
+
357
619
  ledger.report();
358
620
 
359
621
  if (findings.length > 0) {
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Warn-only gate: operator symptom entries under `agents/tickets/symptoms/`
4
+ * must be resolved within 30 days of the reported date.
5
+ *
6
+ * "Resolved" means the entry carries exactly one of the two blocks the
7
+ * directory's `README.md` defines — `## confirmed:` (a defect with `file:line`
8
+ * at a pinned commit) or `## null:` (checked, not reproducible, with evidence).
9
+ * A `null:` is a first-class outcome: the point of the gate is that somebody
10
+ * looked, not that a defect was found.
11
+ *
12
+ * **Warn-only by design, and the design is the load-bearing part.** Intake has
13
+ * to stay cheaper than the work it triggers, so an unresolved report is a
14
+ * reminder, never a build failure — a gate that reds CI because a human has not
15
+ * yet investigated a symptom would train people to stop filing symptoms, which
16
+ * is the exact opposite of what the surface is for
17
+ * (`agents/roadmaps/archive/road-to-symptom-driven-harvest-loop.md` Phase 1 Step 2).
18
+ * The only exit-1 path is a dead scan scope.
19
+ *
20
+ * Scope: `agents/tickets/symptoms/*.md`, one level, no recursion.
21
+ * `README.md` and `_`-prefixed files (the template) are counted as scanned but
22
+ * never evaluated — so the gate keeps a non-zero denominator while the surface
23
+ * exists, and does not red the moment the last entry is resolved away.
24
+ *
25
+ * Exit codes: 0 = clean or warnings only, 1 = dead scan scope.
26
+ * `--quiet` is a bare argv membership check, matching the sibling roadmap gates.
27
+ * `--now=YYYY-MM-DD` overrides today (tests and reproducible runs only).
28
+ */
29
+ import * as fs from 'node:fs';
30
+ import * as path from 'node:path';
31
+ import { fileURLToPath, pathToFileURL } from 'node:url';
32
+
33
+ import { GateLedger } from './_lib/gate_ledger.js';
34
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
35
+
36
+ const QUIET = process.argv.slice(2).includes('--quiet');
37
+
38
+ const SYMPTOM_DIR = path.join('agents', 'tickets', 'symptoms');
39
+
40
+ /** Days after `reported:` at which an unresolved entry starts warning. */
41
+ export const STALE_AFTER_DAYS = 30;
42
+
43
+ const _HERE = path.resolve(fileURLToPath(import.meta.url));
44
+
45
+ const REPORTED_RE = /^reported:\s*(\d{4}-\d{2}-\d{2})\s*$/m;
46
+ const CONFIRMED_RE = /^##\s+confirmed:\s*$/m;
47
+ const NULL_RE = /^##\s+null:\s*$/m;
48
+
49
+ export interface SymptomFinding {
50
+ readonly file: string;
51
+ readonly kind: 'unresolved' | 'no-reported-date';
52
+ readonly ageDays: number | null;
53
+ }
54
+
55
+ /** Walk up from CWD until a dir containing `agents/tickets` is found. */
56
+ function _repoRoot(): string {
57
+ let cur = process.cwd();
58
+ const chain = [cur];
59
+ for (;;) {
60
+ const parent = path.dirname(cur);
61
+ if (parent === cur) break;
62
+ chain.push(parent);
63
+ cur = parent;
64
+ }
65
+ for (const candidate of chain) {
66
+ try {
67
+ if (fs.statSync(path.join(candidate, 'agents', 'tickets')).isDirectory()) {
68
+ return candidate;
69
+ }
70
+ } catch {
71
+ // keep walking
72
+ }
73
+ }
74
+ return process.cwd();
75
+ }
76
+
77
+ function _isDir(p: string): boolean {
78
+ try {
79
+ return fs.statSync(p).isDirectory();
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ /** Every `*.md` directly under the symptoms dir, sorted. */
86
+ export function listMarkdown(base: string): string[] {
87
+ if (!_isDir(base)) return [];
88
+ let entries: fs.Dirent[];
89
+ try {
90
+ entries = fs.readdirSync(base, { withFileTypes: true });
91
+ } catch {
92
+ return [];
93
+ }
94
+ return entries
95
+ .filter((e) => e.isFile() && e.name.endsWith('.md'))
96
+ .map((e) => e.name)
97
+ .sort();
98
+ }
99
+
100
+ /** An entry is a report; `README.md` and `_`-prefixed files are not. */
101
+ export function isEntry(name: string): boolean {
102
+ return name.endsWith('.md') && name !== 'README.md' && !name.startsWith('_');
103
+ }
104
+
105
+ function _daysBetween(fromIso: string, now: Date): number | null {
106
+ const parsed = Date.parse(`${fromIso}T00:00:00Z`);
107
+ if (Number.isNaN(parsed)) return null;
108
+ return Math.floor((now.getTime() - parsed) / 86_400_000);
109
+ }
110
+
111
+ export function evaluate(text: string, name: string, now: Date): SymptomFinding | null {
112
+ const reported = REPORTED_RE.exec(text);
113
+ if (reported === null) {
114
+ return { file: name, kind: 'no-reported-date', ageDays: null };
115
+ }
116
+ if (CONFIRMED_RE.test(text) || NULL_RE.test(text)) {
117
+ return null;
118
+ }
119
+ const age = _daysBetween(reported[1] as string, now);
120
+ if (age === null) {
121
+ return { file: name, kind: 'no-reported-date', ageDays: null };
122
+ }
123
+ if (age <= STALE_AFTER_DAYS) {
124
+ return null;
125
+ }
126
+ return { file: name, kind: 'unresolved', ageDays: age };
127
+ }
128
+
129
+ function _now(): Date {
130
+ const flag = process.argv.slice(2).find((a) => a.startsWith('--now='));
131
+ if (flag !== undefined) {
132
+ const parsed = Date.parse(`${flag.slice('--now='.length)}T00:00:00Z`);
133
+ if (!Number.isNaN(parsed)) return new Date(parsed);
134
+ }
135
+ return new Date();
136
+ }
137
+
138
+ function main(): number {
139
+ const root = _repoRoot();
140
+ const base = path.join(root, SYMPTOM_DIR);
141
+ const names = listMarkdown(base);
142
+
143
+ try {
144
+ // The scanned unit counts README + template too, deliberately: this gate
145
+ // must not go dead the moment the last open entry is resolved, and the
146
+ // directory always carries its convention file while the surface exists.
147
+ assertScanned({
148
+ gate: 'lint_symptom_intake',
149
+ scanned: names.length,
150
+ units: 'symptom file(s)',
151
+ roots: [SYMPTOM_DIR],
152
+ });
153
+ } catch (exc) {
154
+ if (exc instanceof DeadScopeError) {
155
+ process.stderr.write(`❌ ${exc.message}\n`);
156
+ return 1;
157
+ }
158
+ throw exc;
159
+ }
160
+
161
+ const now = _now();
162
+ const findings: SymptomFinding[] = [];
163
+ let entryCount = 0;
164
+
165
+ // Per-target accounting: README.md and the template are planned too, so the
166
+ // ledger states out loud that they were seen and deliberately not evaluated
167
+ // — an unaccounted `continue` is the silent-skip defect this module catches.
168
+ const ledger = new GateLedger('lint_symptom_intake');
169
+ ledger.plan(names);
170
+
171
+ for (const name of names) {
172
+ if (!isEntry(name)) {
173
+ ledger.outOfScope(name, 'not_applicable_kind');
174
+ continue;
175
+ }
176
+ entryCount += 1;
177
+ let text: string;
178
+ try {
179
+ text = fs.readFileSync(path.join(base, name), 'utf-8');
180
+ } catch {
181
+ ledger.skip(name, 'binary_content');
182
+ continue;
183
+ }
184
+ const finding = evaluate(text, name, now);
185
+ if (finding !== null) {
186
+ findings.push(finding);
187
+ ledger.fail(name, finding.kind);
188
+ } else {
189
+ ledger.complete(name);
190
+ }
191
+ }
192
+
193
+ if (findings.length === 0) {
194
+ ledger.report();
195
+ if (!QUIET) {
196
+ process.stdout.write(
197
+ `✅ lint-symptom-intake: ${entryCount} entry(ies) resolved or within ` +
198
+ `${STALE_AFTER_DAYS} days.\n`,
199
+ );
200
+ }
201
+ return 0;
202
+ }
203
+
204
+ ledger.report();
205
+ process.stdout.write(`⚠️ lint-symptom-intake: ${findings.length} unresolved symptom entry(ies):\n`);
206
+ for (const f of findings) {
207
+ if (f.kind === 'no-reported-date') {
208
+ process.stdout.write(` ${SYMPTOM_DIR}/${f.file} — no valid \`reported: YYYY-MM-DD\` in frontmatter\n`);
209
+ } else {
210
+ process.stdout.write(
211
+ ` ${SYMPTOM_DIR}/${f.file} — reported ${String(f.ageDays)} days ago, ` +
212
+ 'no `## confirmed:` or `## null:` block\n',
213
+ );
214
+ }
215
+ }
216
+ process.stdout.write('\n');
217
+ process.stdout.write(' Add whichever block is true (see agents/tickets/symptoms/README.md):\n');
218
+ process.stdout.write(' • `## confirmed:` — defect + file:line at a pinned commit + owning roadmap\n');
219
+ process.stdout.write(' • `## null:` — what was checked, and why it does not reproduce\n');
220
+ process.stdout.write(' A null is a result, not a failure to act. This gate never fails the build.\n');
221
+ return 0;
222
+ }
223
+
224
+ function _isCliEntry(): boolean {
225
+ if (process.argv[1] === undefined) return false;
226
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
227
+ if (import.meta.url === argvUrl) return true;
228
+ try {
229
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
230
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
231
+ return here === argv;
232
+ } catch {
233
+ return false;
234
+ }
235
+ }
236
+
237
+ if (_isCliEntry() || process.argv[1] === _HERE) {
238
+ process.exitCode = main();
239
+ }
240
+
241
+ export { QUIET, SYMPTOM_DIR, main };
@@ -46,6 +46,7 @@ import * as os from 'node:os';
46
46
  import * as path from 'node:path';
47
47
  import { fileURLToPath } from 'node:url';
48
48
 
49
+ import { projectStoreSlug } from './_lib/cc_transcript.js';
49
50
  import {
50
51
  isConsultation,
51
52
  isUiWrite,
@@ -107,7 +108,7 @@ export interface RateReport {
107
108
  * reads a clean zero out of a store it never found.
108
109
  */
109
110
  export function defaultStore(cwd: string): string {
110
- return path.join(os.homedir(), '.claude', 'projects', cwd.replace(/[/.]/g, '-'));
111
+ return path.join(os.homedir(), '.claude', 'projects', projectStoreSlug(cwd));
111
112
  }
112
113
 
113
114
  /**
@@ -52,6 +52,8 @@ import * as os from 'node:os';
52
52
  import * as path from 'node:path';
53
53
  import { fileURLToPath, pathToFileURL } from 'node:url';
54
54
 
55
+ import { projectStoreSlug } from './_lib/cc_transcript.js';
56
+
55
57
  const _HERE = fileURLToPath(import.meta.url);
56
58
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
57
59
 
@@ -114,11 +116,11 @@ export interface UsageReport {
114
116
 
115
117
  /** Default store for the current working directory, mangled the way the host does. */
116
118
  export function defaultStore(cwd: string): string {
117
- // Dots are flattened too, not only separators: a worktree at
118
- // `<repo>/.claude/worktrees/x` lands in `…-agent-config--claude-worktrees-x`.
119
- // Replacing only `/` returns a directory that does not exist, and the caller
119
+ // Every non-alphanumeric is flattened, not only separators: a worktree at
120
+ // `<repo>/.claude/worktrees/x+y` lands in `…-agent-config--claude-worktrees-x-y`.
121
+ // A narrower class returns a directory that does not exist, and the caller
120
122
  // then reads a clean zero out of a store it never found.
121
- return path.join(os.homedir(), '.claude', 'projects', cwd.replace(/[/.]/g, '-'));
123
+ return path.join(os.homedir(), '.claude', 'projects', projectStoreSlug(cwd));
122
124
  }
123
125
 
124
126
  export function measureUsage(store: string, limit: number): UsageReport {
@@ -59,6 +59,7 @@ import * as path from 'node:path';
59
59
  import process from 'node:process';
60
60
  import { fileURLToPath, pathToFileURL } from 'node:url';
61
61
 
62
+ import { projectStoreSlug } from './_lib/cc_transcript.js';
62
63
  import { entryText, isSidechain, toolUses as _toolUses } from './_lib/transcript_entry.js';
63
64
  import { censusSkills, DETERMINISTIC_RE, SKILLS_ROOT } from './report_skill_activation.js';
64
65
 
@@ -408,7 +409,7 @@ export interface Sk2Report {
408
409
  }
409
410
 
410
411
  export function defaultStore(cwd: string): string {
411
- return path.join(os.homedir(), '.claude', 'projects', cwd.replace(/[/.]/g, '-'));
412
+ return path.join(os.homedir(), '.claude', 'projects', projectStoreSlug(cwd));
412
413
  }
413
414
 
414
415
  export function scanStore(repoRoot: string, store: string, limit: number): Sk2Report {
@@ -144,8 +144,6 @@ export const TEMPLATE_PLACEHOLDER_DEFAULTS: Readonly<Record<string, string>> = {
144
144
  __DISCIPLINE_PROFILE__: 'auto',
145
145
  __USER_TYPE__: '',
146
146
  __CHAT_HISTORY_FREQUENCY__: 'per_turn',
147
- __CHAT_HISTORY_MAX_SIZE_KB__: '2048',
148
- __CHAT_HISTORY_ON_OVERFLOW__: 'rotate',
149
147
  };
150
148
 
151
149
  /** Replace every known template placeholder with its default value. */