@event4u/agent-config 14.5.0 → 14.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +384 -3
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/decision.md +7 -0
- package/dist/agent-src/commands/analyze/inbox.md +264 -10
- package/dist/agent-src/commands/analyze.md +1 -1
- package/dist/agent-src/commands/optimize/project.md +10 -0
- package/dist/agent-src/commands/roadmap/process-full.md +132 -4
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +31 -0
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +161 -1
- package/dist/agent-src/rules/context-hygiene.md +2 -0
- package/dist/agent-src/rules/decision-revisit-gate.md +111 -7
- package/dist/agent-src/rules/fix-what-you-see.md +108 -0
- package/dist/agent-src/rules/no-roadmap-references.md +0 -5
- package/dist/agent-src/rules/recurring-criticism.md +110 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +1 -1
- package/dist/agent-src/rules/rule-type-governance.md +0 -2
- package/dist/agent-src/rules/self-repair-loop.md +17 -0
- package/dist/agent-src/rules/skill-quality.md +0 -2
- package/dist/agent-src/rules/source-confidentiality.md +37 -7
- package/dist/agent-src/scripts/archival_sweep.ts +117 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
- package/dist/agent-src/scripts/gate_budget.ts +282 -0
- package/dist/agent-src/scripts/gate_execute.ts +72 -31
- package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
- package/dist/agent-src/scripts/update_roadmap_progress.ts +251 -7
- package/dist/agent-src/skills/adr-create/SKILL.md +25 -3
- package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
- package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
- package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
- package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
- package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
- package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
- package/dist/agent-src/skills/decision-review/SKILL.md +18 -0
- package/dist/agent-src/skills/quality-tools/SKILL.md +34 -326
- package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
- package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
- package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
- package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
- package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
- package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
- package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -0
- package/dist/agent-src/skills/skill-writing/SKILL.md +34 -570
- package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
- package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
- package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +45 -2
- package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
- package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
- package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
- package/dist/cli/registry.js +1 -1
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
- package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-4VR4ORQ6.js} +10 -7
- package/dist/cli-delegate/chunk-6EOCNQAM.js +402 -0
- package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-74E3TKWV.js} +381 -280
- package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-7F3VYSM2.js} +4 -4
- package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
- package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
- package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-GJ5BVHXM.js} +72 -165
- package/dist/cli-delegate/{chunk-ZPSXJTLI.js → chunk-JZ4NR2TW.js} +353 -1
- package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
- package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
- package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
- package/dist/cli-delegate/cmd_conformance.js +459 -709
- package/dist/cli-delegate/cmd_doctor.js +6 -6
- package/dist/cli-delegate/cmd_explain.js +1 -1
- package/dist/cli-delegate/cmd_export.js +7 -6
- package/dist/cli-delegate/cmd_handoff.js +6 -6
- package/dist/cli-delegate/cmd_packs_active.js +1 -1
- package/dist/cli-delegate/cmd_preflight.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +7 -6
- package/dist/cli-delegate/cmd_session_recycle.js +840 -30
- package/dist/cli-delegate/cmd_settings_migrate.js +7 -6
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +7 -6
- package/dist/cli-delegate/cmd_uninstall.js +9 -7
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +7 -6
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +72 -29
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +10 -8
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +8 -6
- package/dist/hooks/dispatch.js +3127 -2022
- package/dist/install/claudePathsPlan.js +187 -0
- package/dist/install/claudePathsPlan.js.map +1 -0
- package/dist/install/hostLayerFingerprint.js +136 -0
- package/dist/install/hostLayerFingerprint.js.map +1 -0
- package/dist/install/install.mjs +553 -425
- package/dist/install/partitionEligibility.js +276 -0
- package/dist/install/partitionEligibility.js.map +1 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1744 -1191
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_src.js +41 -0
- package/dist/scripts/_lib/agent_src.js.map +1 -1
- package/dist/scripts/_lib/install_layout.js +83 -0
- package/dist/scripts/_lib/install_layout.js.map +1 -0
- package/dist/scripts/_lib/installed_lock.js +444 -0
- package/dist/scripts/_lib/installed_lock.js.map +1 -0
- package/dist/scripts/condense.js +13 -164
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +4 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +12 -6
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
- package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
- package/docs/guidelines/agent-infra/engineering-memory-data-format.md +49 -0
- package/docs/guidelines/agent-infra/memory-access.md +17 -0
- package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
- package/package.json +1 -1
- package/src/agent-src/scripts/archival_sweep.ts +117 -0
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
- package/src/agent-src/scripts/gate_budget.ts +282 -0
- package/src/agent-src/scripts/gate_execute.ts +72 -31
- package/src/agent-src/scripts/roadmap_gates.ts +74 -3
- package/src/agent-src/scripts/update_roadmap_progress.ts +251 -7
- package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
- package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
- package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
- package/src/config/agent-settings.template.yml +27 -2
- package/src/config/ci-local-parity.yml +18 -0
- package/src/config/estate-count-budget.json +367 -3
- package/src/config/gate-violation-baselines.json +41 -41
- package/src/config/pack-size-budget.json +3 -2
- package/src/config/rule-activation-census.json +14 -6
- package/src/config/rule-stub-ceilings.json +19 -6
- package/src/install/claudePathsPlan.ts +196 -0
- package/src/install/hostLayerFingerprint.ts +145 -0
- package/src/install/partitionEligibility.ts +325 -0
- package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
- package/src/scripts/_dispatch.bash +6 -1
- package/src/scripts/_lib/ac_heading.ts +30 -0
- package/src/scripts/_lib/agent_src.ts +42 -0
- package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
- package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
- package/src/scripts/_lib/envelope_grounding.ts +38 -8
- package/src/scripts/_lib/installed_lock.ts +50 -5
- package/src/scripts/_lib/layer_overlap_notice.ts +85 -0
- package/src/scripts/_lib/md_section.ts +129 -0
- package/src/scripts/_lib/scope_guard.sh +86 -4
- package/src/scripts/_lib/self_repair.ts +19 -0
- package/src/scripts/_lib/self_repair_class_b.ts +171 -0
- package/src/scripts/_lib/subagent_capsule.ts +81 -1
- package/src/scripts/adr/regenerate_index.ts +13 -2
- package/src/scripts/adr_cite_check.ts +424 -0
- package/src/scripts/ai_council/cli_help.ts +55 -0
- package/src/scripts/before_complete_hook.ts +153 -28
- package/src/scripts/bench_ab_v2_complexity.ts +27 -7
- package/src/scripts/check_adr_frontmatter.ts +187 -1
- package/src/scripts/check_references.ts +60 -0
- package/src/scripts/check_release_published.ts +32 -11
- package/src/scripts/check_single_delivery.ts +517 -0
- package/src/scripts/ci_settle.ts +170 -0
- package/src/scripts/condense.ts +20 -170
- package/src/scripts/council_cli.ts +3 -3
- package/src/scripts/dispatch_r2_reviewer.ts +6 -14
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +77 -14
- package/src/scripts/hooks/block_no_verify.ts +61 -10
- package/src/scripts/hooks/block_unauthorized_git.ts +1 -1
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/rtk_wrap_hook.ts +8 -3
- package/src/scripts/hooks/run_continuation_hook.ts +887 -68
- package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
- package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
- package/src/scripts/hooks/state_io.ts +782 -20
- package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
- package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
- package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
- package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
- package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
- package/src/scripts/install.sh +12 -2
- package/src/scripts/install.ts +16 -11
- package/src/scripts/language_mirror_hook.ts +561 -25
- package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
- package/src/scripts/lint_plan_risk_register.ts +4 -3
- package/src/scripts/lint_skill_router_head.ts +4 -4
- package/src/scripts/mcp_server/tools.ts +2 -2
- package/src/scripts/memory_consolidation.ts +189 -0
- package/src/scripts/memory_eviction.ts +384 -0
- package/src/scripts/new_roadmap.ts +136 -0
- package/src/scripts/pack_mcp_content.ts +20 -5
- package/src/scripts/release.ts +37 -38
- package/src/scripts/report_layer_overlap.ts +78 -0
- package/src/scripts/report_memory_pointers.ts +622 -0
- package/src/scripts/self_repair_hook.ts +37 -5
- package/src/scripts/session_register_hook.ts +115 -15
- package/src/scripts/skill_usage_collect.ts +11 -1
- package/src/scripts/skill_usage_report.ts +278 -8
- package/src/scripts/sync_pr_branch.ts +7 -0
- package/src/server/schemas/settings.ts +8 -0
- package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
- package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
- /package/dist/cli-delegate/{chunk-RYNADUFG.js → chunk-LWTYTX6F.js} +0 -0
|
@@ -29,7 +29,14 @@
|
|
|
29
29
|
* - post_tool_use → inspect tool + command, record verifications
|
|
30
30
|
* - stop → record stop fired (claim-done window)
|
|
31
31
|
*
|
|
32
|
-
* Output: `agents/state/verify-before-complete
|
|
32
|
+
* Output: `agents/state/verify-before-complete/<sha256(session_id)>.json`
|
|
33
|
+
* — ONE FILE PER SESSION. It was one file per project root until 2026-08-20,
|
|
34
|
+
* which under this repo's worktree workflow (`CLAUDE_PROJECT_DIR` resolves to
|
|
35
|
+
* the PARENT checkout) meant one file across every concurrent run: a
|
|
36
|
+
* neighbour's CI witness and verification counters became this run's, and the
|
|
37
|
+
* in-file session-boundary reset in `_update` turned into the damage rather
|
|
38
|
+
* than the defense, because two live runs then clear each other in a loop.
|
|
39
|
+
* Consumers address it via `statePathFor`, never a path literal.
|
|
33
40
|
* {
|
|
34
41
|
* "schema_version": 1,
|
|
35
42
|
* "session_id": "<str>",
|
|
@@ -49,14 +56,79 @@ import fs from "node:fs";
|
|
|
49
56
|
import path from "node:path";
|
|
50
57
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
51
58
|
|
|
52
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
has_stable_session_id,
|
|
61
|
+
prune_legacy_state_file,
|
|
62
|
+
prune_stale_session_states,
|
|
63
|
+
session_state_file,
|
|
64
|
+
update_json_under_lock,
|
|
65
|
+
} from "./hooks/state_io.js";
|
|
53
66
|
import { readHookStdin } from "./hooks/hook_stdin.js";
|
|
54
67
|
|
|
55
68
|
// NOTE: the Python docstring says `agents/runtime/state/`, but the code
|
|
56
69
|
// constant is `agents/state/`. Replicated verbatim — latent docstring/code
|
|
57
70
|
// divergence in the retired Python implementation (ADR-200 § replicate latent bugs).
|
|
71
|
+
//
|
|
72
|
+
// PRE-SPLIT PATH. Nothing reads it after the per-session split below;
|
|
73
|
+
// `prune_legacy_state_file` removes it once this version owns the tree. Kept
|
|
74
|
+
// exported because `prune_legacy_state_file` needs to name the path it deletes,
|
|
75
|
+
// and because a reader that still resolves it should get a compile-visible
|
|
76
|
+
// symbol rather than a silently stale path literal. It is NOT kept for an
|
|
77
|
+
// "older bundle still writing during an upgrade" — that phrasing was copied
|
|
78
|
+
// here unchecked and is corrected at `state_io.prune_legacy_state_file`, which
|
|
79
|
+
// carries the measured deployment shape and the one narrow window that remains.
|
|
58
80
|
export const STATE_FILE = path.join("agents", "state", "verify-before-complete.json");
|
|
59
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Per-SESSION state, one file each.
|
|
84
|
+
*
|
|
85
|
+
* WHY, in one sentence: the single file above is shared by every concurrent
|
|
86
|
+
* session under one project root — which in this repo's worktree workflow is
|
|
87
|
+
* the PARENT checkout — so a neighbouring run's CI witness and verification
|
|
88
|
+
* counters became this run's.
|
|
89
|
+
*
|
|
90
|
+
* The in-file session-boundary reset in `_update` is NOT the fix and is the
|
|
91
|
+
* reason this was easy to miss. It is written for SEQUENTIAL sessions: notice a
|
|
92
|
+
* foreign `session_id`, clear the session-scoped counters, carry on. Under
|
|
93
|
+
* CONCURRENT sessions that same code is the damage — each run reads the other's
|
|
94
|
+
* id, resets, and writes, so two sessions erase each other's verification
|
|
95
|
+
* evidence in a loop. The direction of the loss is toward FORGETTING a
|
|
96
|
+
* verification that did happen, which then reads as "not verified this turn".
|
|
97
|
+
*
|
|
98
|
+
* The reset stays, but NOT for the reasons this comment first gave. It said the
|
|
99
|
+
* reset "still covers the id-less bucket and a legacy file", and a cross-model
|
|
100
|
+
* review (2026-08-20, both seats) showed neither is reachable: an id-less
|
|
101
|
+
* envelope returns from `run()` before `_update` is ever called, and the legacy
|
|
102
|
+
* file is never LOADED any more — only deleted. Writing an unreachable
|
|
103
|
+
* justification next to retained code is how dead logic survives review, so the
|
|
104
|
+
* real one is stated instead.
|
|
105
|
+
*
|
|
106
|
+
* Its one reachable case is a file at THIS session's digest path carrying
|
|
107
|
+
* somebody else's `session_id` — a copy, a restore, a hand-edit, or a buggy
|
|
108
|
+
* writer. That is integrity recovery on the producer side, and it pairs with the
|
|
109
|
+
* consumer side refusing the same file outright (`owns_session_state`). Both
|
|
110
|
+
* halves are needed: the producer cannot refuse to run, and the consumer cannot
|
|
111
|
+
* repair. `before_complete_session_isolation.test.ts` reaches it directly.
|
|
112
|
+
*
|
|
113
|
+
* Rationale, the digest-not-sanitiser property, and the claim-then-revalidate
|
|
114
|
+
* prune all live once in `hooks/state_io.ts` § Per-session concern state — this
|
|
115
|
+
* is the second concern to need them, which is what made sharing them right.
|
|
116
|
+
*/
|
|
117
|
+
export const STATE_DIR = path.join("agents", "state", "verify-before-complete");
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Days after which an untouched session's state is pruned.
|
|
121
|
+
*
|
|
122
|
+
* Matches the language hook and the council session-artefact window — a
|
|
123
|
+
* convention match, not a measurement, and stated as such.
|
|
124
|
+
*/
|
|
125
|
+
export const STATE_RETENTION_DAYS = 7;
|
|
126
|
+
|
|
127
|
+
/** Path of one session's state file. */
|
|
128
|
+
export function statePathFor(session_id: string): string {
|
|
129
|
+
return session_state_file(STATE_DIR, session_id);
|
|
130
|
+
}
|
|
131
|
+
|
|
60
132
|
// Tool names across platforms whose `command` / `tool_input.command` field
|
|
61
133
|
// carries a shell command we can inspect. Edit tools are deliberately
|
|
62
134
|
// excluded — they cannot run verification.
|
|
@@ -125,26 +197,6 @@ function _empty_state(): StateDict {
|
|
|
125
197
|
};
|
|
126
198
|
}
|
|
127
199
|
|
|
128
|
-
function _load_state(target: string): StateDict {
|
|
129
|
-
let isFile = false;
|
|
130
|
-
try {
|
|
131
|
-
isFile = fs.statSync(target).isFile();
|
|
132
|
-
} catch {
|
|
133
|
-
isFile = false;
|
|
134
|
-
}
|
|
135
|
-
if (!isFile) {
|
|
136
|
-
return _empty_state();
|
|
137
|
-
}
|
|
138
|
-
try {
|
|
139
|
-
const decoded = JSON.parse(fs.readFileSync(target, "utf-8")) as unknown;
|
|
140
|
-
if (typeof decoded === "object" && decoded !== null && !Array.isArray(decoded)) {
|
|
141
|
-
return { ..._empty_state(), ...(decoded as StateDict) };
|
|
142
|
-
}
|
|
143
|
-
} catch {
|
|
144
|
-
// fall through
|
|
145
|
-
}
|
|
146
|
-
return _empty_state();
|
|
147
|
-
}
|
|
148
200
|
|
|
149
201
|
/** Return [tool_name, command_text] from a tool-event payload. */
|
|
150
202
|
function _extract_command(payload: StateDict): [string | null, string | null] {
|
|
@@ -424,19 +476,92 @@ export function run(
|
|
|
424
476
|
}
|
|
425
477
|
|
|
426
478
|
const event = (envelope["event"] || "") as string;
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
479
|
+
const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
|
|
480
|
+
|
|
481
|
+
// No stable identity → persist NOTHING. Sanitising an empty id into a shared
|
|
482
|
+
// literal is the original cross-session defect restored in the one case with
|
|
483
|
+
// no guard left (see `state_io` § has_stable_session_id).
|
|
484
|
+
//
|
|
485
|
+
// What that costs, and it is worse than the word this comment first used: on a
|
|
486
|
+
// host that sends no `session_id` this concern records no evidence at all, so
|
|
487
|
+
// `readCiSettled` returns "nothing observed" and the turn-end gate's completion
|
|
488
|
+
// detector never fires. The first version called that "the SAFE direction —
|
|
489
|
+
// under-refusing". A cross-model review (2026-08-20, both seats) rejected the
|
|
490
|
+
// word: for a BLOCKING gate, not refusing is fail-OPEN. A premature completion
|
|
491
|
+
// claim over unsettled CI passes unchallenged. This is DEGRADED ENFORCEMENT, and
|
|
492
|
+
// naming it "safe" hides the loss behind a reassuring adjective.
|
|
493
|
+
//
|
|
494
|
+
// It is still the right call among the options available, which is a different
|
|
495
|
+
// claim and the only one the evidence supports: the alternative is one shared
|
|
496
|
+
// file whose contents belong to whichever concurrent run wrote last, i.e. a
|
|
497
|
+
// gate that refuses or clears on somebody else's evidence. A gate that goes
|
|
498
|
+
// quiet is recoverable; a gate that acts on a foreign witness is not.
|
|
499
|
+
// Every host this suite binds `post_tool_use` on sends a `session_id`
|
|
500
|
+
// (`hook_manifest.yaml` platforms × `native_event_aliases`), so the degraded
|
|
501
|
+
// path is a fallback rather than a supported mode — but it is not verified per
|
|
502
|
+
// host, and this comment does not claim it is.
|
|
503
|
+
if (!has_stable_session_id(session_id)) {
|
|
504
|
+
if (verbose) {
|
|
505
|
+
process.stderr.write(
|
|
506
|
+
"verify-before-complete-hook: no session_id — running stateless, nothing recorded\n",
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
return 0;
|
|
510
|
+
}
|
|
430
511
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
512
|
+
const target = path.join(consumer_root, statePathFor(session_id));
|
|
513
|
+
|
|
514
|
+
// LOAD → UPDATE → PUBLISH under ONE lock, not three separate steps.
|
|
515
|
+
//
|
|
516
|
+
// This was `_load_state` / `_update` / `atomic_write_json`, which makes the
|
|
517
|
+
// publish atomic and leaves the transaction racy. A cross-model review
|
|
518
|
+
// (2026-08-20, both seats) named the interleaving, and this host runs tool
|
|
519
|
+
// calls in parallel, so it is reachable rather than theoretical: two
|
|
520
|
+
// `post_tool_use` invocations for the SAME session both load the counter at
|
|
521
|
+
// N, both compute N+1, both publish N+1, and one verification is lost. The
|
|
522
|
+
// per-session split does nothing about it — the two failures are independent,
|
|
523
|
+
// and closing the cross-session one made this one easier to mistake for
|
|
524
|
+
// solved.
|
|
525
|
+
//
|
|
526
|
+
// `state` is captured for the verbose line below; the value that lands is the
|
|
527
|
+
// one computed inside the lock, from state read inside the lock.
|
|
528
|
+
let state: StateDict = {};
|
|
529
|
+
// Three-state result (state_io § Three states, not two). This mutator NEVER
|
|
530
|
+
// returns null, so `skipped` is unreachable here — but it is handled with
|
|
531
|
+
// `written` rather than lumped with `failed`, because the two have opposite
|
|
532
|
+
// meanings for this caller: a decline means the state needed no change, a
|
|
533
|
+
// failure means the state on disk is not what this run computed. Writing
|
|
534
|
+
// `!== "written"` would have made a future mutator that learns to decline
|
|
535
|
+
// silently stop recording, which is the failure this API change exists to
|
|
536
|
+
// make impossible to write by accident.
|
|
537
|
+
const outcome = update_json_under_lock<StateDict>(target, (loaded) => {
|
|
538
|
+
// `_empty_state()` UNDER the loaded value, which is what the `_load_state`
|
|
539
|
+
// this replaced did (`{ ..._empty_state(), ...decoded }`). Dropping it would
|
|
540
|
+
// have been a silent regression that the suite could not see: `_asInt`
|
|
541
|
+
// treats a missing counter as 0 and the `=== true` guards treat a missing
|
|
542
|
+
// flag as false, so every assertion would still pass while `schema_version`
|
|
543
|
+
// and an explicit `ci_last: null` vanished from a freshly created file.
|
|
544
|
+
state = _update({ ..._empty_state(), ...loaded } as StateDict, event, envelope);
|
|
545
|
+
return state;
|
|
546
|
+
});
|
|
547
|
+
if (outcome === "failed") {
|
|
434
548
|
if (verbose) {
|
|
435
549
|
process.stderr.write("verify-before-complete-hook: state write failed\n");
|
|
436
550
|
}
|
|
437
551
|
return 0;
|
|
438
552
|
}
|
|
439
553
|
|
|
554
|
+
// Housekeeping on the once-per-turn events only — never on `post_tool_use`,
|
|
555
|
+
// which fires many times per turn, and never before the write above.
|
|
556
|
+
if (event === "session_start" || event === "user_prompt_submit") {
|
|
557
|
+
prune_legacy_state_file(path.join(consumer_root, STATE_FILE));
|
|
558
|
+
prune_stale_session_states(
|
|
559
|
+
path.join(consumer_root, STATE_DIR),
|
|
560
|
+
Date.now(),
|
|
561
|
+
STATE_RETENTION_DAYS,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
440
565
|
if (verbose) {
|
|
441
566
|
process.stderr.write(
|
|
442
567
|
`verify-before-complete-hook: event=${event} ` +
|
|
@@ -35,6 +35,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
35
35
|
|
|
36
36
|
import { parse as parseYaml } from 'yaml';
|
|
37
37
|
|
|
38
|
+
import { PinnedRepoError, pristine_tree_for } from './_lib/bench_ab_pinned_repo.js';
|
|
38
39
|
import { medianComplexityPerChangedFunction } from './_lib/bench_ab_complexity.js';
|
|
39
40
|
import { changed_files, diff_line_counts } from './_lib/bench_ab_scoring_v2.js';
|
|
40
41
|
|
|
@@ -63,15 +64,15 @@ function _dictOr(v: unknown): Dict {
|
|
|
63
64
|
* trial to `skipped_reason: 'task id not in the corpus'`, which is a reportable
|
|
64
65
|
* state, rather than aborting a re-score that may still cover most of the report.
|
|
65
66
|
*/
|
|
66
|
-
function loadCorpusFixtures(corpusPath: string | null): Map<string,
|
|
67
|
-
const byId = new Map<string,
|
|
67
|
+
function loadCorpusFixtures(corpusPath: string | null): Map<string, Dict> {
|
|
68
|
+
const byId = new Map<string, Dict>();
|
|
68
69
|
if (!corpusPath || !fs.existsSync(corpusPath)) return byId;
|
|
69
70
|
try {
|
|
70
71
|
const corpus = _dictOr(parseYaml(fs.readFileSync(corpusPath, 'utf8'), { version: '1.1' }));
|
|
71
72
|
const tasks = Array.isArray(corpus['tasks']) ? (corpus['tasks'] as Dict[]) : [];
|
|
72
73
|
for (const t of tasks) {
|
|
73
|
-
if (t['id'] !== undefined && t['fixture'] !== undefined) {
|
|
74
|
-
byId.set(String(t['id']),
|
|
74
|
+
if (t['id'] !== undefined && (t['fixture'] !== undefined || t['repo'] !== undefined)) {
|
|
75
|
+
byId.set(String(t['id']), t);
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
} catch {
|
|
@@ -109,7 +110,7 @@ export async function rescoreReport(
|
|
|
109
110
|
|
|
110
111
|
for (const rec of records) {
|
|
111
112
|
const taskId = String(rec['id'] ?? '<unknown>');
|
|
112
|
-
const
|
|
113
|
+
const corpusTask = byId.get(taskId) ?? null;
|
|
113
114
|
const arms = _dictOr(rec['arms']);
|
|
114
115
|
for (const [arm, runsRaw] of Object.entries(arms)) {
|
|
115
116
|
const runs = Array.isArray(runsRaw) ? (runsRaw as Dict[]) : [];
|
|
@@ -134,12 +135,31 @@ export async function rescoreReport(
|
|
|
134
135
|
out.push(row);
|
|
135
136
|
continue;
|
|
136
137
|
}
|
|
137
|
-
if (
|
|
138
|
+
if (corpusTask === null) {
|
|
138
139
|
row.skipped_reason = 'task id not in the corpus';
|
|
139
140
|
out.push(row);
|
|
140
141
|
continue;
|
|
141
142
|
}
|
|
142
|
-
|
|
143
|
+
// Delta #9: a pinned task's pristine tree is a local cache entry, and
|
|
144
|
+
// this re-scorer is `offlineOnly` on purpose — a cold cache reports
|
|
145
|
+
// "not materialised" rather than fetching a repository during what is
|
|
146
|
+
// meant to be an offline re-score of a finished sweep. A malformed pin
|
|
147
|
+
// is reported per trial, never thrown: one bad corpus entry must not
|
|
148
|
+
// abort a re-score that still covers every other trial.
|
|
149
|
+
let fixture: string | null;
|
|
150
|
+
try {
|
|
151
|
+
fixture = pristine_tree_for(corpusTask, { fixturesRoot, offlineOnly: true });
|
|
152
|
+
} catch (err) {
|
|
153
|
+
row.skipped_reason =
|
|
154
|
+
err instanceof PinnedRepoError ? `invalid pin: ${err.message}` : 'invalid corpus task';
|
|
155
|
+
out.push(row);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (fixture === null) {
|
|
159
|
+
row.skipped_reason = 'pinned repo not materialised locally';
|
|
160
|
+
out.push(row);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
143
163
|
if (!fs.existsSync(fixture)) {
|
|
144
164
|
row.skipped_reason = 'fixture missing on disk';
|
|
145
165
|
out.push(row);
|
|
@@ -51,6 +51,92 @@ export const REVIEW_TRIGGER_SINCE = '2026-07-25';
|
|
|
51
51
|
/** Fields every ADR must carry — the shape that already exists in practice. */
|
|
52
52
|
const REQUIRED = ['adr', 'status', 'date', 'decision'] as const;
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Reopen-authority vocabulary (`adr-layout.md` § Reopen authority).
|
|
56
|
+
*
|
|
57
|
+
* Both fields are OPTIONAL and stay optional. An absent `reopen_policy`
|
|
58
|
+
* resolves to `unclassified` at the reader, never to `owner`: with 146 accepted
|
|
59
|
+
* ADRs a fail-closed default would encode the existing blockage into the new
|
|
60
|
+
* schema, which is the one thing both council seats (2026-08-19) rejected in
|
|
61
|
+
* the same words. This validator therefore checks the VALUE when present and
|
|
62
|
+
* never requires the KEY.
|
|
63
|
+
*/
|
|
64
|
+
const ALLOWED_REOPEN_POLICY = new Set(['directional', 'owner', 'unclassified']);
|
|
65
|
+
|
|
66
|
+
const ALLOWED_PROTECTED_DIMENSIONS = new Set([
|
|
67
|
+
'purpose',
|
|
68
|
+
'security_floor',
|
|
69
|
+
'privacy_floor',
|
|
70
|
+
'external_commitment',
|
|
71
|
+
'governance',
|
|
72
|
+
'none',
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/** Split an inline `[a, b]` list or a folded multi-value string into members. */
|
|
76
|
+
export function split_dimensions(raw: string): string[] {
|
|
77
|
+
return raw
|
|
78
|
+
.replace(/^\[/, '')
|
|
79
|
+
.replace(/\]$/, '')
|
|
80
|
+
.split(/[,\s]+/)
|
|
81
|
+
.map((s) =>
|
|
82
|
+
s
|
|
83
|
+
.trim()
|
|
84
|
+
.replace(/^-\s*/, '')
|
|
85
|
+
.replace(/^["'](.*)["']$/, '$1'),
|
|
86
|
+
)
|
|
87
|
+
.filter((s) => s !== '');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate the two optional reopen-authority fields — value only, never
|
|
92
|
+
* presence. A wrong enum is a real error (it silently mis-routes authority);
|
|
93
|
+
* an absent field is the documented default and not a finding.
|
|
94
|
+
*/
|
|
95
|
+
export function check_reopen_authority(
|
|
96
|
+
rel: string,
|
|
97
|
+
fm: Record<string, string>,
|
|
98
|
+
findings: AdrFinding[],
|
|
99
|
+
): void {
|
|
100
|
+
const policy = fm['reopen_policy'];
|
|
101
|
+
if (policy !== undefined && policy !== '' && !ALLOWED_REOPEN_POLICY.has(policy)) {
|
|
102
|
+
findings.push({
|
|
103
|
+
file: rel,
|
|
104
|
+
level: 'error',
|
|
105
|
+
message:
|
|
106
|
+
`\`reopen_policy\` \`${policy}\` is not one of: ` +
|
|
107
|
+
`${[...ALLOWED_REOPEN_POLICY].sort().join(', ')} (absent = unclassified)`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const dims = fm['protected_dimensions'];
|
|
112
|
+
if (dims !== undefined && dims !== '') {
|
|
113
|
+
for (const d of split_dimensions(dims)) {
|
|
114
|
+
if (!ALLOWED_PROTECTED_DIMENSIONS.has(d)) {
|
|
115
|
+
findings.push({
|
|
116
|
+
file: rel,
|
|
117
|
+
level: 'error',
|
|
118
|
+
message:
|
|
119
|
+
`\`protected_dimensions\` member \`${d}\` is not one of: ` +
|
|
120
|
+
`${[...ALLOWED_PROTECTED_DIMENSIONS].sort().join(', ')}`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// `owner` without a named dimension is the frozen-over-classification shape
|
|
127
|
+
// the contract warns about: it reserves every future transition, including
|
|
128
|
+
// the ones that only strengthen the decision, and records no reason.
|
|
129
|
+
if (policy === 'owner' && (dims === undefined || dims === '')) {
|
|
130
|
+
findings.push({
|
|
131
|
+
file: rel,
|
|
132
|
+
level: 'warn',
|
|
133
|
+
message:
|
|
134
|
+
'`reopen_policy: owner` without `protected_dimensions` — name the reserved ' +
|
|
135
|
+
'interest, or the reservation cannot be checked against a proposed transition',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
54
140
|
const ALLOWED_STATUS = new Set([
|
|
55
141
|
'accepted',
|
|
56
142
|
'proposed',
|
|
@@ -141,6 +227,98 @@ export function check_one(rel: string, text: string): AdrFinding[] {
|
|
|
141
227
|
message: `\`review_trigger\` is a cadence, not a condition: "${trigger}". A calendar review is ignored; an event fires.`,
|
|
142
228
|
});
|
|
143
229
|
}
|
|
230
|
+
check_reopen_authority(rel, fm, findings);
|
|
231
|
+
check_amendment_shape(rel, fm, findings);
|
|
232
|
+
return findings;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** `ADR-035`, `035`, `35` → `35`. Returns null when nothing resolves. */
|
|
236
|
+
export function adr_number(raw: string): string | null {
|
|
237
|
+
const m = /(\d{1,4})/.exec(raw.trim());
|
|
238
|
+
return m?.[1] === undefined ? null : String(Number(m[1]));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Validate `amends:` / `amended_by:` shape. The reciprocal half is checked in
|
|
243
|
+
* `check_amendment_links` (it needs the whole corpus); this one only rejects a
|
|
244
|
+
* value that is neither the em-dash placeholder nor an ADR reference, so a
|
|
245
|
+
* typo'd link is caught before it becomes an invisible one-sided amendment.
|
|
246
|
+
*/
|
|
247
|
+
export function check_amendment_shape(
|
|
248
|
+
rel: string,
|
|
249
|
+
fm: Record<string, string>,
|
|
250
|
+
findings: AdrFinding[],
|
|
251
|
+
): void {
|
|
252
|
+
for (const key of ['amends', 'amended_by'] as const) {
|
|
253
|
+
const v = fm[key];
|
|
254
|
+
if (v === undefined || v === '' || v === '—') continue;
|
|
255
|
+
for (const part of v.split(',')) {
|
|
256
|
+
if (adr_number(part) === null) {
|
|
257
|
+
findings.push({
|
|
258
|
+
file: rel,
|
|
259
|
+
level: 'error',
|
|
260
|
+
message: `\`${key}\` value \`${part.trim()}\` names no ADR number`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Corpus-level check: every `amends:` has its reciprocal `amended_by:` and vice
|
|
269
|
+
* versa. This is the half the `supersedes:` / `superseded_by:` pair never had —
|
|
270
|
+
* and its absence is exactly how ADR-035 kept asserting a rejection ADR-232 had
|
|
271
|
+
* reopened. A one-sided link is invisible from the side that is stale, which is
|
|
272
|
+
* the side a reader lands on first.
|
|
273
|
+
*/
|
|
274
|
+
export function check_amendment_links(
|
|
275
|
+
files: { rel: string; fm: Record<string, string> }[],
|
|
276
|
+
): AdrFinding[] {
|
|
277
|
+
const findings: AdrFinding[] = [];
|
|
278
|
+
const byNumber = new Map<string, { rel: string; fm: Record<string, string> }>();
|
|
279
|
+
for (const f of files) {
|
|
280
|
+
const n = f.fm['adr'] === undefined ? null : adr_number(f.fm['adr']);
|
|
281
|
+
if (n !== null) byNumber.set(n, f);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const reciprocal = { amends: 'amended_by', amended_by: 'amends' } as const;
|
|
285
|
+
for (const f of files) {
|
|
286
|
+
for (const key of ['amends', 'amended_by'] as const) {
|
|
287
|
+
const v = f.fm[key];
|
|
288
|
+
if (v === undefined || v === '' || v === '—') continue;
|
|
289
|
+
const self = f.fm['adr'] === undefined ? null : adr_number(f.fm['adr']);
|
|
290
|
+
for (const part of v.split(',')) {
|
|
291
|
+
const target = adr_number(part);
|
|
292
|
+
if (target === null) continue;
|
|
293
|
+
const other = byNumber.get(target);
|
|
294
|
+
if (other === undefined) {
|
|
295
|
+
findings.push({
|
|
296
|
+
file: f.rel,
|
|
297
|
+
level: 'error',
|
|
298
|
+
message: `\`${key}: ${part.trim()}\` points at an ADR that does not exist`,
|
|
299
|
+
});
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const back = other.fm[reciprocal[key]] ?? '';
|
|
303
|
+
const names_self =
|
|
304
|
+
self !== null &&
|
|
305
|
+
back
|
|
306
|
+
.split(',')
|
|
307
|
+
.map((s) => adr_number(s))
|
|
308
|
+
.includes(self);
|
|
309
|
+
if (!names_self) {
|
|
310
|
+
findings.push({
|
|
311
|
+
file: f.rel,
|
|
312
|
+
level: 'error',
|
|
313
|
+
message:
|
|
314
|
+
`\`${key}: ${part.trim()}\` is one-sided — ${other.rel} carries no ` +
|
|
315
|
+
`reciprocal \`${reciprocal[key]}\`. A one-sided amendment link is ` +
|
|
316
|
+
`invisible from the stale side.`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
144
322
|
return findings;
|
|
145
323
|
}
|
|
146
324
|
|
|
@@ -163,13 +341,20 @@ export function check(dir: string = ADR_DIR, ledger?: GateLedger): AdrFinding[]
|
|
|
163
341
|
const candidates = fs.readdirSync(dir).filter((n) => n.endsWith('.md')).sort();
|
|
164
342
|
ledger?.plan(candidates);
|
|
165
343
|
|
|
344
|
+
// Collected for the corpus-level reciprocal-amendment check below, which
|
|
345
|
+
// cannot be decided from one file.
|
|
346
|
+
const corpus: { rel: string; fm: Record<string, string> }[] = [];
|
|
347
|
+
|
|
166
348
|
for (const name of candidates) {
|
|
167
349
|
if (!/^ADR-.*\.md$/.test(name)) {
|
|
168
350
|
ledger?.outOfScope(name, 'not_applicable_kind');
|
|
169
351
|
continue;
|
|
170
352
|
}
|
|
171
353
|
const rel = path.relative(REPO_ROOT, path.join(dir, name));
|
|
172
|
-
const
|
|
354
|
+
const text = fs.readFileSync(path.join(dir, name), 'utf-8');
|
|
355
|
+
const parsed = parse_frontmatter(text);
|
|
356
|
+
if (parsed !== null) corpus.push({ rel, fm: parsed });
|
|
357
|
+
const own = check_one(rel, text);
|
|
173
358
|
findings.push(...own);
|
|
174
359
|
|
|
175
360
|
const errors = own.filter((f) => f.level === 'error').length;
|
|
@@ -179,6 +364,7 @@ export function check(dir: string = ADR_DIR, ledger?: GateLedger): AdrFinding[]
|
|
|
179
364
|
ledger?.complete(name);
|
|
180
365
|
}
|
|
181
366
|
}
|
|
367
|
+
findings.push(...check_amendment_links(corpus));
|
|
182
368
|
return findings;
|
|
183
369
|
}
|
|
184
370
|
|
|
@@ -473,6 +473,57 @@ function _extract_personas_frontmatter(text: string): Array<[number, string]> {
|
|
|
473
473
|
return results;
|
|
474
474
|
}
|
|
475
475
|
|
|
476
|
+
/**
|
|
477
|
+
* Paths this checker must not call broken because nothing is allowed to fix them.
|
|
478
|
+
*
|
|
479
|
+
* `archive_completed_roadmaps` treats `agents/evidence/**` and every `*.patch` as
|
|
480
|
+
* a FROZEN RECORD and deliberately does not rewrite path strings there — a review
|
|
481
|
+
* artefact is a record of what a reviewer was shown, and editing it retroactively
|
|
482
|
+
* makes it claim something that did not happen (measured 2026-08-11: one archival
|
|
483
|
+
* rewrote four frozen artefacts including a `diff.patch` header, and reported it
|
|
484
|
+
* as "4 ref(s) migrated"). So when a roadmap is archived, a record naming its old
|
|
485
|
+
* path keeps naming it — correctly.
|
|
486
|
+
*
|
|
487
|
+
* Without this carve-out the two gates contradict each other: the sweep forbids
|
|
488
|
+
* the rewrite and this checker reds the un-rewritten path, so EVERY archival of a
|
|
489
|
+
* roadmap a review artefact mentions turns CI red with no compliant fix available.
|
|
490
|
+
* Measured on the change that made archival automatic: 2 such references.
|
|
491
|
+
*
|
|
492
|
+
* Deliberately narrow — three conditions, all required. The source is a frozen
|
|
493
|
+
* record; the reference is a top-level roadmap path; and the file is still
|
|
494
|
+
* findable under a terminal disposition. A frozen record pointing at a roadmap
|
|
495
|
+
* that exists NOWHERE is still broken and still reds, which is the part a blanket
|
|
496
|
+
* `agents/evidence/reviews` exclusion would have lost: that directory holds live
|
|
497
|
+
* `*.findings.md` work alongside archived records, so excluding it wholesale would
|
|
498
|
+
* stop checking documents that are still being acted on.
|
|
499
|
+
*
|
|
500
|
+
* The frozen-record predicate is duplicated from `_is_frozen_record` in
|
|
501
|
+
* `src/agent-src/scripts/archive_completed_roadmaps.ts` rather than imported: that
|
|
502
|
+
* module is projected into consumer installs and pulls the dashboard generator in
|
|
503
|
+
* with it, so a package-internal gate importing it would drag the projection layer
|
|
504
|
+
* into CI's gate path. `tests/scripts/check_references.test.ts` holds the two
|
|
505
|
+
* copies against one table so the duplication cannot drift silently.
|
|
506
|
+
*/
|
|
507
|
+
const _FROZEN_RECORD_PREFIXES = ['agents/evidence/'];
|
|
508
|
+
const _TERMINAL_ROADMAP_DIRS = ['archive', 'skipped', 'later'];
|
|
509
|
+
|
|
510
|
+
export function _is_frozen_record_source(rel: string): boolean {
|
|
511
|
+
const norm = rel.split(path.sep).join('/');
|
|
512
|
+
return _FROZEN_RECORD_PREFIXES.some((p) => norm.startsWith(p)) || norm.endsWith('.patch');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Does `ref` name a top-level roadmap that now lives under a terminal disposition? */
|
|
516
|
+
export function _resolves_under_terminal_disposition(ref: string, root: string): boolean {
|
|
517
|
+
const norm = ref.split(path.sep).join('/');
|
|
518
|
+
const m = /^agents\/roadmaps\/([^/]+\.md)$/.exec(norm);
|
|
519
|
+
if (m === null) {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
return _TERMINAL_ROADMAP_DIRS.some((d) =>
|
|
523
|
+
_exists(path.join(root, 'agents', 'roadmaps', d, m[1] as string)),
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
476
527
|
function _find_suggestion(p: string, root: string): string {
|
|
477
528
|
const name = path.basename(p);
|
|
478
529
|
// DEAD-ROOT REPAIR (road-to-renewal-foundation Phase 1): the middle entry
|
|
@@ -664,6 +715,15 @@ function check_file(
|
|
|
664
715
|
// check above already swallows refs into `agents/runtime/`,
|
|
665
716
|
// so no extra carve-out is needed.
|
|
666
717
|
}
|
|
718
|
+
if (
|
|
719
|
+
!resolved &&
|
|
720
|
+
_is_frozen_record_source(_relPosix(root, filepath)) &&
|
|
721
|
+
_resolves_under_terminal_disposition(rawRef, root)
|
|
722
|
+
) {
|
|
723
|
+
// A record naming the pre-archival path of a roadmap that is
|
|
724
|
+
// still findable. Nothing may rewrite it — see above.
|
|
725
|
+
resolved = true;
|
|
726
|
+
}
|
|
667
727
|
if (!resolved) {
|
|
668
728
|
broken.push(
|
|
669
729
|
makeBrokenRef(
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* matching git tag, and npm's `latest` therefore lags behind main.
|
|
14
14
|
*
|
|
15
15
|
* Two independent invariants:
|
|
16
|
-
* 1. Tag invariant
|
|
17
|
-
*
|
|
16
|
+
* 1. Tag invariant: the version in `package.json` MUST have a matching git
|
|
17
|
+
* tag ON THE REMOTE. A local-only tag published nothing — `publish-npm.yml`
|
|
18
|
+
* triggers on `push: tags:` — so the probe asks `origin` (one `ls-remote`).
|
|
18
19
|
* 2. npm invariant (`--check-npm`, network): `npm view <pkg>
|
|
19
20
|
* dist-tags.latest` MUST equal the `package.json` version.
|
|
20
21
|
*
|
|
@@ -51,13 +52,32 @@ function _package_name(): string {
|
|
|
51
52
|
return String(data['name']);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
/**
|
|
56
|
+
* True when the tag is PUBLISHED — present on `origin`, never merely local.
|
|
57
|
+
*
|
|
58
|
+
* This probe used to answer local-OR-remote, and that made the gate reply
|
|
59
|
+
* "tagged, therefore published" to the exact state it exists to detect. A tag
|
|
60
|
+
* created by `release.ts` step 8 whose push then failed sits in the local
|
|
61
|
+
* repository while nothing has shipped: `publish-npm.yml` triggers on
|
|
62
|
+
* `push: tags:`, so a tag that never reached the remote published nothing.
|
|
63
|
+
* Measured 2026-08-20 on 14.6.0 — main carried `package.json` 14.6.0, the tag
|
|
64
|
+
* existed only in the maintainer's checkout, and npm still served 14.5.0.
|
|
65
|
+
*
|
|
66
|
+
* In CI the old form happened to be harmless: `actions/checkout` brings no
|
|
67
|
+
* unpushed local tag, so the remote arm decided. The hole was LOCAL — a
|
|
68
|
+
* maintainer running `task check-release-published` (or `task preflight`) on
|
|
69
|
+
* the machine that minted the tag got a green for an unpublished release, on
|
|
70
|
+
* the one machine where the stuck state is visible. The sibling defect in
|
|
71
|
+
* `release.ts::_detect_in_flight_target` is the same construct.
|
|
72
|
+
*
|
|
73
|
+
* @param git Seam for the probe. Production passes nothing and gets the real
|
|
74
|
+
* `_git`; tests inject a stub, because the alternative — reaching the branches
|
|
75
|
+
* only by creating and deleting tags in the checkout — is why neither arm of
|
|
76
|
+
* this function had a test while both were wrong.
|
|
77
|
+
*/
|
|
78
|
+
function _tag_exists(tag: string, git: (...args: string[]) => [number, string] = _git): boolean {
|
|
79
|
+
const [rc] = git('ls-remote', '--exit-code', '--tags', 'origin', tag);
|
|
80
|
+
return rc === 0;
|
|
61
81
|
}
|
|
62
82
|
|
|
63
83
|
function _on_main(): boolean {
|
|
@@ -179,8 +199,9 @@ function main(argv: readonly string[] = [], hooks: Hooks = _hooks): number {
|
|
|
179
199
|
if (!hooks._tag_exists(version)) {
|
|
180
200
|
problems.push(
|
|
181
201
|
`package.json is ${version} but no git tag ${version} exists ` +
|
|
182
|
-
`
|
|
183
|
-
`
|
|
202
|
+
`on origin — the release was bumped/merged but never tagged, ` +
|
|
203
|
+
`or the tag push never landed. Complete it: tag the ` +
|
|
204
|
+
`release-merge commit and push ` +
|
|
184
205
|
`(triggers publish-npm.yml), e.g. \`git tag ${version} && git ` +
|
|
185
206
|
`push origin ${version}\`.`,
|
|
186
207
|
);
|