@event4u/agent-config 14.14.0 → 14.14.1

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 (66) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +33 -0
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/pr/create.md +16 -1
  7. package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
  8. package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
  9. package/dist/agent-src/rules/token-budget-discipline.md +2 -2
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/cli/registry.js +1 -0
  12. package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
  13. package/dist/cli-delegate/cmd_conformance.js +1 -1
  14. package/dist/cli-delegate/cmd_doctor.js +1 -1
  15. package/dist/discovery/deprecation-report.md +1 -1
  16. package/dist/discovery/discovery-manifest.json +5 -5
  17. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  18. package/dist/discovery/discovery-manifest.summary.md +1 -1
  19. package/dist/discovery/dormancy-report.md +1 -1
  20. package/dist/discovery/orphan-report.md +1 -1
  21. package/dist/discovery/packs.json +2 -2
  22. package/dist/discovery/trust-report.md +1 -1
  23. package/dist/discovery/workspaces.json +2 -2
  24. package/dist/hooks/dispatch.js +528 -371
  25. package/dist/mcp/registry-manifest.json +2 -2
  26. package/dist/mcp/server.json +2 -2
  27. package/dist/mcp/server.mjs +185 -98
  28. package/package.json +1 -1
  29. package/src/config/assurance-capability-registry.json +1 -1
  30. package/src/config/evaluator-budgets.json +4 -4
  31. package/src/config/gate-coverage.yml +62 -2
  32. package/src/config/gate-reachability-exemptions.json +1 -0
  33. package/src/config/gate-violation-baselines.json +4 -3
  34. package/src/config/web-launch-readiness.json +4 -2
  35. package/src/scripts/_cli/_doctor_emit.ts +71 -0
  36. package/src/scripts/_cli/_doctor_strict.ts +163 -0
  37. package/src/scripts/_cli/cmd_doctor.ts +16 -37
  38. package/src/scripts/_dispatch.bash +11 -0
  39. package/src/scripts/_lib/branch_convergence.ts +186 -0
  40. package/src/scripts/_lib/context_observation.ts +132 -0
  41. package/src/scripts/_lib/continuation_ladder.ts +188 -0
  42. package/src/scripts/_lib/experiment_binding.ts +95 -0
  43. package/src/scripts/_lib/ignored_blocker.ts +8 -1
  44. package/src/scripts/_lib/outcome_envelope.ts +6 -1
  45. package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
  46. package/src/scripts/_lib/release_highlights.ts +12 -3
  47. package/src/scripts/_lib/repeated_failure.ts +19 -2
  48. package/src/scripts/_lib/runtime_journal.ts +11 -4
  49. package/src/scripts/check_agent_artifact_location.ts +337 -0
  50. package/src/scripts/check_artefact_count_messaging.ts +74 -8
  51. package/src/scripts/check_enforcement_coverage.ts +40 -1
  52. package/src/scripts/check_release_highlights.ts +17 -1
  53. package/src/scripts/check_web_launch_readiness.ts +310 -20
  54. package/src/scripts/evolution_lab.ts +53 -1
  55. package/src/scripts/generate_capability_matrix.ts +16 -9
  56. package/src/scripts/grade_target_readiness.ts +221 -2
  57. package/src/scripts/hook_effect_doctor.ts +47 -4
  58. package/src/scripts/hooks/run_continuation_hook.ts +78 -88
  59. package/src/scripts/hooks/session_eol_hook.ts +12 -1
  60. package/src/scripts/install-hooks.sh +18 -0
  61. package/src/scripts/lint_code_comments.ts +106 -1
  62. package/src/scripts/release_drill.ts +57 -1
  63. package/src/scripts/release_publication.ts +58 -0
  64. package/src/scripts/roadmap_context.ts +15 -0
  65. package/src/scripts/skill_scout.ts +633 -0
  66. package/src/scripts/sync_pr_branch.ts +521 -42
@@ -7,7 +7,7 @@
7
7
  * written in a command's markdown cannot be asserted by anything. So the
8
8
  * detection is here and the command invokes it; the command keeps the narrative.
9
9
  *
10
- * THE ANTI-VANITY RULE IS STRUCTURAL, NOT A STYLE NOTE. Ten dimensions are
10
+ * THE ANTI-VANITY RULE IS STRUCTURAL, NOT A STYLE NOTE. Thirteen dimensions are
11
11
  * graded 0 Absent / 1 Present / 2 Enforced-in-CI / 3 Independent-and-diff-scoped,
12
12
  * and the verdict is `min` over the FOUR knockout dimensions, reported as
13
13
  * `L<n> — bound by <dimension>`. **No aggregate number is ever emitted** — no
@@ -113,6 +113,66 @@ function _ciText(root: string): string {
113
113
  return out;
114
114
  }
115
115
 
116
+ /* -- bounded text corpus, for the Phase 4 dimensions ----------------------- */
117
+
118
+ /** Directories a target scan must never descend into: cost with no signal. */
119
+ const _SKIP_DIRS = new Set([
120
+ 'node_modules', '.git', 'vendor', 'dist', 'build', 'out', 'coverage',
121
+ '.next', '.nuxt', '.venv', 'venv', '__pycache__', 'target', '.terraform',
122
+ ]);
123
+ /** Extensions that can carry a DNS record, a policy doc or an ops runbook. */
124
+ const _CORPUS_RE = /\.(tf|tfvars|hcl|ya?ml|json|txt|zone|dns|ini|conf|cfg|toml|md|env|example|sh)$/i;
125
+ const _CORPUS_MAX_FILES = 2000;
126
+ const _CORPUS_MAX_BYTES = 256 * 1024;
127
+
128
+ /**
129
+ * Read a bounded slice of the target's text, once, for the dimensions that
130
+ * cannot be answered by a root-level `_exists` probe.
131
+ *
132
+ * Three dimensions need to see INSIDE files — a DMARC policy is a string in a
133
+ * zone file or a Terraform record, not a filename — and the existing probes are
134
+ * all filename-shaped. The walk is capped in three directions (skip list, file
135
+ * count, per-file bytes) because an unbounded read of an arbitrary target repo
136
+ * is a cost this grader has no way to predict.
137
+ *
138
+ * Returns the concatenated text AND the relative paths, because a dimension that
139
+ * says WHERE it saw something is auditable and one that only says "somewhere in
140
+ * the tree" is not.
141
+ */
142
+ export function _textCorpus(root: string): { text: string; paths: string[] } {
143
+ const paths: string[] = [];
144
+ let text = '';
145
+ const walk = (dir: string, rel: string): void => {
146
+ if (paths.length >= _CORPUS_MAX_FILES) return;
147
+ let entries: fs.Dirent[];
148
+ try {
149
+ entries = fs.readdirSync(dir, { withFileTypes: true });
150
+ } catch {
151
+ return;
152
+ }
153
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
154
+ if (paths.length >= _CORPUS_MAX_FILES) return;
155
+ const abs = path.join(dir, e.name);
156
+ const r = rel === '' ? e.name : `${rel}/${e.name}`;
157
+ if (e.isDirectory()) {
158
+ if (_SKIP_DIRS.has(e.name)) continue;
159
+ walk(abs, r);
160
+ continue;
161
+ }
162
+ if (!_CORPUS_RE.test(e.name)) continue;
163
+ try {
164
+ if (fs.statSync(abs).size > _CORPUS_MAX_BYTES) continue;
165
+ text += `${fs.readFileSync(abs, 'utf-8')}\n`;
166
+ } catch {
167
+ continue;
168
+ }
169
+ paths.push(r);
170
+ }
171
+ };
172
+ walk(root, '');
173
+ return { text, paths };
174
+ }
175
+
116
176
  /**
117
177
  * Is this a Python-primary target?
118
178
  *
@@ -125,7 +185,7 @@ export function isPythonTarget(root: string): boolean {
125
185
  return py && !other;
126
186
  }
127
187
 
128
- // ── the ten dimensions ─────────────────────────────────────────────────────
188
+ // ── the thirteen dimensions ──
129
189
 
130
190
  /**
131
191
  * Grade one dimension. `ci` decides 1 → 2: a thing that exists is `Present`; a
@@ -149,6 +209,113 @@ function _ciBlocks(ci: string, ...needles: string[]): boolean {
149
209
  return !allSoft;
150
210
  }
151
211
 
212
+ /* -- Phase 4 dimension ladders --------------------------------------------- */
213
+
214
+ interface Scored {
215
+ grade: Grade;
216
+ evidence: string;
217
+ }
218
+
219
+ /** A runbook, a restore procedure, or a rollback path — and whether CI runs it. */
220
+ function _recovery(corpus: { text: string; paths: string[] }, ci: string): Scored {
221
+ const namedFile = corpus.paths.some((p) => /(^|\/)(runbook|runbooks|disaster-recovery|dr)([./-]|$)/i.test(p));
222
+ const procedure = /\b(restore from backup|disaster recovery|rollback plan|pg_restore|pg_dump|restic|velero|RPO|RTO)\b/i.test(corpus.text);
223
+ const present = namedFile || procedure;
224
+ const enforced = _ciBlocks(ci, 'restore', 'rollback', 'disaster-recovery', 'backup');
225
+ // A DRILL is what separates a documented procedure from a proven one. It is
226
+ // the only honest route to 3 here, because "independent" for recovery means
227
+ // the restore was actually exercised rather than described.
228
+ const drill = /\b(restore (drill|test|rehearsal)|recovery drill|game ?day)\b/i.test(corpus.text);
229
+ const seen: string[] = [
230
+ ...(namedFile ? ['a runbook or DR document'] : []),
231
+ ...(procedure ? ['a restore/rollback procedure in the text'] : []),
232
+ ...(enforced ? ['a blocking CI step naming restore/rollback/backup'] : []),
233
+ ...(drill ? ['a restore drill'] : []),
234
+ ];
235
+ return {
236
+ grade: _graded(present, enforced, drill),
237
+ evidence: seen.length > 0 ? seen.join('; ') : 'no runbook, restore procedure or rollback path found',
238
+ };
239
+ }
240
+
241
+ /**
242
+ * SPF, DKIM and DMARC — and the rung that must not be fudged (4.2).
243
+ *
244
+ * 0 Absent — no SPF, DKIM or DMARC RECORD anywhere. Prose about DMARC is
245
+ * not a DMARC record; the signal is the record shape.
246
+ * 1 Present — SPF and/or DKIM published, OR a DMARC record with `p=none`.
247
+ * `p=none` is a MONITORING policy: it requests aggregate reports
248
+ * and instructs receivers to take no action on a failing message.
249
+ * A domain publishing it is measuring the problem, not preventing
250
+ * it, so it is capped here however much else sits alongside it.
251
+ * 2 Enforced — DMARC `p=quarantine` or `p=reject`, with SPF or DKIM to align
252
+ * against. An enforcing policy with nothing to align is a policy
253
+ * over no signal, and stays at 1.
254
+ * 3 Top — `p=reject` with BOTH SPF and DKIM and an `rua=` reporting
255
+ * address, so the enforcement can be observed rather than assumed.
256
+ */
257
+ function _mailAuthenticity(corpus: { text: string }): Scored {
258
+ const t = corpus.text;
259
+ const spf = /v=spf1\b/i.test(t);
260
+ const dkim = /v=DKIM1\b/i.test(t);
261
+ const dmarc = /v=DMARC1\b/i.test(t);
262
+ const policy = /v=DMARC1[^"'\n]*?\bp\s*=\s*(none|quarantine|reject)\b/i.exec(t)?.[1]?.toLowerCase();
263
+ const rua = /v=DMARC1[^"'\n]*?\brua\s*=/i.test(t);
264
+ const aligned = spf || dkim;
265
+ const parts = [
266
+ spf ? 'SPF published' : 'no SPF record',
267
+ dkim ? 'DKIM published' : 'no DKIM record',
268
+ dmarc ? `DMARC published (p=${policy ?? 'unset'})` : 'no DMARC record',
269
+ ];
270
+ if (!spf && !dkim && !dmarc) {
271
+ return { grade: 0, evidence: 'no SPF, DKIM or DMARC record found in the tree' };
272
+ }
273
+ if (policy === 'none') {
274
+ return {
275
+ grade: 1,
276
+ evidence:
277
+ `${parts.join('; ')} — capped at Present: p=none is a monitoring policy and is NOT protection, ` +
278
+ 'because receivers are told to take no action on a message that fails. A domain publishing it ' +
279
+ 'scores below one that enforces.',
280
+ };
281
+ }
282
+ if (policy === 'quarantine' || policy === 'reject') {
283
+ if (!aligned) {
284
+ return {
285
+ grade: 1,
286
+ evidence: `${parts.join('; ')} — an enforcing policy with neither SPF nor DKIM to align against protects nothing yet`,
287
+ };
288
+ }
289
+ if (policy === 'reject' && spf && dkim && rua) {
290
+ return { grade: 3, evidence: `${parts.join('; ')} — enforcing, both mechanisms aligned, and reporting to an rua address` };
291
+ }
292
+ return { grade: 2, evidence: `${parts.join('; ')} — an enforcing DMARC policy with an aligned mechanism` };
293
+ }
294
+ return { grade: 1, evidence: `${parts.join('; ')} — a mail-authenticity record exists, with no enforcing DMARC policy above it` };
295
+ }
296
+
297
+ /** A privacy notice, a retention rule, a consent mechanism, a deletion path. */
298
+ function _privacyObligations(corpus: { text: string; paths: string[] }, ci: string): Scored {
299
+ const notice =
300
+ corpus.paths.some((p) => /(privacy|datenschutz|\bdpa\b|data-processing)/i.test(p)) ||
301
+ /\b(privacy (policy|notice)|Datenschutzerkl)/i.test(corpus.text);
302
+ const retention = /\b(retention (policy|period|schedule)|data retention|storage limitation)\b/i.test(corpus.text);
303
+ const consent = /\b(cookie consent|consent (banner|manager|mode)|cookiebot|usercentrics|klaro|borlabs)\b/i.test(corpus.text);
304
+ const erasure = /\b(right to erasure|data subject request|DSAR|DSR|account deletion|delete my data)\b/i.test(corpus.text);
305
+ const enforced = _ciBlocks(ci, 'privacy', 'gdpr', 'retention', 'consent', 'dsar');
306
+ const seen: string[] = [
307
+ ...(notice ? ['a privacy notice or DPA'] : []),
308
+ ...(retention ? ['a retention rule'] : []),
309
+ ...(consent ? ['a consent mechanism'] : []),
310
+ ...(erasure ? ['an erasure / data-subject-request path'] : []),
311
+ ...(enforced ? ['a blocking CI step naming a privacy obligation'] : []),
312
+ ];
313
+ return {
314
+ grade: _graded(notice || retention || consent || erasure, enforced),
315
+ evidence: seen.length > 0 ? seen.join('; ') : 'no privacy notice, retention rule, consent mechanism or erasure path found',
316
+ };
317
+ }
318
+
152
319
  export function grade(root: string): Matrix {
153
320
  const ci = _ciText(root);
154
321
  const hasCi = ci !== '';
@@ -310,6 +477,41 @@ export function grade(root: string): Matrix {
310
477
  evidence: 'out of scope by design (see the roadmap exclusion table)',
311
478
  });
312
479
 
480
+ // 11. operational recovery — NON-KNOCKOUT
481
+ //
482
+ // New dimensions are never knockouts. A knockout added today would re-bind
483
+ // every existing verdict at L0 on the day it shipped, which changes what the
484
+ // level MEANS while looking like a change to what it measures.
485
+ const corpus = _textCorpus(root);
486
+ const recovery = _recovery(corpus, ci);
487
+ dims.push({
488
+ id: 'operational-recovery',
489
+ label: 'operational recovery',
490
+ knockout: false,
491
+ grade: recovery.grade,
492
+ evidence: recovery.evidence,
493
+ });
494
+
495
+ // 12. mail authenticity — NON-KNOCKOUT, own ladder (4.2)
496
+ const mail = _mailAuthenticity(corpus);
497
+ dims.push({
498
+ id: 'mail-authenticity',
499
+ label: 'mail authenticity',
500
+ knockout: false,
501
+ grade: mail.grade,
502
+ evidence: mail.evidence,
503
+ });
504
+
505
+ // 13. privacy obligations — NON-KNOCKOUT
506
+ const privacy = _privacyObligations(corpus, ci);
507
+ dims.push({
508
+ id: 'privacy-obligations',
509
+ label: 'privacy obligations',
510
+ knockout: false,
511
+ grade: privacy.grade,
512
+ evidence: privacy.evidence,
513
+ });
514
+
313
515
  // ── the verdict: min over knockouts, undetectable binds at L0 ──────────
314
516
  const knockouts = dims.filter((d) => d.knockout);
315
517
  const undetectable = knockouts.find((d) => d.grade === null);
@@ -440,6 +642,23 @@ export function selfTest(): number {
440
642
  return /%|\/100/.test(out) ? 1 : 0;
441
643
  },
442
644
  },
645
+ {
646
+ name: 'DMARC p=none scores strictly below p=quarantine — monitoring is not protection',
647
+ expect: 'accept',
648
+ run: () => {
649
+ const g = (zone: string): number => {
650
+ const d = grade(mk({
651
+ 'dns/zone.txt': zone,
652
+ 'dns/spf.txt': '@ IN TXT "v=spf1 include:_spf.example.net -all"\n',
653
+ 'dns/dkim.txt': 'sel._domainkey IN TXT "v=DKIM1; k=rsa; p=MIIB"\n',
654
+ }));
655
+ return d.dimensions.find((x) => x.id === 'mail-authenticity')?.grade ?? -1;
656
+ };
657
+ const none = g('_dmarc IN TXT "v=DMARC1; p=none; rua=mailto:d@example.com"\n');
658
+ const quarantine = g('_dmarc IN TXT "v=DMARC1; p=quarantine; rua=mailto:d@example.com"\n');
659
+ return none === 1 && quarantine === 2 ? 0 : 1;
660
+ },
661
+ },
443
662
  {
444
663
  name: 'rejects a missing target root rather than grading an empty tree',
445
664
  expect: 'reject',
@@ -42,8 +42,13 @@
42
42
  * measurement runs first.
43
43
  *
44
44
  * Usage:
45
- * ./scripts-run src/scripts/hooks_doctor [--host <name>] [--format text|json]
46
- * [--root <path>] [--limit <n>]
45
+ * ./agent-config hooks:effect [--host <name>] [--format text|json]
46
+ * [--root <path>] [--limit <n>] [--help]
47
+ * ./scripts-run src/scripts/hook_effect_doctor (same flags, in this checkout)
48
+ *
49
+ * `--help` prints the two verdict vocabularies FROM THE MODULE THAT DEFINES
50
+ * THEM. A help text that restated them by hand would be a fifth hand-typed copy
51
+ * of a closed set, which is the drift class this repository gates elsewhere.
47
52
  */
48
53
  import { spawnSync } from 'node:child_process';
49
54
  import * as crypto from 'node:crypto';
@@ -51,7 +56,13 @@ import * as fs from 'node:fs';
51
56
  import * as os from 'node:os';
52
57
  import * as path from 'node:path';
53
58
 
54
- import { buildHostReport, type ConcernProbe, type HostReport } from './_lib/hook_effect_probe.js';
59
+ import {
60
+ HOST_VERDICTS,
61
+ PROBE_STATES,
62
+ buildHostReport,
63
+ type ConcernProbe,
64
+ type HostReport,
65
+ } from './_lib/hook_effect_probe.js';
55
66
 
56
67
  const HERE = path.dirname(new URL(import.meta.url).pathname);
57
68
  const REPO_ROOT = path.resolve(HERE, '..', '..');
@@ -230,6 +241,34 @@ export function renderText(r: HostReport): string {
230
241
  return out.join('\n') + '\n';
231
242
  }
232
243
 
244
+ /**
245
+ * The synopsis, both closed vocabularies, and the scope line.
246
+ *
247
+ * Rendered from `PROBE_STATES` and `HOST_VERDICTS` rather than restated, so the
248
+ * help can never describe a vocabulary the probe no longer has.
249
+ */
250
+ export function renderHelp(): string {
251
+ return [
252
+ 'agent-config hooks:effect — does a bound concern actually FIRE on this host?',
253
+ '',
254
+ 'Usage: agent-config hooks:effect [--host <name>] [--format text|json]',
255
+ ' [--root <path>] [--limit <n>] [--help]',
256
+ '',
257
+ 'Not hooks:doctor. That one reads the MANIFEST — which concerns are declared,',
258
+ 'their posture, whether their scripts and trampolines exist. This one dispatches',
259
+ 'each eligible concern against a scratch root and reports what was observed.',
260
+ '',
261
+ `per-concern states: ${PROBE_STATES.join(' · ')}`,
262
+ `host verdicts: ${HOST_VERDICTS.join(' · ')}`,
263
+ '',
264
+ '`unknown` is never rendered as `effective`: a concern that wrote in the sandbox,',
265
+ 'or that could not be dispatched at all, is reported as unestablished — never as a',
266
+ 'working configuration.',
267
+ '',
268
+ 'Exits 0 on every verdict. An `inert` host is information, not a build failure.',
269
+ ].join('\n') + '\n';
270
+ }
271
+
233
272
  export function main(argv: string[] = process.argv.slice(2)): number {
234
273
  let host = 'claude';
235
274
  let format: 'text' | 'json' = 'text';
@@ -237,6 +276,10 @@ export function main(argv: string[] = process.argv.slice(2)): number {
237
276
  let limit = 200;
238
277
  for (let i = 0; i < argv.length; i++) {
239
278
  const a = argv[i];
279
+ if (a === '--help' || a === '-h') {
280
+ process.stdout.write(renderHelp());
281
+ return 0;
282
+ }
240
283
  if (a === '--host') host = argv[++i] ?? host;
241
284
  else if (a === '--format') format = (argv[++i] as 'text' | 'json') ?? format;
242
285
  else if (a === '--root') root = path.resolve(argv[++i] ?? root);
@@ -246,7 +289,7 @@ export function main(argv: string[] = process.argv.slice(2)): number {
246
289
  try {
247
290
  report = buildReport(root, host, limit);
248
291
  } catch (err) {
249
- process.stderr.write(`❌ hooks_doctor: ${(err as Error).message}\n`);
292
+ process.stderr.write(`❌ hooks:effect: ${(err as Error).message}\n`);
250
293
  return 2;
251
294
  }
252
295
  process.stdout.write(format === 'json' ? JSON.stringify(report, null, 2) + '\n' : renderText(report));
@@ -64,6 +64,13 @@
64
64
  * inert mechanism that says nothing is
65
65
  * indistinguishable from a healthy idle run,
66
66
  * which is the failure this file exists over)
67
+ * plan premise moved → allow (event: halt-premise-invalidated, ONCE per
68
+ * run. Engaged-under fingerprint and newest
69
+ * observation both known and differing. Before the
70
+ * counter rungs: iterating against a stale plan
71
+ * does not make it current, and capping out would
72
+ * report `exhausted`, a budget word, for staleness.
73
+ * Never fires on traffic the run did not observe)
67
74
  * quality-gate refusal → defer (event: deferred-quality-gate)
68
75
  * duplicate stop fire → repeat the BLOCK, no count (event: none —
69
76
  * an allow here would END the reply the block
@@ -173,8 +180,35 @@ import {
173
180
  readUnavailableDependency,
174
181
  stallSignal,
175
182
  type StallLevel,
176
- type UnavailableDependency,
177
183
  } from '../_lib/loop_guards.js';
184
+ import { premiseMoved, readContextObservation } from '../_lib/context_observation.js';
185
+ import { RUN_TERMINAL_VOCABULARY_VERSION } from '../_lib/outcome_vocabularies.js';
186
+ import {
187
+ HALT_ACTIONS,
188
+ ladder,
189
+ MAX_ITERATIONS,
190
+ parseHaltStamp,
191
+ STALL_WINDOW,
192
+ terminalStateFor,
193
+ WALL_CLOCK_CAP_MS,
194
+ type LadderAction,
195
+ } from '../_lib/continuation_ladder.js';
196
+
197
+ /**
198
+ * DECLARED in `_lib/continuation_ladder.ts`, re-exported here: one declaration,
199
+ * two import paths, no second copy to drift. That module's header says why the
200
+ * move happened (`road-to-wired-instruments` Phase 2).
201
+ */
202
+ export {
203
+ HALT_ACTIONS,
204
+ ladder,
205
+ MAX_ITERATIONS,
206
+ parseHaltStamp,
207
+ STALL_WINDOW,
208
+ terminalStateFor,
209
+ WALL_CLOCK_CAP_MS,
210
+ type LadderAction,
211
+ };
178
212
 
179
213
  /** Re-exported so a test can assert the IDENTITY, not two matching literals. */
180
214
  export { TRANSCRIPT_READ_MAX_BYTES };
@@ -198,11 +232,6 @@ export const EVENTS_RELPATH = path.join(
198
232
  );
199
233
  const STATE_DIR_REL = path.join('agents', 'runtime', 'state');
200
234
 
201
- /** Ladder defaults. Phase 1.1 pre-registers all three for revisit, not tuning. */
202
- export const MAX_ITERATIONS = 25;
203
- export const WALL_CLOCK_CAP_MS = 4 * 60 * 60 * 1000;
204
- export const STALL_WINDOW = 3;
205
-
206
235
  /**
207
236
  * How many consecutive absent-roadmap fires confirm the absence.
208
237
  *
@@ -285,6 +314,14 @@ export interface RunState {
285
314
  * cannot finish") named these rungs as its mitigation.
286
315
  */
287
316
  halted?: LadderAction;
317
+ /**
318
+ * The fingerprint this run ENGAGED under — its plan premise, recorded once on
319
+ * the first engagement from `_lib/context_observation` and then left alone.
320
+ * Refreshing it every fire would make both sides of the premise comparison
321
+ * equal by construction and the rung unreachable. Absent means the run never
322
+ * observed one, which is never a disagreement (`premiseMoved`).
323
+ */
324
+ context_fingerprint?: string;
288
325
  }
289
326
 
290
327
  /**
@@ -347,34 +384,6 @@ export interface ScanResult {
347
384
  next: NextStep | null;
348
385
  }
349
386
 
350
- export type LadderAction =
351
- | 'engage'
352
- | 'complete'
353
- | 'blocked'
354
- | 'halt-max-iterations'
355
- | 'halt-wall-clock'
356
- | 'halt-stall'
357
- | 'halt-dependency-unavailable';
358
-
359
- /** The terminal rungs — the set `RunState.halted` may hold. */
360
- /**
361
- * `halt-roadmap-absent` is deliberately NOT in this union and not in
362
- * `LadderAction`. It is an event name the caller emits directly when the claimed
363
- * roadmap is unreadable from the authoritative tree, and it CLEARS the state
364
- * rather than stamping `halted` — widening the union would let `parseRecord`
365
- * accept it as a `halted` value, which would mean a run whose budget was cleared
366
- * also carried a halt stamp. The two are mutually exclusive by construction.
367
- */
368
- export const HALT_ACTIONS: readonly LadderAction[] = [
369
- 'halt-max-iterations',
370
- 'halt-wall-clock',
371
- 'halt-stall',
372
- // Phase 5.4. Checked BEFORE the counter rungs: a dependency the run cannot
373
- // obtain is not something more iterations close, so consuming the budget
374
- // against it is the waste the rung exists to stop.
375
- 'halt-dependency-unavailable',
376
- ];
377
-
378
387
  /**
379
388
  * Frontmatter `execution.mode`, or null. Line-oriented on purpose: the
380
389
  * frontmatter this repo writes is two levels deep at most, and a YAML parser
@@ -480,50 +489,6 @@ export function extractVerify(stepText: string): string | null {
480
489
  return null;
481
490
  }
482
491
 
483
- /**
484
- * The termination ladder, pure. `state` is the record BEFORE this stop;
485
- * `openCount` is the scan of the claimed roadmap as it stands now.
486
- */
487
- export function ladder(
488
- state: RunState,
489
- openCount: number,
490
- nowMs: number,
491
- // No default: `= 0` would silently restore the complete-instead-of-blocked
492
- // behaviour for any caller that omitted it.
493
- blockedCount: number,
494
- caps: { maxIterations: number; wallClockMs: number; stallWindow: number } = {
495
- maxIterations: MAX_ITERATIONS,
496
- wallClockMs: WALL_CLOCK_CAP_MS,
497
- stallWindow: STALL_WINDOW,
498
- },
499
- // Phase 5.4. `null` when nothing unobtainable was detected. Optional so every
500
- // existing caller and test keeps its meaning unchanged.
501
- unavailable: UnavailableDependency | null = null,
502
- ): LadderAction {
503
- // A halt is terminal for this run id. Checked BEFORE `complete` so a
504
- // halted run whose roadmap later reads zero-open does not report a
505
- // completion it never reached.
506
- if (state.halted) return state.halted;
507
- // `scanOpenSteps` EXCLUDES `blocked-by:` steps from `openCount`, so zero-open
508
- // with blocked steps left is exhaustion of runnable work, not completion —
509
- // ADR-235's own terminal outcome, and never a sixth halt.
510
- if (openCount === 0) return blockedCount > 0 ? 'blocked' : 'complete';
511
- // BEFORE the counter rungs, deliberately: a missing credential, an absent
512
- // binary or an exhausted quota is not closed by iterating, so spending the
513
- // budget on it converts a nameable blocker into an anonymous cap-out.
514
- if (unavailable !== null) return 'halt-dependency-unavailable';
515
- if (state.iterations >= caps.maxIterations) return 'halt-max-iterations';
516
- const started = Date.parse(state.started_at);
517
- if (Number.isFinite(started) && nowMs - started >= caps.wallClockMs) {
518
- return 'halt-wall-clock';
519
- }
520
- const tail = state.history.slice(-caps.stallWindow);
521
- if (tail.length >= caps.stallWindow && tail.every((n) => n === openCount)) {
522
- return 'halt-stall';
523
- }
524
- return 'engage';
525
- }
526
-
527
492
  /**
528
493
  * Where a run's state lives: keyed on the session AND the roadmap.
529
494
  *
@@ -686,16 +651,17 @@ function readState(file: string): RunState | null {
686
651
  if (o['inert_reported'] === true) {
687
652
  rec.inert_reported = true;
688
653
  }
689
- // Validated against the halt set rather than accepted as any string:
690
- // an unrecognised value would be returned verbatim by `ladder` and
691
- // become an action no branch below handles.
692
- const halted = o['halted'];
693
- if (
694
- typeof halted === 'string' &&
695
- (HALT_ACTIONS as readonly string[]).includes(halted)
696
- ) {
697
- rec.halted = halted as LadderAction;
654
+ // Round-tripped for the same reason `history_source` is: dropped, the
655
+ // premise would be re-recorded every fire from the newest observation, so
656
+ // the two sides could never differ and the rung would be dead code.
657
+ if (typeof o['context_fingerprint'] === 'string' && o['context_fingerprint'] !== '') {
658
+ rec.context_fingerprint = o['context_fingerprint'];
698
659
  }
660
+ // Tolerant, not permissive — see `parseHaltStamp` for what it accepts
661
+ // beyond the halt set and why dropping a newer build's stamp was the
662
+ // wrong failure direction for a budget.
663
+ const halted = parseHaltStamp(o['halted']);
664
+ if (halted !== null) rec.halted = halted;
699
665
  return rec;
700
666
  } catch {
701
667
  return null;
@@ -1412,7 +1378,21 @@ export function main(): number {
1412
1378
  // differently. One producer, one definition.
1413
1379
  state.stall = stallSignal(state.history, STALL_WINDOW).level;
1414
1380
 
1415
- const action = ladder(state, scan.open, Date.now(), scan.blocked, undefined, unavailable);
1381
+ // 2.1 — repository drift, from the newest observation `roadmap:context`
1382
+ // recorded. No probe here: the fingerprint costs a `gh` call and this is the
1383
+ // Stop path. An unknown on either side is never a halt.
1384
+ const observed = readContextObservation(workspaceRoot)?.fingerprint ?? null;
1385
+ const premiseInvalidated = premiseMoved(state.context_fingerprint, observed);
1386
+
1387
+ const action = ladder(
1388
+ state,
1389
+ scan.open,
1390
+ Date.now(),
1391
+ scan.blocked,
1392
+ undefined,
1393
+ unavailable,
1394
+ premiseInvalidated,
1395
+ );
1416
1396
 
1417
1397
  if (action !== 'engage') {
1418
1398
  // A run that is ALREADY stamped emits nothing further — round 6 finding 6.
@@ -1436,6 +1416,12 @@ export function main(): number {
1436
1416
  iterations: state.iterations,
1437
1417
  open: scan.open,
1438
1418
  blocked: scan.blocked,
1419
+ // A ledger line is a persisted shape carrying a `RunTerminalState`,
1420
+ // so it names the vocabulary version it was written against: a
1421
+ // reader meeting an unknown value can then tell a newer writer
1422
+ // from a corrupt row.
1423
+ terminal_state: terminalStateFor(action),
1424
+ terminal_vocabulary_version: RUN_TERMINAL_VOCABULARY_VERSION,
1439
1425
  at: new Date().toISOString(),
1440
1426
  ...roots,
1441
1427
  });
@@ -1472,6 +1458,10 @@ export function main(): number {
1472
1458
 
1473
1459
  state.iterations += 1;
1474
1460
  state.last_turn = turnOrdinal;
1461
+ // Recorded ONCE — the premise this run engaged under. See the field's docblock.
1462
+ if (state.context_fingerprint === undefined && observed !== null) {
1463
+ state.context_fingerprint = observed;
1464
+ }
1475
1465
  state.history.push(scan.open);
1476
1466
  state.last_engaged_at = new Date().toISOString();
1477
1467
  if (!writeState(stateFile, state)) {
@@ -54,6 +54,7 @@ import {
54
54
  scanEolSlice,
55
55
  type EolCounters,
56
56
  } from '../_lib/session_eol.js';
57
+ import { readContextObservation } from '../_lib/context_observation.js';
57
58
  import { buildCheckpoint, writeCheckpoint } from '../_lib/run_checkpoint.js';
58
59
  import { read_claimed_slug } from '../session_register_hook.js';
59
60
  import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
@@ -381,7 +382,17 @@ export function main(): number {
381
382
  try {
382
383
  const slug = read_claimed_slug(workspaceRoot, checkpointRunId);
383
384
  if (slug !== null) {
384
- const cp = buildCheckpoint(workspaceRoot, eolSessionKey(checkpointRunId), slug);
385
+ // `road-to-wired-instruments` 2.1. The fingerprint was never
386
+ // passed here, so `context_fingerprint` was null in every
387
+ // checkpoint this package has ever written and the resume-time
388
+ // drift check it exists for could not fire. The value comes from
389
+ // the newest recorded probe rather than from a fresh one: this is
390
+ // the Stop path, and the probe costs a `gh` call. Absent stays
391
+ // absent, which reads as "not known" and never as "unchanged".
392
+ const observed = readContextObservation(workspaceRoot)?.fingerprint ?? null;
393
+ const cp = buildCheckpoint(workspaceRoot, eolSessionKey(checkpointRunId), slug, {
394
+ ...(observed !== null ? { contextFingerprint: observed } : {}),
395
+ });
385
396
  if (cp !== null) writeCheckpoint(workspaceRoot, cp);
386
397
  }
387
398
  } catch {
@@ -26,6 +26,24 @@ cat > "$HOOKS_DIR/pre-push" << 'EOF'
26
26
  # Pre-push hook: mirror the CI "Sync + Generate Tools Consistency" gate LOCALLY
27
27
  # so derived-output drift is caught BEFORE push, not in remote CI.
28
28
  #
29
+ # THIS HOOK REFUSES. IT NEVER MERGES, AND NO ONE MAY "IMPROVE" IT INTO ONE.
30
+ # A merge inside pre-push rewrites the working tree at the exact moment the
31
+ # contributor believes their work is finished: one rejected push silently
32
+ # becomes an unreviewed commit, and a conflict lands them mid-merge with no
33
+ # warning and no way back to the state they typed `git push` from. Detection
34
+ # belongs here; resolution belongs to a step run with the result in front of
35
+ # you. When this hook refuses for staleness, the thing that fixes it is
36
+ #
37
+ # task push-ready # fetch -> integrate the base SET -> regenerate
38
+ # # -> verify -> re-check freshness -> push
39
+ # task push-ready DRY=1 # the same six steps, read-only
40
+ #
41
+ # The base is a SET: a branch targeting a release line or a stacked parent may
42
+ # also have to integrate the default branch, per the branch-convergence policy
43
+ # read at the TARGET commit (src/scripts/_lib/branch_convergence.ts). The
44
+ # default is merged FIRST, so a conflict surfaces against the broader base
45
+ # where it is cheapest to abandon.
46
+ #
29
47
  # History: the remote "Consistency" check failed in a large share of PRs because
30
48
  # the old hook only verified dist sync + the COMMAND count — it missed
31
49
  # guideline/skill/rule count drift and the generate-tools / router / corpus