@event4u/agent-config 14.19.0 → 14.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +108 -156
  5. package/dist/agent-src/commands/agent-handoff.md +42 -41
  6. package/dist/agent-src/contexts/execution/host-capability-manifest.md +7 -1
  7. package/dist/agent-src/rules/untrusted-input-defense.md +0 -2
  8. package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +1 -1
  9. package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
  10. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +28 -6
  11. package/dist/agent-src/skills/test-driven-development/SKILL.md +1 -3
  12. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  13. package/dist/agent-src/templates/roadmaps.md +14 -1
  14. package/dist/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  15. package/dist/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  16. package/dist/agent-src/templates/scripts/telemetry_report.ts +22 -2
  17. package/dist/cli/mcp/dispatch.js +10 -0
  18. package/dist/cli/mcp/stdio.js +33 -2
  19. package/dist/cli/mcp/telemetry.js +227 -0
  20. package/dist/cli-delegate/{chunk-GZTMRYCN.js → chunk-C6IPZRDA.js} +79 -12
  21. package/dist/cli-delegate/{chunk-CYH5SP6M.js → chunk-I6RHFQEG.js} +151 -16
  22. package/dist/cli-delegate/{chunk-IDHUQIXP.js → chunk-NUMQHLS5.js} +21 -4
  23. package/dist/cli-delegate/{chunk-PMEKGDED.js → chunk-PI2GU3DP.js} +163 -1
  24. package/dist/cli-delegate/{chunk-7PRE6ZV2.js → chunk-YS6XTLEK.js} +199 -4
  25. package/dist/cli-delegate/chunk-ZVBOHSVF.js +1109 -0
  26. package/dist/cli-delegate/cmd_conformance.js +73 -109
  27. package/dist/cli-delegate/cmd_doctor.js +2 -2
  28. package/dist/cli-delegate/cmd_export.js +2 -2
  29. package/dist/cli-delegate/cmd_handoff.js +41 -11
  30. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  31. package/dist/cli-delegate/cmd_prune.js +2 -2
  32. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  33. package/dist/cli-delegate/cmd_session_recycle.js +59 -902
  34. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  35. package/dist/cli-delegate/cmd_sync.js +2 -2
  36. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  37. package/dist/cli-delegate/cmd_validate.js +2 -2
  38. package/dist/discovery/deprecation-report.md +1 -1
  39. package/dist/discovery/discovery-manifest.json +9 -9
  40. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  41. package/dist/discovery/discovery-manifest.summary.md +1 -1
  42. package/dist/discovery/dormancy-report.md +1 -1
  43. package/dist/discovery/orphan-report.md +1 -1
  44. package/dist/discovery/packs.json +2 -2
  45. package/dist/discovery/trust-report.md +1 -1
  46. package/dist/discovery/workspaces.json +2 -2
  47. package/dist/hooks/dispatch.js +7257 -6497
  48. package/dist/install/install.mjs +393 -168
  49. package/dist/mcp/registry-manifest.json +2 -2
  50. package/dist/mcp/server.json +2 -2
  51. package/dist/mcp/server.mjs +497 -118
  52. package/dist/scripts/mcp_server/telemetry.js +148 -0
  53. package/docs/guidelines/agent-infra/naming.md +1 -1
  54. package/docs/guidelines/agent-infra/security-lint-containment.md +46 -6
  55. package/docs/guidelines/agent-infra/self-improvement-pipeline.md +4 -4
  56. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +1 -1
  57. package/docs/guidelines/redundancy-taxonomy.md +28 -0
  58. package/package.json +1 -1
  59. package/src/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  60. package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  61. package/src/agent-src/templates/scripts/telemetry_report.ts +22 -2
  62. package/src/config/assurance-capability-registry.json +9 -0
  63. package/src/config/gate-coverage.yml +55 -0
  64. package/src/config/gate-violation-baselines.json +15 -3
  65. package/src/scripts/_cli/cmd_session_recycle.ts +51 -3
  66. package/src/scripts/_lib/audit_field_provenance.ts +44 -3
  67. package/src/scripts/_lib/host_capability.ts +38 -0
  68. package/src/scripts/_lib/install_reach_checks.ts +33 -1
  69. package/src/scripts/_lib/mcp_bridge.ts +222 -10
  70. package/src/scripts/_lib/mcp_consent_residual.ts +173 -0
  71. package/src/scripts/_lib/orchestration_record.ts +23 -1
  72. package/src/scripts/_lib/recycle_envelope_paths.ts +190 -0
  73. package/src/scripts/_lib/release_position.ts +96 -0
  74. package/src/scripts/_lib/review_skipped_record.ts +17 -1
  75. package/src/scripts/_lib/security_lint.ts +179 -7
  76. package/src/scripts/_lib/self_repair.ts +141 -6
  77. package/src/scripts/_lib/self_repair_store.ts +273 -7
  78. package/src/scripts/_lib/subagent_capsule.ts +155 -3
  79. package/src/scripts/build_rule_trigger_matrix.ts +5 -0
  80. package/src/scripts/check_mcp_doc_drift.ts +217 -0
  81. package/src/scripts/check_pack_size.ts +321 -3
  82. package/src/scripts/handoff_context_hook.ts +54 -7
  83. package/src/scripts/hooks/session_eol_hook.ts +18 -7
  84. package/src/scripts/install.ts +4 -4
  85. package/src/scripts/lint_agent_security.ts +316 -25
  86. package/src/scripts/lint_confusables.ts +42 -6
  87. package/src/scripts/lint_handoffs.ts +0 -132
  88. package/src/scripts/lint_hidden_unicode.ts +49 -7
  89. package/src/scripts/lint_instruction_smuggling.ts +42 -6
  90. package/src/scripts/lint_mcp_config_security.ts +42 -6
  91. package/src/scripts/lint_roadmap_later_disposition.ts +217 -27
  92. package/src/scripts/lint_skill_frontmatter_safety.ts +43 -7
  93. package/src/scripts/mcp_server/resources.ts +41 -0
  94. package/src/scripts/mcp_server/telemetry.ts +19 -4
  95. package/src/scripts/orchestration_record.ts +12 -1
  96. package/src/scripts/release.ts +24 -24
  97. package/src/scripts/release_publication.ts +6 -2
  98. package/src/scripts/self_repair_cli.ts +31 -1
  99. package/src/scripts/skill_scout.ts +69 -0
  100. package/dist/cli-delegate/chunk-2GNWGMAL.js +0 -172
  101. package/dist/cli-delegate/chunk-LPPWO5CE.js +0 -161
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Per-host MCP consent residuals (roadmap step 3.2).
3
+ *
4
+ * A RESIDUAL is what remains non-automatic after this package has installed:
5
+ * the step the human still has to take before the MCP server actually runs.
6
+ * The step's own framing is the design constraint — "a checklist of things that
7
+ * did work is noise, a named residual is actionable" — so this module models
8
+ * only what is left, and the surfaces that print it print nothing else.
9
+ *
10
+ * Provenance is carried, not assumed.
11
+ *
12
+ * Every residual says where its text came from, and the surfaces print it:
13
+ *
14
+ * - `vendor-doc` — the host's own documentation says so. Nobody here watched it
15
+ * happen. Vendor documentation drifts, so this is a starting value, never a
16
+ * fact about the machine the reader is on.
17
+ * - `observed` — a session on that host recorded it. Only an observation may
18
+ * replace a documented value, and only in that direction.
19
+ *
20
+ * An absent host is UNRECORDED, never "no residual".
21
+ *
22
+ * A host with no entry here has not been looked at. `residualFor` returns
23
+ * `null` and the callers say so in words, because printing nothing would make
24
+ * "this host needs no step" and "nobody checked this host" the same line — the
25
+ * exact confusion the capability manifest's own `default` provenance exists to
26
+ * prevent.
27
+ */
28
+
29
+ import { describeHostCapabilities } from './host_capability.js';
30
+
31
+ /** Where a residual's text came from. Only an observation may replace a doc. */
32
+ export type ResidualSource = 'vendor-doc' | 'observed';
33
+
34
+ export interface ConsentResidual {
35
+ /** Capability-manifest host id (`claude`), not the installer tool id. */
36
+ hostId: string;
37
+ /** The step the human still has to take. One sentence, imperative. */
38
+ residual: string;
39
+ source: ResidualSource;
40
+ /** Why we believe it — a doc reference, or the session that observed it. */
41
+ cite: string;
42
+ }
43
+
44
+ /**
45
+ * Seeded residuals.
46
+ *
47
+ * DELIBERATELY SHORT. Only hosts whose residual is establishable today appear;
48
+ * a row invented for a host nobody checked would be the "vendor-doc residuals
49
+ * harden into asserted facts" risk this roadmap's own register names, arriving
50
+ * one step earlier than predicted. Absence here is the honest state, and the
51
+ * callers render it as such.
52
+ */
53
+ const SEEDED: Readonly<Record<string, ConsentResidual>> = {
54
+ claude: {
55
+ hostId: 'claude',
56
+ residual:
57
+ 'Approve the project-scoped `agent-config` server the first time the host '
58
+ + 'prompts — the installer writes `.mcp.json` but does not pre-approve it.',
59
+ source: 'vendor-doc',
60
+ cite:
61
+ 'Host documentation: a project-scope MCP server requires explicit approval, '
62
+ + 'suppressible only through the user-global `enabledMcpjsonServers` key, '
63
+ + 'which this package deliberately does not write '
64
+ + '(blocker `mcp-user-scope-approval-consent`).',
65
+ },
66
+ };
67
+
68
+ /** Installer `--tools` id → capability-manifest host id, for the report. */
69
+ export const TOOL_TO_HOST: Readonly<Record<string, string>> = {
70
+ 'claude-code': 'claude',
71
+ cursor: 'cursor',
72
+ 'gemini-cli': 'gemini',
73
+ windsurf: 'windsurf',
74
+ cline: 'cline',
75
+ copilot: 'copilot',
76
+ augment: 'augment',
77
+ };
78
+
79
+ /**
80
+ * The residual for one host, or `null` when none is recorded.
81
+ *
82
+ * An OBSERVATION outranks the documented value, and in one direction only: a
83
+ * host whose capability row explicitly records `mcp_needs_manual_activation:
84
+ * false` — an observation, since the field's provenance reads `registry` only
85
+ * when the row wrote it — has no residual left, and that clearance is itself
86
+ * reported as `observed`. The reverse never happens: a documented residual is
87
+ * not upgraded to `observed` by anything short of a session.
88
+ */
89
+ export function residualFor(hostId: string): ConsentResidual | null {
90
+ const { manifest, sources } = describeHostCapabilities(hostId);
91
+ if (sources.mcp_needs_manual_activation === 'registry' && !manifest.mcp_needs_manual_activation) {
92
+ return null;
93
+ }
94
+ return SEEDED[hostId] ?? null;
95
+ }
96
+
97
+ /** Was this host looked at at all? Distinguishes "cleared" from "unrecorded". */
98
+ export function isRecorded(hostId: string): boolean {
99
+ const { sources } = describeHostCapabilities(hostId);
100
+ return hostId in SEEDED || sources.mcp_needs_manual_activation === 'registry';
101
+ }
102
+
103
+ /** One rendered line per host, for the doctor check and the installer report. */
104
+ export interface ResidualLine {
105
+ hostId: string;
106
+ /** `residual` · `cleared` · `unrecorded` — the three honest states. */
107
+ state: 'residual' | 'cleared' | 'unrecorded';
108
+ text: string;
109
+ }
110
+
111
+ /** Render the state of one host. Never silent, in any of the three states. */
112
+ export function residualLine(hostId: string): ResidualLine {
113
+ const r = residualFor(hostId);
114
+ if (r !== null) {
115
+ return {
116
+ hostId,
117
+ state: 'residual',
118
+ text: `${hostId}: ${r.residual} (source: ${r.source} — not observed here)`,
119
+ };
120
+ }
121
+ if (isRecorded(hostId)) {
122
+ return {
123
+ hostId,
124
+ state: 'cleared',
125
+ text: `${hostId}: nothing left to do (source: observed)`,
126
+ };
127
+ }
128
+ return {
129
+ hostId,
130
+ state: 'unrecorded',
131
+ text: `${hostId}: no residual recorded — nobody checked this host, which is not the same as nothing to do`,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * The lines an installer run should print, for the tools it selected.
137
+ *
138
+ * Only hosts with a real residual OR an unrecorded state are returned: a host
139
+ * whose residual was observed away has nothing actionable to say, and the step
140
+ * asks for exactly the actionable set.
141
+ */
142
+ export function residualReport(toolIds: Iterable<string>): ResidualLine[] {
143
+ const seen = new Set<string>();
144
+ const out: ResidualLine[] = [];
145
+ for (const toolId of toolIds) {
146
+ const hostId = TOOL_TO_HOST[toolId];
147
+ if (hostId === undefined || seen.has(hostId)) continue;
148
+ seen.add(hostId);
149
+ const line = residualLine(hostId);
150
+ if (line.state !== 'cleared') out.push(line);
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /**
156
+ * Print the residuals for one install's selected tools, or nothing.
157
+ *
158
+ * Lives here rather than inline in the installer's closing block for the same
159
+ * reason `registerMcpHosts` does: `install.ts` sits far over the source-size
160
+ * ratchet's ceiling, and the rendering belongs beside the table it renders.
161
+ */
162
+ export function printResiduals(
163
+ toolIds: Iterable<string>,
164
+ quiet: boolean,
165
+ write: (s: string) => void = (s) => process.stdout.write(s),
166
+ ): void {
167
+ if (quiet) return;
168
+ const lines = residualReport(toolIds);
169
+ if (lines.length === 0) return;
170
+ write(' Still needs you (MCP):\n');
171
+ for (const line of lines) write(` \u2022 ${line.text}\n`);
172
+ write('\n');
173
+ }
@@ -16,6 +16,7 @@
16
16
  * add one. (Mirrors `domain-safety-pii` § Surface 2 / artifact-engagement.)
17
17
  */
18
18
 
19
+ import { appliedIds } from './audit_field_provenance.js';
19
20
  import type { LookupClass } from './auto_dispatch.js';
20
21
  import type { EvidenceBasis } from './evidence_basis.js';
21
22
  // TYPE-ONLY on purpose. `runtime_journal.ts` imports `node:sqlite` at runtime,
@@ -104,6 +105,24 @@ export interface RecordInput {
104
105
  * the rules bound in the contract.
105
106
  */
106
107
  skills_applied?: string[] | null | undefined;
108
+ /**
109
+ * Stable rule ids the run ACTUALLY carried, as an observation.
110
+ *
111
+ * Until 2026-09-07 this producer wrote the literal `['delegation-policy']`
112
+ * on every line, and so did `review_skipped_record.ts`. Mining the real
113
+ * stream with `extract_audit_patterns --min-count 2` therefore minted
114
+ * exactly ONE pattern at count 1074 over 1104 lines: not a regularity, just
115
+ * arithmetic over a constant. Every per-asset reader downstream was
116
+ * aggregating over the writer rather than over the work.
117
+ *
118
+ * ABSENT now means an EMPTY list, not a fabricated one. An empty list is an
119
+ * honest absence — the same answer `activation_receipt_producer.ts` has
120
+ * always given — and it is what makes the field's variation, when a caller
121
+ * does supply ids, evidence about the run.
122
+ *
123
+ * Ids only, never bodies. Bounded to <= 32, mirroring the contract.
124
+ */
125
+ rules_applied?: string[] | null | undefined;
107
126
  /**
108
127
  * What this dispatch was supposed to produce. Drives the anti-forgery gate
109
128
  * in `envelopeOutcome`. Absent or `unknown` disables the gate for this
@@ -253,6 +272,7 @@ const ORIGIN_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
253
272
  */
254
273
  const MAX_APPLIED_IDS = 32;
255
274
 
275
+
256
276
  /**
257
277
  * What THIS producer's lines carry. `ids-only` and not `counts-only` because
258
278
  * every line carries `rules_applied`, and some carry `skills_applied`.
@@ -626,7 +646,9 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
626
646
  risk_class: risk,
627
647
  memory: { asks: 0, hits: 0 },
628
648
  verify: { claims: 0, first_try_passes: 0 },
629
- rules_applied: ['delegation-policy'],
649
+ // COMPUTED, never a producer constant. See `rules_applied` on the input
650
+ // interface for the measurement that retired the constant.
651
+ rules_applied: appliedIds(input.rules_applied),
630
652
  // MANDATORY, never optional and never derived by the reader. This line
631
653
  // carries `rules_applied` and may carry `skills_applied`, both of which
632
654
  // are stable artefact ids, so the class it declares is `ids-only`
@@ -5,8 +5,11 @@
5
5
  * when it is stale. A second copy of any of these values is the drift seam
6
6
  * Risk 6 warns about.
7
7
  */
8
+ import * as fs from 'node:fs';
8
9
  import * as path from 'node:path';
9
10
 
11
+ import { safe_stem } from './session_register.js';
12
+
10
13
  /** Where `session:recycle` writes the pending envelope (gitignored runtime state). */
11
14
  export const RECYCLE_ENVELOPE_REL = path.join('agents', 'runtime', 'state', 'recycle-envelope.json');
12
15
 
@@ -33,3 +36,190 @@ export const RECYCLE_MAX_AGE_HOURS = 48;
33
36
  * for the wrapper block and a co-pending handoff.
34
37
  */
35
38
  export const RECYCLE_ENVELOPE_MAX_BYTES = 6144;
39
+
40
+ // ---------------------------------------------------------------------
41
+ // Per-SESSION keying (road-to-one-continuity-record Phase 2.1)
42
+ // ---------------------------------------------------------------------
43
+
44
+ /** State directory the two constants above live in. */
45
+ const _STATE_REL = path.join('agents', 'runtime', 'state');
46
+
47
+ /**
48
+ * Per-SESSION record path. The two constants above name **one file per
49
+ * workspace**, and the session register in the same tree models concurrent
50
+ * sessions per checkout — so the continuity artifact and the identity model
51
+ * disagreed, and the artifact lost. Two sessions in one checkout overwrite
52
+ * each other's record and each resumes from whichever wrote last.
53
+ *
54
+ * This is the same defect `roadmap_claim_rel` (`session_register_hook.ts:105`)
55
+ * was repaired for, and it is deliberately repaired the same way rather than a
56
+ * better way: one shape for "state keyed by session", so a reader who has seen
57
+ * one recognises the other. `safe_stem` is imported from the register rather
58
+ * than re-implemented — a second sanitiser is a second traversal guard to keep
59
+ * correct.
60
+ *
61
+ * **No `latest` index, by decision.** A pointer file naming the newest record
62
+ * would restore the shared write target one layer up and re-create the same
63
+ * race with an extra indirection. Resolution is by register identity,
64
+ * workspace and branch — never by recency.
65
+ *
66
+ * When no session id is resolvable the legacy shared path is returned, so an
67
+ * older record keeps working and the fallback degrades to the previous
68
+ * behaviour instead of losing the record. The reader's ambiguity rule
69
+ * (`resolveContinuityRecord`) is what keeps that fallback from becoming a
70
+ * cross-session read.
71
+ */
72
+ export function recycle_envelope_rel(session_id: string | null | undefined): string {
73
+ return _sessionKeyed(session_id, RECYCLE_ENVELOPE_REL, 'recycle-envelope', '.json');
74
+ }
75
+
76
+ /** Consumed sibling of {@link recycle_envelope_rel}, same keying. */
77
+ export function recycle_consumed_rel(session_id: string | null | undefined): string {
78
+ return _sessionKeyed(session_id, RECYCLE_CONSUMED_REL, 'recycle-envelope', '.consumed.json');
79
+ }
80
+
81
+ /**
82
+ * Shared builder. Containment is asserted locally rather than trusted from
83
+ * `safe_stem`: a guarantee living in another module's implementation is one
84
+ * refactor away from being untrue here, and the failure mode of being wrong is
85
+ * a write outside the state directory.
86
+ */
87
+ function _sessionKeyed(
88
+ session_id: string | null | undefined,
89
+ legacy: string,
90
+ stem: string,
91
+ suffix: string,
92
+ ): string {
93
+ const id = String(session_id ?? '').trim();
94
+ if (id === '') {
95
+ return legacy;
96
+ }
97
+ const rel = path.join(_STATE_REL, `${stem}-${safe_stem(id)}${suffix}`);
98
+ const norm = path.normalize(rel);
99
+ if (!norm.startsWith(`${_STATE_REL}${path.sep}`) || norm.includes('..')) {
100
+ return legacy;
101
+ }
102
+ return norm;
103
+ }
104
+
105
+ /** Every session-keyed record present in a workspace, plus the legacy file. */
106
+ export function listContinuityRecords(workspace_root: string): string[] {
107
+ const dir = path.join(workspace_root, _STATE_REL);
108
+ let names: string[];
109
+ try {
110
+ names = fs.readdirSync(dir);
111
+ } catch {
112
+ return [];
113
+ }
114
+ return names
115
+ .filter(
116
+ (n) =>
117
+ n.startsWith('recycle-envelope') &&
118
+ n.endsWith('.json') &&
119
+ !n.endsWith('.consumed.json'),
120
+ )
121
+ .sort()
122
+ .map((n) => path.join(dir, n));
123
+ }
124
+
125
+ /** What {@link resolveContinuityRecord} decided, and why — the reason is emitted, never dropped. */
126
+ export interface ContinuityResolution {
127
+ /** Absolute path to read, or `null` when the reader must start clean. */
128
+ file: string | null;
129
+ /** One line, always present. On `file === null` this is what the reader says out loud. */
130
+ reason: string;
131
+ }
132
+
133
+ /**
134
+ * Which record THIS session may read.
135
+ *
136
+ * Three cases, and the third is the one the per-session key exists for:
137
+ *
138
+ * 1. **Own id resolves.** Read the record keyed to it, and nothing else. A peer
139
+ * session's record is not a candidate at any point, so "neither observes the
140
+ * other's" holds by construction rather than by a comparison that could be
141
+ * skipped.
142
+ * 2. **No id, one candidate.** Read it. A single record in a checkout with no
143
+ * resolvable identity is the pre-key situation and is unchanged.
144
+ * 3. **No id, several candidates.** START CLEAN and say so. Picking the newest
145
+ * is precisely the recency resolution the no-index rule forbids: it would
146
+ * hand this session a stranger's state and look like a successful resume.
147
+ */
148
+ export function resolveContinuityRecord(
149
+ workspace_root: string,
150
+ session_id: string | null | undefined,
151
+ ): ContinuityResolution {
152
+ const id = String(session_id ?? '').trim();
153
+ if (id !== '') {
154
+ const own = path.join(workspace_root, recycle_envelope_rel(id));
155
+ return fs.existsSync(own)
156
+ ? { file: own, reason: `continuity record for this session (${safe_stem(id)})` }
157
+ : { file: null, reason: `no continuity record for this session (${safe_stem(id)})` };
158
+ }
159
+ const candidates = listContinuityRecords(workspace_root);
160
+ if (candidates.length === 0) {
161
+ return { file: null, reason: 'no continuity record present' };
162
+ }
163
+ if (candidates.length === 1) {
164
+ return { file: candidates[0] as string, reason: 'one continuity record, no session id to key on' };
165
+ }
166
+ return {
167
+ file: null,
168
+ reason:
169
+ `starting clean: ${candidates.length} continuity records in this workspace and no session id ` +
170
+ 'to tell them apart — resuming from the newest would resume a peer session, not this one',
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Is there any trace in this workspace of the session a record names as its
176
+ * predecessor?
177
+ *
178
+ * The normal case resolves through the consumed sibling: when this session
179
+ * started, its reader consumed the predecessor's record and moved it to this
180
+ * session's consumed path, so that file carries `session_id === predecessor`.
181
+ * A live record under the predecessor's own key also counts — that is the
182
+ * shape when the chain has not been walked yet.
183
+ *
184
+ * **A `false` here is a refusal, not a repair.** The reader discards the record
185
+ * and says why. The alternative — injecting a record whose stated lineage
186
+ * cannot be corroborated — is the guess this check exists to prevent, and it is
187
+ * the expensive direction: a refused resume costs one session's context, a
188
+ * wrong resume costs a session working from a stranger's state without knowing
189
+ * it.
190
+ *
191
+ * **Known false-refusal, stated rather than hidden:** the janitor prunes old
192
+ * state, so a long-idle chain can lose its consumed sibling and be refused
193
+ * although it was genuine. That is the same cost as the record having expired,
194
+ * which at {@link RECYCLE_MAX_AGE_HOURS} it very likely has.
195
+ */
196
+ export function predecessorTracePresent(workspace_root: string, predecessor: string): boolean {
197
+ const id = String(predecessor ?? '').trim();
198
+ if (id === '' || id === 'none') {
199
+ return true;
200
+ }
201
+ if (fs.existsSync(path.join(workspace_root, recycle_envelope_rel(id)))) {
202
+ return true;
203
+ }
204
+ const dir = path.join(workspace_root, _STATE_REL);
205
+ let names: string[];
206
+ try {
207
+ names = fs.readdirSync(dir);
208
+ } catch {
209
+ return false;
210
+ }
211
+ for (const n of names) {
212
+ if (!n.startsWith('recycle-envelope') || !n.endsWith('.json')) continue;
213
+ try {
214
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, n), 'utf-8')) as {
215
+ session_id?: unknown;
216
+ };
217
+ if (String(raw.session_id ?? '').trim() === id) {
218
+ return true;
219
+ }
220
+ } catch {
221
+ // unreadable file proves nothing in either direction
222
+ }
223
+ }
224
+ return false;
225
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Where a release may START — the branch it may run from, and whether a dirty
3
+ * working tree is tolerated there.
4
+ *
5
+ * Its own module because `release.ts` sits 456 lines over the 1500-line source
6
+ * ratchet, so growing it is a gate failure by construction. `_lib/` is where
7
+ * `release_highlights.ts` and `release_material.ts` already put the predicates
8
+ * the pipeline and its gates share.
9
+ */
10
+
11
+ export interface PositionVerdict {
12
+ proceed: boolean;
13
+ /** Printed before the run continues. Never a refusal. */
14
+ notice?: string;
15
+ /** Printed on refusal (no trailing newline). */
16
+ message?: string;
17
+ }
18
+
19
+ /**
20
+ * Where a release may START, resolved as a pure verdict (no I/O, so it is
21
+ * unit-testable — same shape as `confirmGate`).
22
+ *
23
+ * THE DEADLOCK THIS REMOVES.
24
+ *
25
+ * `guard_release_curation` refuses BETWEEN step 2 and step 3: step 1 has
26
+ * already created and checked out `release/{target}`, step 2 has already
27
+ * written the bump, the changelog section and every regenerated derived file,
28
+ * and nothing is committed yet. Its message says to curate the head and re-run
29
+ * `task release`. Measured on 14.19.0, neither re-run reached step 1:
30
+ *
31
+ * - `task release` → `release must run from 'main'` (this check)
32
+ * - `task release --resume` → `working tree is not clean` (this check)
33
+ *
34
+ * So the guard prescribed a remedy the preflight refused, in both spellings,
35
+ * and the only way through was to hand-craft the `release: X.Y.Z` commit the
36
+ * pipeline makes for itself one step later.
37
+ *
38
+ * `docs/release-runbook.md` already claimed this was closed on 2026-09-03 and
39
+ * the drill pins `plain-run-reuses-an-existing-branch` for it. Both were true
40
+ * of the fix that landed — in `checkout_release_branch`, which is STEP 1. The
41
+ * preflight runs before step 1, so from the release branch the run never
42
+ * arrived at the code that had been repaired. The claim was not wrong about
43
+ * step 1; it was wrong that step 1 was reachable from that position.
44
+ *
45
+ * Why `release/{target}` is a legitimate start, with or without `--resume`:
46
+ *
47
+ * It is the position the pipeline itself leaves behind. `--resume` remains
48
+ * meaningful for what it was always for — skipping a COMMIT, a PR, a tag or a
49
+ * Release that already exists — and the `!resume` tag-exists check in
50
+ * `preflight` still refuses a plain re-run of an already-tagged version.
51
+ *
52
+ * Why a dirty tree is accepted THERE and nowhere else:
53
+ *
54
+ * On `release/{target}` the dirt is this pipeline's own step-2 output, and
55
+ * step 3 sweeps it with `git add -A` regardless. Refusing it can therefore
56
+ * only ever fire against the tool's own state. On `main` the same tree is an
57
+ * operator's unrelated work about to be swept into a release commit, so the
58
+ * refusal stays exactly as it was. The accepted files are PRINTED rather than
59
+ * absorbed silently, and `check_release_pr_shape` still refuses anything
60
+ * outside the version-bump allowlist on the resulting PR — two nets under the
61
+ * one place this relaxes.
62
+ */
63
+ export function preflightPosition(opts: {
64
+ branch: string;
65
+ mainBranch: string;
66
+ releaseBranch: string;
67
+ porcelain: string;
68
+ }): PositionVerdict {
69
+ const { branch, mainBranch, releaseBranch, porcelain } = opts;
70
+ if (branch !== mainBranch && branch !== releaseBranch) {
71
+ return {
72
+ proceed: false,
73
+ message:
74
+ `release must run from '${mainBranch}' or '${releaseBranch}', ` +
75
+ `currently on '${branch}'`,
76
+ };
77
+ }
78
+ if (!porcelain.trim()) {
79
+ return { proceed: true };
80
+ }
81
+ if (branch === mainBranch) {
82
+ return { proceed: false, message: 'working tree is not clean; commit or stash first' };
83
+ }
84
+ return {
85
+ proceed: true,
86
+ notice:
87
+ `working tree is not clean, and on '${releaseBranch}' that is this pipeline's own ` +
88
+ 'step-2 output — step 3 commits it with `git add -A`. Files that will be swept ' +
89
+ 'into the release commit:\n' +
90
+ porcelain
91
+ .split('\n')
92
+ .filter((l) => l.trim())
93
+ .map((l) => ` ${l}`)
94
+ .join('\n'),
95
+ };
96
+ }
@@ -34,6 +34,7 @@
34
34
  */
35
35
  export type MutationMeasure = 'exact' | 'capped_approximation';
36
36
 
37
+ import { appliedIds } from './audit_field_provenance.js';
37
38
  import type { NoFreeForm } from './runtime_journal.js';
38
39
  import { type PrivacyClass } from './privacy_class.js';
39
40
 
@@ -55,6 +56,20 @@ export interface ReviewSkippedInput {
55
56
  /** Stable id (ULID, UUID, or content hash); caller supplies. */
56
57
  id: string;
57
58
  work_id?: string | undefined;
59
+ /**
60
+ * Stable rule ids the run ACTUALLY carried. Absent means `[]`, an honest
61
+ * absence — this writer observes a review that did NOT happen, so it
62
+ * usually has no rules observation to offer in either direction.
63
+ *
64
+ * It wrote the literal `['delegation-policy']` on every line until
65
+ * 2026-09-07, which made the whole audit stream's most frequent "pattern" a
66
+ * property of the writer. See `_lib/audit_field_provenance.ts` for the
67
+ * measurement, and note the difference from `skills_applied` below: that
68
+ * field is OMITTED because audit-log-v1 lets absence mean "not recorded",
69
+ * while `rules_applied` is mandatory on every line and `[]` is how absence
70
+ * is spelled there.
71
+ */
72
+ rules_applied?: string[] | null | undefined;
58
73
  }
59
74
 
60
75
  export interface BuiltReviewSkippedLine {
@@ -102,7 +117,8 @@ export function buildReviewSkippedLine(input: ReviewSkippedInput): BuiltReviewSk
102
117
  risk_class: input.diff_lines >= HIGH_RISK_DIFF_LINES ? 'high' : 'medium',
103
118
  memory: { asks: 0, hits: 0 },
104
119
  verify: { claims: 0, first_try_passes: 0 },
105
- rules_applied: ['delegation-policy'],
120
+ // COMPUTED, never a producer constant.
121
+ rules_applied: appliedIds(input.rules_applied),
106
122
  // MANDATORY on every audit line, same as the orchestration producer.
107
123
  privacy_class: PRODUCER_PRIVACY_CLASS,
108
124
  // `skills_applied` is OMITTED here, deliberately and not by oversight.