@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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +108 -156
- package/dist/agent-src/commands/agent-handoff.md +42 -41
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +7 -1
- package/dist/agent-src/rules/untrusted-input-defense.md +0 -2
- package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +1 -1
- package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +28 -6
- package/dist/agent-src/skills/test-driven-development/SKILL.md +1 -3
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +14 -1
- package/dist/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
- package/dist/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
- package/dist/agent-src/templates/scripts/telemetry_report.ts +22 -2
- package/dist/cli/mcp/dispatch.js +10 -0
- package/dist/cli/mcp/stdio.js +33 -2
- package/dist/cli/mcp/telemetry.js +227 -0
- package/dist/cli-delegate/{chunk-GZTMRYCN.js → chunk-C6IPZRDA.js} +79 -12
- package/dist/cli-delegate/{chunk-CYH5SP6M.js → chunk-I6RHFQEG.js} +151 -16
- package/dist/cli-delegate/{chunk-IDHUQIXP.js → chunk-NUMQHLS5.js} +21 -4
- package/dist/cli-delegate/{chunk-PMEKGDED.js → chunk-PI2GU3DP.js} +163 -1
- package/dist/cli-delegate/{chunk-7PRE6ZV2.js → chunk-YS6XTLEK.js} +199 -4
- package/dist/cli-delegate/chunk-ZVBOHSVF.js +1109 -0
- package/dist/cli-delegate/cmd_conformance.js +73 -109
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +41 -11
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +59 -902
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +9 -9
- 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 +7257 -6497
- package/dist/install/install.mjs +393 -168
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +497 -118
- package/dist/scripts/mcp_server/telemetry.js +148 -0
- package/docs/guidelines/agent-infra/naming.md +1 -1
- package/docs/guidelines/agent-infra/security-lint-containment.md +46 -6
- package/docs/guidelines/agent-infra/self-improvement-pipeline.md +4 -4
- package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +1 -1
- package/docs/guidelines/redundancy-taxonomy.md +28 -0
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
- package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
- package/src/agent-src/templates/scripts/telemetry_report.ts +22 -2
- package/src/config/assurance-capability-registry.json +9 -0
- package/src/config/gate-coverage.yml +55 -0
- package/src/config/gate-violation-baselines.json +15 -3
- package/src/scripts/_cli/cmd_session_recycle.ts +51 -3
- package/src/scripts/_lib/audit_field_provenance.ts +44 -3
- package/src/scripts/_lib/host_capability.ts +38 -0
- package/src/scripts/_lib/install_reach_checks.ts +33 -1
- package/src/scripts/_lib/mcp_bridge.ts +222 -10
- package/src/scripts/_lib/mcp_consent_residual.ts +173 -0
- package/src/scripts/_lib/orchestration_record.ts +23 -1
- package/src/scripts/_lib/recycle_envelope_paths.ts +190 -0
- package/src/scripts/_lib/release_position.ts +96 -0
- package/src/scripts/_lib/review_skipped_record.ts +17 -1
- package/src/scripts/_lib/security_lint.ts +179 -7
- package/src/scripts/_lib/self_repair.ts +141 -6
- package/src/scripts/_lib/self_repair_store.ts +273 -7
- package/src/scripts/_lib/subagent_capsule.ts +155 -3
- package/src/scripts/build_rule_trigger_matrix.ts +5 -0
- package/src/scripts/check_mcp_doc_drift.ts +217 -0
- package/src/scripts/check_pack_size.ts +321 -3
- package/src/scripts/handoff_context_hook.ts +54 -7
- package/src/scripts/hooks/session_eol_hook.ts +18 -7
- package/src/scripts/install.ts +4 -4
- package/src/scripts/lint_agent_security.ts +316 -25
- package/src/scripts/lint_confusables.ts +42 -6
- package/src/scripts/lint_handoffs.ts +0 -132
- package/src/scripts/lint_hidden_unicode.ts +49 -7
- package/src/scripts/lint_instruction_smuggling.ts +42 -6
- package/src/scripts/lint_mcp_config_security.ts +42 -6
- package/src/scripts/lint_roadmap_later_disposition.ts +217 -27
- package/src/scripts/lint_skill_frontmatter_safety.ts +43 -7
- package/src/scripts/mcp_server/resources.ts +41 -0
- package/src/scripts/mcp_server/telemetry.ts +19 -4
- package/src/scripts/orchestration_record.ts +12 -1
- package/src/scripts/release.ts +24 -24
- package/src/scripts/release_publication.ts +6 -2
- package/src/scripts/self_repair_cli.ts +31 -1
- package/src/scripts/skill_scout.ts +69 -0
- package/dist/cli-delegate/chunk-2GNWGMAL.js +0 -172
- 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
|
|
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
|
-
|
|
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.
|