@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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/pr/create.md +16 -1
- package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
- package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
- package/dist/agent-src/rules/token-budget-discipline.md +2 -2
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
- package/dist/cli-delegate/cmd_conformance.js +1 -1
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +5 -5
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +528 -371
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +185 -98
- package/package.json +1 -1
- package/src/config/assurance-capability-registry.json +1 -1
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +62 -2
- package/src/config/gate-reachability-exemptions.json +1 -0
- package/src/config/gate-violation-baselines.json +4 -3
- package/src/config/web-launch-readiness.json +4 -2
- package/src/scripts/_cli/_doctor_emit.ts +71 -0
- package/src/scripts/_cli/_doctor_strict.ts +163 -0
- package/src/scripts/_cli/cmd_doctor.ts +16 -37
- package/src/scripts/_dispatch.bash +11 -0
- package/src/scripts/_lib/branch_convergence.ts +186 -0
- package/src/scripts/_lib/context_observation.ts +132 -0
- package/src/scripts/_lib/continuation_ladder.ts +188 -0
- package/src/scripts/_lib/experiment_binding.ts +95 -0
- package/src/scripts/_lib/ignored_blocker.ts +8 -1
- package/src/scripts/_lib/outcome_envelope.ts +6 -1
- package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
- package/src/scripts/_lib/release_highlights.ts +12 -3
- package/src/scripts/_lib/repeated_failure.ts +19 -2
- package/src/scripts/_lib/runtime_journal.ts +11 -4
- package/src/scripts/check_agent_artifact_location.ts +337 -0
- package/src/scripts/check_artefact_count_messaging.ts +74 -8
- package/src/scripts/check_enforcement_coverage.ts +40 -1
- package/src/scripts/check_release_highlights.ts +17 -1
- package/src/scripts/check_web_launch_readiness.ts +310 -20
- package/src/scripts/evolution_lab.ts +53 -1
- package/src/scripts/generate_capability_matrix.ts +16 -9
- package/src/scripts/grade_target_readiness.ts +221 -2
- package/src/scripts/hook_effect_doctor.ts +47 -4
- package/src/scripts/hooks/run_continuation_hook.ts +78 -88
- package/src/scripts/hooks/session_eol_hook.ts +12 -1
- package/src/scripts/install-hooks.sh +18 -0
- package/src/scripts/lint_code_comments.ts +106 -1
- package/src/scripts/release_drill.ts +57 -1
- package/src/scripts/release_publication.ts +58 -0
- package/src/scripts/roadmap_context.ts +15 -0
- package/src/scripts/skill_scout.ts +633 -0
- 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.
|
|
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
|
|
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
|
-
* ./
|
|
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 {
|
|
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(`❌
|
|
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
|
-
//
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|