@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
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* |---|---|---|---|
|
|
14
14
|
* | phase | one audit-log line — a `/work` PHASE | here, consumed by `orchestration_record` | 4 |
|
|
15
15
|
* | step | one work-engine STEP | `work_engine/delivery_state.ts` (template tree) | 3 |
|
|
16
|
-
* | run | one command or gate RUN | here, consumed by `outcome_envelope` | 6
|
|
16
|
+
* | run | one command or gate RUN | here, consumed by `outcome_envelope` | 7 | code-comment-allow report-comment -- one row of a pre-existing three-row table documenting the three vocabularies; the digit changed from 6 to 7 with the vocabulary, and the table is the registry's whole point
|
|
17
17
|
*
|
|
18
18
|
* All three are declared in code AND emitted today. `skipped` and `error` are
|
|
19
19
|
* not aspirational: `envelopeOutcome` in `orchestration_record.ts` returns
|
|
@@ -33,16 +33,42 @@
|
|
|
33
33
|
* `partial`. So: separate vocabularies, one registry, mappings only where
|
|
34
34
|
* execution actually crosses.
|
|
35
35
|
*
|
|
36
|
-
* **Mappings are not written for symmetry.**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
* **Mappings are not written for symmetry.** Two crossings exist, and both are
|
|
37
|
+
* places where execution actually translates: `DispatchOutcome` -> phase in
|
|
38
|
+
* `orchestration_record.ts`, and `LadderAction` -> run in
|
|
39
|
+
* `continuation_ladder.ts` (added 2026-09-03 with the seventh run state, so the
|
|
40
|
+
* state has a producer rather than being a word nothing emits). A total
|
|
41
|
+
* phase<->step<->run mapping would be nine relations of which two are real, and
|
|
42
|
+
* the seven invented ones would read as contract.
|
|
40
43
|
*
|
|
41
44
|
* `revisit-if`: a producer needs a value its vocabulary cannot express, or a
|
|
42
45
|
* second real crossing appears, or producer analysis shows two of the three
|
|
43
46
|
* subjects share identical terminal semantics with a lossless mapping — at
|
|
44
47
|
* which point unifying those two becomes the cheaper answer.
|
|
45
48
|
*
|
|
49
|
+
* **The run vocabulary grew to seven on 2026-09-03** — exactly the first branch
|
|
50
|
+
* of the reopening condition above: the continuation ladder needed a word for a
|
|
51
|
+
* run whose plan premise moved, and had none. AI council (anthropic/claude-sonnet-4-5 +
|
|
52
|
+
* openai/codex-default, three rounds, blind chairman) resolved it unanimously
|
|
53
|
+
* as option (a), extend rather than overload `blocked` with a reason field. The
|
|
54
|
+
* seats attached the same prerequisites, discharged here: a versioned value
|
|
55
|
+
* domain ({@link RUN_TERMINAL_VOCABULARY_VERSION}), a tolerant read
|
|
56
|
+
* ({@link readRunTerminalState}), a declared downgrade
|
|
57
|
+
* ({@link RUN_TERMINAL_STATE_DOWNGRADE}) and the rollback trigger below.
|
|
58
|
+
*
|
|
59
|
+
* **Rollback trigger, stated rather than instrumented.** Withdraw
|
|
60
|
+
* `premise-invalidated` — remove it from {@link RUN_TERMINAL_STATES} and let the
|
|
61
|
+
* ladder rung report its declared downgrade `blocked` instead — on EITHER of:
|
|
62
|
+
* (1) any consumer is observed failing (a throw, a refused write, a dropped
|
|
63
|
+
* record) on encountering the value, or (2) `readRunTerminalState` returns
|
|
64
|
+
* `null` for a persisted value in normal operation, which would mean a writer
|
|
65
|
+
* emitted something this registry does not know. Both are single-occurrence
|
|
66
|
+
* triggers, not rates: the downgrade mapping already exists, so withdrawal
|
|
67
|
+
* costs one commit and no migration, and there is no reason to tolerate a
|
|
68
|
+
* budget of failures before paying that. No telemetry is built for this and
|
|
69
|
+
* none is claimed — condition (1) surfaces as a failing run and (2) as a null
|
|
70
|
+
* where a value was written.
|
|
71
|
+
*
|
|
46
72
|
* Type-only + literal arrays. No `node:` import, so any surface may read it.
|
|
47
73
|
*/
|
|
48
74
|
|
|
@@ -82,9 +108,99 @@ export const RUN_TERMINAL_STATES = [
|
|
|
82
108
|
'approval-required',
|
|
83
109
|
'exhausted',
|
|
84
110
|
'stagnated',
|
|
111
|
+
/**
|
|
112
|
+
* The plan premise this run was built on no longer holds — the situational
|
|
113
|
+
* -awareness fingerprint the run engaged under (`origin/main` plus every open
|
|
114
|
+
* PR head) was re-observed and differs.
|
|
115
|
+
*
|
|
116
|
+
* Added by `road-to-wired-instruments` Phase 2 under the AI-council decision
|
|
117
|
+
* of 2026-09-03 (option (a), unanimous). It is NOT `blocked` with a reason
|
|
118
|
+
* field: premise invalidation is operationally distinct from a missing
|
|
119
|
+
* precondition and from an exhausted budget, and folding it into either loses
|
|
120
|
+
* both aggregation by state and type-enforced exhaustive handling. The
|
|
121
|
+
* remedy differs too — re-probe and re-plan, where `blocked` says obtain the
|
|
122
|
+
* missing thing and `exhausted` says the budget was too small.
|
|
123
|
+
*/
|
|
124
|
+
'premise-invalidated',
|
|
85
125
|
] as const;
|
|
86
126
|
export type RunTerminalState = (typeof RUN_TERMINAL_STATES)[number];
|
|
87
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Version of the RUN vocabulary's VALUE DOMAIN — bumped when a member is added
|
|
130
|
+
* or removed, never for prose.
|
|
131
|
+
*
|
|
132
|
+
* Distinct from any table version: `runtime_journal.JOURNAL_SCHEMA_VERSION`
|
|
133
|
+
* covers tables and columns and is unaffected by a widening of what one column
|
|
134
|
+
* may contain, so a value-domain change needs its own number or it travels
|
|
135
|
+
* unversioned. A persisted shape carrying a `RunTerminalState` stamps THIS
|
|
136
|
+
* number beside the value, so a reader can tell a value it does not know from a
|
|
137
|
+
* value that is corrupt.
|
|
138
|
+
*
|
|
139
|
+
* v1 — the six original states.
|
|
140
|
+
* v2 — adds `premise-invalidated` (2026-09-03).
|
|
141
|
+
*/
|
|
142
|
+
export const RUN_TERMINAL_VOCABULARY_VERSION = 2;
|
|
143
|
+
|
|
144
|
+
/** The vocabulary version each member first appeared in. */
|
|
145
|
+
export const RUN_TERMINAL_STATE_SINCE: Readonly<Record<RunTerminalState, number>> = {
|
|
146
|
+
success: 1,
|
|
147
|
+
'clean-no-op': 1,
|
|
148
|
+
blocked: 1,
|
|
149
|
+
'approval-required': 1,
|
|
150
|
+
exhausted: 1,
|
|
151
|
+
stagnated: 1,
|
|
152
|
+
'premise-invalidated': 2,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* What a consumer pinned to an older vocabulary version reports instead.
|
|
157
|
+
*
|
|
158
|
+
* `premise-invalidated` -> `blocked`: of the six v1 states it is the only one
|
|
159
|
+
* that is honest for a reader that cannot represent it — the run stopped on an
|
|
160
|
+
* external condition it cannot resolve by iterating. Mapping it to `exhausted`
|
|
161
|
+
* would invite the wrong remedy (raise the budget), which is the exact
|
|
162
|
+
* confusion the contract's own `exhausted`/`stagnated` split exists to prevent.
|
|
163
|
+
*
|
|
164
|
+
* Every member introduced after v1 MUST have a row here; `outcome_vocabularies`
|
|
165
|
+
* contract tests assert that, so a future eighth value cannot ship undowngradable.
|
|
166
|
+
*/
|
|
167
|
+
export const RUN_TERMINAL_STATE_DOWNGRADE: Readonly<Partial<Record<RunTerminalState, RunTerminalState>>> = {
|
|
168
|
+
'premise-invalidated': 'blocked',
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The state a consumer pinned at `toVersion` should report for `state`.
|
|
173
|
+
*
|
|
174
|
+
* Returns the state unchanged when it already existed at that version, the
|
|
175
|
+
* declared downgrade when it did not, and `null` for a string this vocabulary
|
|
176
|
+
* does not know at all — never a throw, because this is the function a reader
|
|
177
|
+
* of PERSISTED data calls, and persisted data outlives the code that wrote it.
|
|
178
|
+
*/
|
|
179
|
+
export function downgradeRunTerminalState(
|
|
180
|
+
state: string,
|
|
181
|
+
toVersion: number,
|
|
182
|
+
): RunTerminalState | null {
|
|
183
|
+
if (!isRunTerminalState(state)) return null;
|
|
184
|
+
if ((RUN_TERMINAL_STATE_SINCE[state] ?? 1) <= toVersion) return state;
|
|
185
|
+
return RUN_TERMINAL_STATE_DOWNGRADE[state] ?? null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Tolerant read of a persisted terminal state: a member returns itself,
|
|
190
|
+
* anything else returns `null`. Never throws.
|
|
191
|
+
*
|
|
192
|
+
* `null` rather than a fabricated fallback is the load-bearing choice. This
|
|
193
|
+
* vocabulary's own consumers already treat a null terminal state as "not
|
|
194
|
+
* recorded" (`repeated_failure` counts it under `unknown`), and that is exactly
|
|
195
|
+
* what an unrecognised value IS to a reader that cannot name it. Substituting
|
|
196
|
+
* `blocked` here would manufacture a measurement out of an absence — the
|
|
197
|
+
* downgrade above is for a value the reader KNOWS is newer, which is a
|
|
198
|
+
* different fact from a value it cannot place.
|
|
199
|
+
*/
|
|
200
|
+
export function readRunTerminalState(v: unknown): RunTerminalState | null {
|
|
201
|
+
return isRunTerminalState(v) ? v : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
88
204
|
/**
|
|
89
205
|
* The crossings that exist. One row, because one crossing is real.
|
|
90
206
|
*
|
|
@@ -112,6 +228,20 @@ export const CROSS_DOMAIN_MAPPINGS = [
|
|
|
112
228
|
*/
|
|
113
229
|
semantics_version: 2,
|
|
114
230
|
},
|
|
231
|
+
{
|
|
232
|
+
source: 'LadderAction',
|
|
233
|
+
target: 'run',
|
|
234
|
+
at: 'src/scripts/_lib/continuation_ladder.ts',
|
|
235
|
+
fn: 'terminalStateFor',
|
|
236
|
+
/**
|
|
237
|
+
* Version 1, and the mapping is TOTAL over `LadderAction` by
|
|
238
|
+
* construction: `TERMINAL_STATE_BY_ACTION` is a `Record<LadderAction,
|
|
239
|
+
* RunTerminalState | null>`, so adding a rung without deciding its
|
|
240
|
+
* terminal state does not compile. `engage` maps to null because it is
|
|
241
|
+
* the one action that is not terminal.
|
|
242
|
+
*/
|
|
243
|
+
semantics_version: 1,
|
|
244
|
+
},
|
|
115
245
|
] as const;
|
|
116
246
|
|
|
117
247
|
/** Is this string a phase outcome? The guard the audit-log consumer was missing. */
|
|
@@ -331,14 +331,23 @@ export function render_derived_head_values(
|
|
|
331
331
|
* `CURATED_HEAD_INSTRUCTION` is a named sentinel imported from the module that
|
|
332
332
|
* defines it — never a shape match over comment prose, which would reject
|
|
333
333
|
* unrelated legitimate comments and miss this one after a reword.
|
|
334
|
+
*
|
|
335
|
+
* `where` names the branch the remedy edit belongs on, because the same
|
|
336
|
+
* blockers are now read at a FOURTH site that is not on `main`:
|
|
337
|
+
* `guard_release_branch_push` refuses the release branch's first push, where
|
|
338
|
+
* the file to edit is on `release/X.Y.Z`. One definition of the blockers with a
|
|
339
|
+
* parameterised remedy beats a second copy of the message that drifts.
|
|
334
340
|
*/
|
|
335
|
-
export function publication_blockers(
|
|
341
|
+
export function publication_blockers(
|
|
342
|
+
sectionBody: string,
|
|
343
|
+
version: string,
|
|
344
|
+
where = '`main`',
|
|
345
|
+
): string[] {
|
|
336
346
|
const out: string[] = [];
|
|
337
347
|
if (sectionBody.includes(DERIVED_MARKER)) {
|
|
338
348
|
out.push(
|
|
339
349
|
`the ${version} section still carries \`${DERIVED_MARKER}\` — the generator's ` +
|
|
340
|
-
|
|
341
|
-
`${'`main`'} and re-run.`,
|
|
350
|
+
`draft head, not a curated claim. Rewrite those line(s) on ${where} and re-run.`,
|
|
342
351
|
);
|
|
343
352
|
}
|
|
344
353
|
if (sectionBody.includes(CURATED_HEAD_INSTRUCTION)) {
|
|
@@ -39,9 +39,26 @@ export const SUCCESS_TERMINALS: ReadonlySet<RunTerminalState> = new Set([
|
|
|
39
39
|
'clean-no-op',
|
|
40
40
|
]);
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Neither a failure nor a success for this metric, and each excluded for its own
|
|
44
|
+
* reason rather than by omission.
|
|
45
|
+
*
|
|
46
|
+
* `approval-required` is the safety floor working; counting a floor doing its
|
|
47
|
+
* job as a failure would make the metric fall every time governance fires.
|
|
48
|
+
*
|
|
49
|
+
* `premise-invalidated` is the same shape one layer out: the run stopped because
|
|
50
|
+
* the world it planned against moved, which is the drift detector working, not
|
|
51
|
+
* the work failing. It is also the state whose base rate is set by OTHER people's
|
|
52
|
+
* pushes rather than by this run's quality — `origin/main` moves constantly — so
|
|
53
|
+
* counting it as a failure would make the repeated-failure rate track repository
|
|
54
|
+
* traffic. That is the concrete form of this roadmap's Risk 1, arriving through
|
|
55
|
+
* the metric instead of through the ladder.
|
|
56
|
+
*/
|
|
57
|
+
const EXCLUDED: ReadonlySet<RunTerminalState> = new Set(['approval-required', 'premise-invalidated']);
|
|
58
|
+
|
|
42
59
|
// Every terminal state is classified or deliberately excluded -- silence about
|
|
43
60
|
// one is how a metric quietly changes meaning when the vocabulary grows.
|
|
44
|
-
const CLASSIFIED = new Set<string>([...FAILURE_TERMINALS, ...SUCCESS_TERMINALS,
|
|
61
|
+
const CLASSIFIED = new Set<string>([...FAILURE_TERMINALS, ...SUCCESS_TERMINALS, ...EXCLUDED]);
|
|
45
62
|
const _unclassified = RUN_TERMINAL_STATES.filter((t) => !CLASSIFIED.has(t));
|
|
46
63
|
if (_unclassified.length > 0) {
|
|
47
64
|
throw new Error(
|
|
@@ -86,7 +103,7 @@ export function repeatedFailureRate(
|
|
|
86
103
|
// `ep.terminal_state` is already the AMENDED verdict: reconstructEpisode
|
|
87
104
|
// drops superseded rows and takes the LAST effective terminal state.
|
|
88
105
|
const t = ep.terminal_state;
|
|
89
|
-
if (t === null || !CLASSIFIED.has(t) || t
|
|
106
|
+
if (t === null || !CLASSIFIED.has(t) || EXCLUDED.has(t)) {
|
|
90
107
|
unknown += 1;
|
|
91
108
|
continue;
|
|
92
109
|
}
|
|
@@ -173,7 +173,7 @@ import type { DatabaseSync } from 'node:sqlite';
|
|
|
173
173
|
|
|
174
174
|
import { current_branch, git_common_dir, git_dir } from './git_common_dir.js';
|
|
175
175
|
import type { TerminalState } from './outcome_envelope.js';
|
|
176
|
-
import { RUN_TERMINAL_STATES } from './outcome_vocabularies.js';
|
|
176
|
+
import { readRunTerminalState, RUN_TERMINAL_STATES } from './outcome_vocabularies.js';
|
|
177
177
|
import {
|
|
178
178
|
isSqliteAvailableSync,
|
|
179
179
|
loadSqliteSync,
|
|
@@ -303,7 +303,7 @@ export const CONSUMPTION_STATES = ['consumed', 'partially-consumed', 'rejected-w
|
|
|
303
303
|
export type Consumption = (typeof CONSUMPTION_STATES)[number];
|
|
304
304
|
|
|
305
305
|
/**
|
|
306
|
-
* The
|
|
306
|
+
* The run terminal states as a runtime array.
|
|
307
307
|
*
|
|
308
308
|
* Re-export of `RUN_TERMINAL_STATES` from `outcome_vocabularies.ts`. It used to
|
|
309
309
|
* be a second literal list here, guarded by two type-level assertions that
|
|
@@ -316,6 +316,12 @@ export type Consumption = (typeof CONSUMPTION_STATES)[number];
|
|
|
316
316
|
*
|
|
317
317
|
* The name is kept because it is exported and pinned by
|
|
318
318
|
* `tests/scripts/runtime_journal.test.ts:205,207`.
|
|
319
|
+
*
|
|
320
|
+
* Growing this list does NOT move {@link JOURNAL_SCHEMA_VERSION}: that covers
|
|
321
|
+
* tables and columns and a mismatch discards the store, while a widened value
|
|
322
|
+
* domain changes neither. The domain carries its own version in the registry
|
|
323
|
+
* (`RUN_TERMINAL_VOCABULARY_VERSION`), and the read path tolerates a value it
|
|
324
|
+
* does not know rather than trusting the column — see `toEvent`.
|
|
319
325
|
*/
|
|
320
326
|
export const TERMINAL_STATES = RUN_TERMINAL_STATES;
|
|
321
327
|
|
|
@@ -1019,7 +1025,7 @@ export function recordEvent(h: JournalHandle, input: RecordEventInput): JournalE
|
|
|
1019
1025
|
!(TERMINAL_STATES as readonly string[]).includes(input.terminal_state)
|
|
1020
1026
|
) {
|
|
1021
1027
|
throw new JournalContractError(
|
|
1022
|
-
`terminal_state ${JSON.stringify(input.terminal_state)} is not
|
|
1028
|
+
`terminal_state ${JSON.stringify(input.terminal_state)} is not in the run vocabulary.`,
|
|
1023
1029
|
);
|
|
1024
1030
|
}
|
|
1025
1031
|
if (
|
|
@@ -1105,7 +1111,8 @@ function toEvent(r: Record<string, unknown>): JournalEvent {
|
|
|
1105
1111
|
worktree_id: String(r['worktree_id']),
|
|
1106
1112
|
at: String(r['at']),
|
|
1107
1113
|
capability: String(r['capability']),
|
|
1108
|
-
|
|
1114
|
+
// Tolerant, not a cast: a value this build cannot place reads as null.
|
|
1115
|
+
terminal_state: readRunTerminalState(r['terminal_state']),
|
|
1109
1116
|
return_ref: (r['return_ref'] as string | null) ?? null,
|
|
1110
1117
|
verification_ref: (r['verification_ref'] as string | null) ?? null,
|
|
1111
1118
|
consumption: (r['consumption'] as Consumption | null) ?? null,
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Agent-artifact location guard — a roadmap-shaped file outside the roadmap root.
|
|
4
|
+
*
|
|
5
|
+
* WHAT IT PROTECTS, AND WHAT IT DOES NOT.
|
|
6
|
+
* ---------------------------------------
|
|
7
|
+
* This gate protects THIS repository, and only this one. A consumer repository
|
|
8
|
+
* never runs this repository's CI, so a gate scoped here protects the tree
|
|
9
|
+
* where the error is LEAST likely to occur — the observed failure was in a
|
|
10
|
+
* consumer project. That is not a reason to skip it, since the same mistake is
|
|
11
|
+
* possible here, but it IS a reason to write the limit down: a gate whose reach
|
|
12
|
+
* is not stated gets cited as broader than it is.
|
|
13
|
+
*
|
|
14
|
+
* The outward-facing half is `agent-config doctor --strict`, which already
|
|
15
|
+
* knows the consumer shape and can now return a non-zero exit.
|
|
16
|
+
*
|
|
17
|
+
* The constraint the detection rests on: every other mechanism that governs a
|
|
18
|
+
* roadmap is keyed on the roadmap root, so a file placed outside it is governed
|
|
19
|
+
* by nothing. This gate is the one that is keyed on the complement.
|
|
20
|
+
*
|
|
21
|
+
* WHY THE NAME TEST ALONE IS NOT THE DETECTOR.
|
|
22
|
+
* -------------------------------------------
|
|
23
|
+
* `is_roadmap_candidate` is IMPORTED from `update_roadmap_progress` rather than
|
|
24
|
+
* mirrored locally, the way `check_estate_count.ts` does it — one definition of
|
|
25
|
+
* what counts, so the roadmap corpus and this guard cannot drift apart.
|
|
26
|
+
*
|
|
27
|
+
* But that function answers "is this filename eligible to be a roadmap inside
|
|
28
|
+
* the roadmap root", and its answer over an arbitrary tree is YES for nearly
|
|
29
|
+
* every `.md` file: it excludes four names and one prefix and otherwise accepts
|
|
30
|
+
* anything. Run bare over this repository it would report thousands of
|
|
31
|
+
* legitimate documents. So the name test is a PRE-FILTER, and the decision is
|
|
32
|
+
* made by {@link roadmapShape}, which reads the file: roadmap frontmatter plus a
|
|
33
|
+
* `## Phase` heading plus at least one checkbox step. All three, because each
|
|
34
|
+
* alone is common — plenty of documents carry frontmatter, and a design doc can
|
|
35
|
+
* carry checkboxes.
|
|
36
|
+
*
|
|
37
|
+
* The finding NAMES the signals that fired, so a false positive is arguable
|
|
38
|
+
* rather than mysterious. A document that trips this gate and should not is a
|
|
39
|
+
* conversation about the signals, which is the conversation worth having.
|
|
40
|
+
*
|
|
41
|
+
* Exit codes: 0 = clean · 1 = a roadmap-shaped file sits outside the root · 3 = internal error.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import * as fs from 'node:fs';
|
|
45
|
+
import * as os from 'node:os';
|
|
46
|
+
import * as path from 'node:path';
|
|
47
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
48
|
+
|
|
49
|
+
import { is_roadmap_candidate as isRoadmapCandidate } from '../agent-src/scripts/update_roadmap_progress.js';
|
|
50
|
+
import { reportScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
51
|
+
import { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
|
|
52
|
+
|
|
53
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
54
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
55
|
+
|
|
56
|
+
/** The one legitimate home. Everything else is a finding. */
|
|
57
|
+
export const ROADMAP_ROOT_REL = 'agents/roadmaps';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Directories the walk never enters.
|
|
61
|
+
*
|
|
62
|
+
* `dist/` and the per-tool projection trees are generated copies — a finding
|
|
63
|
+
* there is a finding about `src/`, reported twice. `node_modules` and `.git`
|
|
64
|
+
* are not this repository's prose.
|
|
65
|
+
*/
|
|
66
|
+
const SKIP_DIRS: ReadonlySet<string> = new Set([
|
|
67
|
+
'.git',
|
|
68
|
+
'node_modules',
|
|
69
|
+
'dist',
|
|
70
|
+
'.augment',
|
|
71
|
+
'.claude',
|
|
72
|
+
'.cursor',
|
|
73
|
+
'.clinerules',
|
|
74
|
+
'.agents',
|
|
75
|
+
'coverage',
|
|
76
|
+
'.venv',
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
/** Frontmatter keys that only a roadmap carries in this tree. */
|
|
80
|
+
const FM_ROADMAP_RE = /^complexity:[ \t]*(lightweight|structural)[ \t]*$/m;
|
|
81
|
+
const FM_RE = /^---\n([\s\S]*?)\n---/;
|
|
82
|
+
const PHASE_RE = /^##[ \t]+Phase[ \t]/m;
|
|
83
|
+
const CHECKBOX_RE = /^[ \t]*- \[[ x~-]\][ \t]/m;
|
|
84
|
+
|
|
85
|
+
export interface Shape {
|
|
86
|
+
/** Which signals fired. Empty means not roadmap-shaped. */
|
|
87
|
+
readonly signals: readonly string[];
|
|
88
|
+
readonly isRoadmap: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Decide whether a file's CONTENT is a roadmap.
|
|
93
|
+
*
|
|
94
|
+
* All three signals are required. Each is individually common; together they are
|
|
95
|
+
* this repository's roadmap format and nothing else's.
|
|
96
|
+
*/
|
|
97
|
+
export function roadmapShape(text: string): Shape {
|
|
98
|
+
const signals: string[] = [];
|
|
99
|
+
const fm = FM_RE.exec(text);
|
|
100
|
+
if (fm !== null && FM_ROADMAP_RE.test(fm[1] as string)) {
|
|
101
|
+
signals.push('roadmap frontmatter (`complexity:` lightweight|structural)');
|
|
102
|
+
}
|
|
103
|
+
if (PHASE_RE.test(text)) {
|
|
104
|
+
signals.push('a `## Phase` heading');
|
|
105
|
+
}
|
|
106
|
+
if (CHECKBOX_RE.test(text)) {
|
|
107
|
+
signals.push('at least one checkbox step');
|
|
108
|
+
}
|
|
109
|
+
return { signals, isRoadmap: signals.length === 3 };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface Finding {
|
|
113
|
+
readonly rel: string;
|
|
114
|
+
readonly signals: readonly string[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ScanResult {
|
|
118
|
+
readonly findings: readonly Finding[];
|
|
119
|
+
/** Every `.md` file the walk visited — the scope, not the matches. */
|
|
120
|
+
readonly scanned: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function walkMarkdown(root: string): string[] {
|
|
124
|
+
const out: string[] = [];
|
|
125
|
+
const stack = [root];
|
|
126
|
+
while (stack.length > 0) {
|
|
127
|
+
const cur = stack.pop() as string;
|
|
128
|
+
let entries: fs.Dirent[];
|
|
129
|
+
try {
|
|
130
|
+
entries = fs.readdirSync(cur, { withFileTypes: true });
|
|
131
|
+
} catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
for (const e of entries) {
|
|
135
|
+
if (e.isSymbolicLink()) continue;
|
|
136
|
+
const full = path.join(cur, e.name);
|
|
137
|
+
if (e.isDirectory()) {
|
|
138
|
+
if (!SKIP_DIRS.has(e.name)) stack.push(full);
|
|
139
|
+
} else if (e.name.toLowerCase().endsWith('.md')) {
|
|
140
|
+
out.push(full);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out.sort();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `true` when `child` is at or below `root`. */
|
|
148
|
+
function isUnder(child: string, root: string): boolean {
|
|
149
|
+
const rel = path.relative(root, child);
|
|
150
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function scan(root: string): ScanResult {
|
|
154
|
+
const roadmapRoot = path.join(root, ...ROADMAP_ROOT_REL.split('/'));
|
|
155
|
+
const files = walkMarkdown(root);
|
|
156
|
+
const findings: Finding[] = [];
|
|
157
|
+
for (const f of files) {
|
|
158
|
+
if (isUnder(f, roadmapRoot)) continue;
|
|
159
|
+
// Cheap name pre-filter first — the SHARED definition, imported, so the
|
|
160
|
+
// roadmap corpus and this guard cannot drift apart.
|
|
161
|
+
if (!isRoadmapCandidate(f)) continue;
|
|
162
|
+
let text: string;
|
|
163
|
+
try {
|
|
164
|
+
text = fs.readFileSync(f, 'utf-8');
|
|
165
|
+
} catch {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const shape = roadmapShape(text);
|
|
169
|
+
if (!shape.isRoadmap) continue;
|
|
170
|
+
findings.push({
|
|
171
|
+
rel: path.relative(root, f).split(path.sep).join('/'),
|
|
172
|
+
signals: shape.signals,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return { findings, scanned: files.length };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface Args {
|
|
179
|
+
quiet: boolean;
|
|
180
|
+
selfTest: boolean;
|
|
181
|
+
root: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function parseArgs(argv: readonly string[]): Args {
|
|
185
|
+
const args: Args = { quiet: false, selfTest: false, root: REPO_ROOT };
|
|
186
|
+
for (let i = 0; i < argv.length; i++) {
|
|
187
|
+
const a = argv[i] as string;
|
|
188
|
+
if (a === '--quiet') args.quiet = true;
|
|
189
|
+
else if (a === '--self-test') args.selfTest = true;
|
|
190
|
+
else if (a === '--root') args.root = path.resolve(argv[++i] ?? '.');
|
|
191
|
+
else if (a === '-h' || a === '--help') {
|
|
192
|
+
process.stdout.write(
|
|
193
|
+
'usage: check_agent_artifact_location [-h] [--quiet] [--root DIR] [--self-test]\n',
|
|
194
|
+
);
|
|
195
|
+
process.exit(0);
|
|
196
|
+
} else {
|
|
197
|
+
process.stderr.write(
|
|
198
|
+
`check_agent_artifact_location: error: unrecognized arguments: ${a}\n`,
|
|
199
|
+
);
|
|
200
|
+
process.exit(2);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return args;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A minimal file with all three shape signals. */
|
|
207
|
+
const ROADMAP_FIXTURE =
|
|
208
|
+
'---\ncomplexity: lightweight\nstatus: ready\n---\n' +
|
|
209
|
+
'# Road to a fixture\n\n## Phase 1 - a phase\n\n- [ ] **1.1 A step.**\n';
|
|
210
|
+
|
|
211
|
+
/** Same content, but not roadmap-shaped — frontmatter only. */
|
|
212
|
+
const PROSE_FIXTURE = '---\ntitle: a design note\n---\n\n# A design note\n\n- [ ] a todo\n';
|
|
213
|
+
|
|
214
|
+
function selfTestCases(): SelfTestCase[] {
|
|
215
|
+
const mk = (rel: string, body: string): string => {
|
|
216
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'artloc-'));
|
|
217
|
+
const full = path.join(dir, ...rel.split('/'));
|
|
218
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
219
|
+
fs.writeFileSync(full, body);
|
|
220
|
+
return dir;
|
|
221
|
+
};
|
|
222
|
+
const run = (dir: string): number =>
|
|
223
|
+
runGateCli(
|
|
224
|
+
REPO_ROOT,
|
|
225
|
+
'src/scripts/check_agent_artifact_location.ts',
|
|
226
|
+
['--root', dir, '--quiet'],
|
|
227
|
+
REPO_ROOT,
|
|
228
|
+
);
|
|
229
|
+
return [
|
|
230
|
+
{
|
|
231
|
+
name: 'a roadmap-shaped file under docs/roadmaps/ is reported',
|
|
232
|
+
expect: 'reject',
|
|
233
|
+
run: () => run(mk('docs/roadmaps/road-to-a-fixture.md', ROADMAP_FIXTURE)),
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
name: 'a roadmap-shaped file at the tree root is reported',
|
|
237
|
+
expect: 'reject',
|
|
238
|
+
run: () => run(mk('road-to-a-fixture.md', ROADMAP_FIXTURE)),
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: 'the SAME file under agents/roadmaps/ is not reported',
|
|
242
|
+
expect: 'accept',
|
|
243
|
+
run: () => run(mk('agents/roadmaps/road-to-a-fixture.md', ROADMAP_FIXTURE)),
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
name: 'a file under agents/roadmaps/later/ is not reported',
|
|
247
|
+
expect: 'accept',
|
|
248
|
+
run: () => run(mk('agents/roadmaps/later/road-to-a-fixture.md', ROADMAP_FIXTURE)),
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: 'prose carrying frontmatter and a checkbox but no phase is not reported',
|
|
252
|
+
expect: 'accept',
|
|
253
|
+
run: () => run(mk('docs/a-design-note.md', PROSE_FIXTURE)),
|
|
254
|
+
},
|
|
255
|
+
];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function main(argv?: readonly string[]): number {
|
|
259
|
+
const args = parseArgs(argv ?? process.argv.slice(2));
|
|
260
|
+
|
|
261
|
+
if (args.selfTest) {
|
|
262
|
+
return runSelfTest({
|
|
263
|
+
gate: 'check_agent_artifact_location',
|
|
264
|
+
cases: selfTestCases(),
|
|
265
|
+
minCases: 5,
|
|
266
|
+
minRejectCases: 2,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let result: ScanResult;
|
|
271
|
+
try {
|
|
272
|
+
result = scan(args.root);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
275
|
+
process.stderr.write(`❌ internal error: ${msg}\n`);
|
|
276
|
+
return 3;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// The unit is every `.md` the walk VISITED, not the matches: zero matches is
|
|
280
|
+
// the normal clean state, so only the unfiltered walk can tell "nothing
|
|
281
|
+
// misplaced" from "nothing read".
|
|
282
|
+
try {
|
|
283
|
+
reportScanned({
|
|
284
|
+
gate: 'check_agent_artifact_location',
|
|
285
|
+
scanned: result.scanned,
|
|
286
|
+
units: 'markdown file(s)',
|
|
287
|
+
roots: [path.relative(REPO_ROOT, args.root) || '.'],
|
|
288
|
+
});
|
|
289
|
+
} catch (exc) {
|
|
290
|
+
if (exc instanceof DeadScopeError) {
|
|
291
|
+
process.stderr.write(`❌ ${exc.message}\n`);
|
|
292
|
+
return 1;
|
|
293
|
+
}
|
|
294
|
+
throw exc;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (result.findings.length === 0) {
|
|
298
|
+
if (!args.quiet) {
|
|
299
|
+
process.stdout.write(
|
|
300
|
+
`✅ check_agent_artifact_location: no roadmap-shaped file outside ` +
|
|
301
|
+
`${ROADMAP_ROOT_REL}/ (${String(result.scanned)} markdown file(s) read).\n`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
process.stderr.write(
|
|
308
|
+
`❌ check_agent_artifact_location: ${String(result.findings.length)} ` +
|
|
309
|
+
`roadmap-shaped file(s) outside ${ROADMAP_ROOT_REL}/:\n`,
|
|
310
|
+
);
|
|
311
|
+
for (const f of result.findings) {
|
|
312
|
+
process.stderr.write(` ${f.rel}\n`);
|
|
313
|
+
process.stderr.write(` reads as a roadmap because it carries ${f.signals.join(', ')}\n`);
|
|
314
|
+
}
|
|
315
|
+
process.stderr.write(
|
|
316
|
+
` Move it to ${ROADMAP_ROOT_REL}/, or — if it is genuinely not a roadmap — ` +
|
|
317
|
+
'say which of the three signals it should not be carrying.\n' +
|
|
318
|
+
' NOTE: this gate scans THIS repository only. A consumer project never runs\n' +
|
|
319
|
+
' it; `agent-config doctor` is the surface that carries a finding outward.\n',
|
|
320
|
+
);
|
|
321
|
+
return 1;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function _isCliEntry(): boolean {
|
|
325
|
+
if (process.argv[1] === undefined) return false;
|
|
326
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
327
|
+
if (import.meta.url === argvUrl) return true;
|
|
328
|
+
try {
|
|
329
|
+
return fs.realpathSync(_HERE) === fs.realpathSync(path.resolve(process.argv[1]));
|
|
330
|
+
} catch {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (_isCliEntry()) {
|
|
336
|
+
process.exit(main());
|
|
337
|
+
}
|