@agent-delivery-harness/cli 0.2.0 → 0.5.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/LICENSE +104 -201
- package/NOTICE +1 -1
- package/package.json +4 -4
- package/src/boundary.ts +77 -61
- package/src/command-activity.ts +75 -0
- package/src/commands/admit.ts +106 -0
- package/src/commands/check.ts +13 -2
- package/src/commands/emit-review-evidence.ts +38 -0
- package/src/commands/emit.ts +80 -12
- package/src/commands/gate.ts +33 -8
- package/src/commands/maintain.ts +1 -0
- package/src/commands/managed.ts +1 -0
- package/src/commands/prepare.ts +123 -22
- package/src/commands/record.ts +98 -3
- package/src/commands/resume.ts +53 -0
- package/src/commands/review-context.ts +19 -0
- package/src/commands/runs.ts +415 -30
- package/src/commands/save-context.ts +51 -0
- package/src/commands/submit-evidence.ts +46 -7
- package/src/commands/verify.ts +29 -8
- package/src/declared-checks.ts +50 -0
- package/src/index.ts +14 -1
- package/src/main.ts +20 -47
- package/src/ordinary-context.ts +99 -0
- package/src/provider-rails.ts +23 -704
- package/src/record-retention.ts +325 -0
- package/src/review-evidence.ts +284 -0
- package/src/run-archive-commands.ts +164 -0
- package/src/run-archive.ts +127 -0
- package/src/run-artifact-commands.ts +163 -0
- package/src/run-attachments.ts +112 -0
- package/src/run-export.ts +164 -0
- package/src/run-live.ts +102 -0
- package/src/run-projection.ts +129 -11
- package/src/run-server.ts +394 -149
- package/src/run-surface.ts +81 -8
- package/src/run-view-command.ts +77 -0
- package/src/run-view-html.ts +138 -0
- package/src/run-view-record.ts +119 -0
- package/src/run-view.ts +343 -0
package/src/commands/prepare.ts
CHANGED
|
@@ -5,34 +5,135 @@
|
|
|
5
5
|
* admission. Capturing an unprepared tree (dirty or with untracked files) is a
|
|
6
6
|
* typed block, not a silent skip.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
9
|
-
|
|
8
|
+
import {
|
|
9
|
+
classifyCandidateDrift,
|
|
10
|
+
computePreparationFingerprint,
|
|
11
|
+
computeDeliverableIdentity,
|
|
12
|
+
evaluatePreparationReceipt,
|
|
13
|
+
createBlocker,
|
|
14
|
+
createExecPort,
|
|
15
|
+
invalidatePreparationReceipt,
|
|
16
|
+
revokePreparationAttempt,
|
|
17
|
+
publishPreparationReceipt,
|
|
18
|
+
type RunPreparationObservation,
|
|
19
|
+
} from "@agent-delivery-harness/kernel";
|
|
20
|
+
import { CliInterruption, type CommandContext, type CommandDescriptor, type CommandResult } from "../boundary.ts";
|
|
21
|
+
|
|
22
|
+
const USAGE = "Usage: delivery-harness prepare [--refresh-record-neutral]";
|
|
10
23
|
|
|
11
24
|
export const prepareCommand: CommandDescriptor = {
|
|
12
25
|
name: "prepare",
|
|
13
26
|
sourceId: "delivery-harness.cli.prepare",
|
|
14
|
-
summary: "
|
|
27
|
+
summary: "Run preparation checks; --refresh-record-neutral permits proven artifact-only receipt refresh.",
|
|
28
|
+
// The text prepare has always answered `--help` with, unchanged; the
|
|
29
|
+
// boundary is what prints it now, for every command rather than this one.
|
|
30
|
+
usage: `${USAGE}\nOrdinary prepare always runs mechanical checks. The refresh flag reuses prior success only when strict validation, policy, wiring and base are unchanged; otherwise it runs the checks.`,
|
|
15
31
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
32
|
+
if (context.args.length > 1 || (context.args.length === 1 && context.args[0] !== "--refresh-record-neutral")) {
|
|
33
|
+
return { kind: "usage", message: USAGE };
|
|
34
|
+
}
|
|
35
|
+
const refreshRecordNeutral = context.args[0] === "--refresh-record-neutral";
|
|
16
36
|
const wiring = await context.wire();
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
37
|
+
let retainedAttemptId: string | undefined;
|
|
38
|
+
let ownedAttemptId: string | undefined;
|
|
39
|
+
let refreshed = false;
|
|
40
|
+
try {
|
|
41
|
+
let capture: Awaited<ReturnType<typeof wiring.captureCandidate>>;
|
|
42
|
+
// A refresh continues the successful attempt it evaluated. It cannot
|
|
43
|
+
// transfer that success to new ownership after another attempt revokes it.
|
|
44
|
+
let reusableFingerprint: string | undefined;
|
|
45
|
+
let attemptId: string;
|
|
46
|
+
try {
|
|
47
|
+
capture = await wiring.captureCandidate();
|
|
48
|
+
if (capture.ok && refreshRecordNeutral) {
|
|
49
|
+
const previous = await evaluatePreparationReceipt(context.rootDir,
|
|
50
|
+
{ config: context.config, candidate: capture.candidate }, { ...wiring.storageOptions, allowValidationEquivalent: true });
|
|
51
|
+
if (previous.prepared && previous.receipt.attemptId !== undefined) {
|
|
52
|
+
reusableFingerprint = previous.receipt.preparationFingerprint;
|
|
53
|
+
retainedAttemptId = previous.receipt.attemptId;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
attemptId = retainedAttemptId ?? await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
|
|
58
|
+
ownedAttemptId = attemptId;
|
|
59
|
+
}
|
|
60
|
+
if (!capture.ok) {
|
|
61
|
+
return { kind: "blocked", blockers: [...capture.blockers] };
|
|
62
|
+
}
|
|
63
|
+
const fingerprint = await computePreparationFingerprint(context.rootDir, context.config, wiring.storageOptions);
|
|
64
|
+
const reusable = reusableFingerprint === fingerprint;
|
|
65
|
+
const preparation: RunPreparationObservation = reusable
|
|
66
|
+
? { checks: "reused", reason: "validation-equivalent" }
|
|
67
|
+
: { checks: "executed", reason: !refreshRecordNeutral ? "ordinary"
|
|
68
|
+
: retainedAttemptId === undefined ? "receipt-not-reusable" : "preparation-fingerprint-changed" };
|
|
69
|
+
if (!reusable && retainedAttemptId !== undefined) {
|
|
70
|
+
attemptId = await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
|
|
71
|
+
ownedAttemptId = attemptId;
|
|
72
|
+
retainedAttemptId = undefined;
|
|
73
|
+
}
|
|
74
|
+
const validationDigest = await computeDeliverableIdentity({ rootDir: context.rootDir, treeSha: capture.candidate.treeSha,
|
|
75
|
+
config: { ...context.config, computingIdentityVersion: "validation-tree/v1", reviewNeutral: context.config.recordNeutral } });
|
|
76
|
+
if (reusable) context.write("reusing preparation checks: strict validation projection, base, policy and wiring unchanged");
|
|
77
|
+
const exec = createExecPort();
|
|
78
|
+
const env = Object.fromEntries(Object.entries(context.env).filter((entry): entry is [string, string] => entry[1] !== undefined));
|
|
79
|
+
for (const check of reusable ? [] : context.config.preparationCommands ?? []) {
|
|
80
|
+
context.write(`preparing ${check.id}`);
|
|
81
|
+
const result = await exec.run({
|
|
82
|
+
command: check.command[0], args: check.command.slice(1), cwd: context.rootDir,
|
|
83
|
+
env, timeoutMs: check.timeoutMs, maxBuffer: 1024 * 1024,
|
|
84
|
+
...(context.signal === undefined ? {} : { signal: context.signal }),
|
|
85
|
+
});
|
|
86
|
+
if (result.code !== 0 || context.signal?.aborted) {
|
|
87
|
+
return {
|
|
88
|
+
kind: "blocked",
|
|
89
|
+
blockers: [createBlocker({
|
|
90
|
+
code: "preparation_command_failed", source: { kind: "command", id: "delivery-harness.cli.prepare" },
|
|
91
|
+
summary: `Preparation check ${check.id} failed; no receipt was published.`,
|
|
92
|
+
details: `exit ${result.code}${result.errorCode === undefined ? "" : ` (${result.errorCode})`}\n${`${result.stdout}\n${result.stderr}`.slice(-4000)}`,
|
|
93
|
+
remediations: [{ id: "repair-preparation-check", kind: "manual_action", summary: `Fix ${check.id}, then run prepare again.` }],
|
|
94
|
+
})],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (context.signal?.aborted) throw new CliInterruption();
|
|
99
|
+
const finalCapture = await wiring.captureCandidate();
|
|
100
|
+
if (!finalCapture.ok) return { kind: "blocked", blockers: [...finalCapture.blockers] };
|
|
101
|
+
const after = finalCapture.candidate;
|
|
102
|
+
if (classifyCandidateDrift(capture.candidate, after).length > 0 ||
|
|
103
|
+
capture.candidate.headSha !== after.headSha || capture.candidate.mode !== after.mode ||
|
|
104
|
+
fingerprint !== await computePreparationFingerprint(context.rootDir, context.config, wiring.storageOptions)) {
|
|
105
|
+
return {
|
|
106
|
+
kind: "blocked",
|
|
107
|
+
blockers: [createBlocker({
|
|
108
|
+
code: "preparation_candidate_changed", source: { kind: "command", id: "delivery-harness.cli.prepare" },
|
|
109
|
+
summary: "The candidate, base, or preparation wiring changed while checks ran; no receipt was published.",
|
|
110
|
+
remediations: [{ id: "prepare-stable-candidate", kind: "manual_action", summary: "Commit any intended changes and prepare the stable candidate again." }],
|
|
111
|
+
})],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const published = await publishPreparationReceipt(
|
|
115
|
+
context.rootDir,
|
|
116
|
+
{ config: context.config, candidate: capture.candidate, attemptId, validationDigest },
|
|
117
|
+
wiring.storageOptions,
|
|
118
|
+
);
|
|
119
|
+
if (context.signal?.aborted) throw new CliInterruption();
|
|
120
|
+
refreshed = true;
|
|
121
|
+
// The labelled line exists so a reader — an operator or a review round
|
|
122
|
+
// about to bind itself to this candidate — has one unambiguous token to
|
|
123
|
+
// copy. It is the same value the record carries as `treeSha` and the same
|
|
124
|
+
// value `review-context` reports as `candidate tree`.
|
|
125
|
+
return {
|
|
126
|
+
kind: "ok",
|
|
127
|
+
preparation,
|
|
128
|
+
summary: [
|
|
129
|
+
`prepared ${context.config.gateId}: tree ${capture.candidate.treeSha} (${capture.candidate.mode}); receipt ${published.path}`,
|
|
130
|
+
` treeSha ${capture.candidate.treeSha}`,
|
|
131
|
+
].join("\n"),
|
|
132
|
+
};
|
|
133
|
+
} finally {
|
|
134
|
+
if (ownedAttemptId !== undefined && !refreshed) {
|
|
135
|
+
await revokePreparationAttempt(context.rootDir, context.config, ownedAttemptId, wiring.storageOptions);
|
|
136
|
+
}
|
|
20
137
|
}
|
|
21
|
-
const published = await publishPreparationReceipt(
|
|
22
|
-
context.rootDir,
|
|
23
|
-
{ config: context.config, candidate: capture.candidate },
|
|
24
|
-
wiring.storageOptions,
|
|
25
|
-
);
|
|
26
|
-
// The labelled line exists so a reader — an operator or a review round
|
|
27
|
-
// about to bind itself to this candidate — has one unambiguous token to
|
|
28
|
-
// copy. It is the same value the record carries as `treeSha` and the same
|
|
29
|
-
// value `review-context` reports as `candidate tree`.
|
|
30
|
-
return {
|
|
31
|
-
kind: "ok",
|
|
32
|
-
summary: [
|
|
33
|
-
`prepared ${context.config.gateId}: tree ${capture.candidate.treeSha} (${capture.candidate.mode}); receipt ${published.path}`,
|
|
34
|
-
` treeSha ${capture.candidate.treeSha}`,
|
|
35
|
-
].join("\n"),
|
|
36
|
-
};
|
|
37
138
|
},
|
|
38
139
|
};
|
package/src/commands/record.ts
CHANGED
|
@@ -13,21 +13,64 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import {
|
|
15
15
|
buildDeliveryRecord,
|
|
16
|
+
candidateTreeEvidenceReader,
|
|
17
|
+
computePreparationFingerprint, capturePortableEvidenceContext, repositoryEvidenceReader, capturePortableVerificationInputs, verifyDeliveryRecord,
|
|
16
18
|
deliveryRecordBytes,
|
|
17
19
|
deliveryRecordPathFor,
|
|
18
20
|
discoverRecords,
|
|
19
21
|
type EvidenceRecord,
|
|
22
|
+
readCompiledRepositoryPolicy,
|
|
20
23
|
} from "@agent-delivery-harness/kernel";
|
|
21
24
|
import path from "node:path";
|
|
22
25
|
import { commandBlocker } from "../boundary.ts";
|
|
23
26
|
import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
|
|
27
|
+
import { oneLine } from "../run-surface.ts";
|
|
28
|
+
import { applyDeliveryRecordRetention } from "../record-retention.ts";
|
|
24
29
|
import { runProviderBackedAdmission } from "./gate.ts";
|
|
25
30
|
|
|
31
|
+
interface RetentionOptions {
|
|
32
|
+
readonly scope: string;
|
|
33
|
+
readonly keepSuperseded: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseRetentionOptions(args: readonly string[]): RetentionOptions | undefined | string {
|
|
37
|
+
if (args.length === 0) return undefined;
|
|
38
|
+
let scope: string | undefined;
|
|
39
|
+
let keepSuperseded: number | undefined;
|
|
40
|
+
for (let index = 0; index < args.length; index += 2) {
|
|
41
|
+
const flag = args[index];
|
|
42
|
+
const value = args[index + 1];
|
|
43
|
+
if (value === undefined) return `${flag ?? "record option"} requires a value`;
|
|
44
|
+
if (flag === "--retention-scope" && scope === undefined) scope = value;
|
|
45
|
+
else if (flag === "--keep-superseded" && keepSuperseded === undefined) {
|
|
46
|
+
if (!/^\d+$/.test(value)) return "--keep-superseded must be an integer from 0 through 100";
|
|
47
|
+
keepSuperseded = Number(value);
|
|
48
|
+
} else return `${oneLine(flag ?? "record option", 64)} is not a valid record retention option`;
|
|
49
|
+
}
|
|
50
|
+
if (scope === undefined || keepSuperseded === undefined) {
|
|
51
|
+
return "--retention-scope and --keep-superseded must be supplied together";
|
|
52
|
+
}
|
|
53
|
+
if (!Number.isSafeInteger(keepSuperseded) || keepSuperseded > 100) {
|
|
54
|
+
return "--keep-superseded must be an integer from 0 through 100";
|
|
55
|
+
}
|
|
56
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(scope)) {
|
|
57
|
+
return "--retention-scope must be a plain 1-128 character delivery key";
|
|
58
|
+
}
|
|
59
|
+
return { scope, keepSuperseded };
|
|
60
|
+
}
|
|
61
|
+
|
|
26
62
|
export const recordCommand: CommandDescriptor = {
|
|
27
63
|
name: "record",
|
|
28
64
|
sourceId: "delivery-harness.cli.record",
|
|
29
65
|
summary: "Write the tracked delivery record for an admitted gate.",
|
|
66
|
+
usage: "Usage: delivery-harness record [--retention-scope <delivery-key> --keep-superseded <0-100>]\nRetention is opt-in; run gate first if a waiver is needed.",
|
|
30
67
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
68
|
+
const observedAt = `${new Date().toISOString().slice(0, 19)}Z`;
|
|
69
|
+
// Arguments before anything is wired, admitted, or written.
|
|
70
|
+
const retention = parseRetentionOptions(context.args);
|
|
71
|
+
if (typeof retention === "string") {
|
|
72
|
+
return { kind: "usage", message: `${retention}.\n${recordCommand.usage}` };
|
|
73
|
+
}
|
|
31
74
|
const wiring = await context.wire();
|
|
32
75
|
|
|
33
76
|
// The gate is run without a prompt: `record` is not the waiver surface. If a
|
|
@@ -79,15 +122,67 @@ export const recordCommand: CommandDescriptor = {
|
|
|
79
122
|
evidenceRecords.push(...discovery.records);
|
|
80
123
|
}
|
|
81
124
|
|
|
82
|
-
const
|
|
125
|
+
const evidenceReader = repositoryEvidenceReader(context.rootDir, context.artifacts);
|
|
126
|
+
const evidenceContext = await capturePortableEvidenceContext(context.config, evidenceReader,
|
|
127
|
+
await computePreparationFingerprint(context.rootDir, context.config));
|
|
128
|
+
const compiledPolicy = context.policyBinding?.compiledPolicy ??
|
|
129
|
+
await readCompiledRepositoryPolicy(await candidateTreeEvidenceReader(context.rootDir, recheck.candidate.treeSha));
|
|
130
|
+
const built = buildDeliveryRecord({ config: context.config, decision, evidenceRecords, context: evidenceContext,
|
|
131
|
+
...(compiledPolicy === null ? {} : { compiledPolicy, observedAt }) });
|
|
83
132
|
if (!built.ok) {
|
|
84
133
|
return { kind: "blocked", blockers: [...built.blockers] };
|
|
85
134
|
}
|
|
86
135
|
|
|
136
|
+
const verificationInputs = await capturePortableVerificationInputs(context.rootDir, context.config, recheck.candidate, built.record);
|
|
137
|
+
const checked = verifyDeliveryRecord(context.config, built.record,
|
|
138
|
+
{ deliverableDigest: recheck.candidate.deliverable.digest, identityToken: recheck.candidate.deliverable.identity }, recheck.candidate.base,
|
|
139
|
+
{ ...verificationInputs, observedAt, liveResults: admission.observedLiveResults ?? [], executionContext: context.classifyContext() });
|
|
140
|
+
if (!checked.ok) return { kind: "blocked", blockers: [...checked.blockers] };
|
|
87
141
|
const relativePath = deliveryRecordPathFor(context.config, decision.candidate.deliverable.digest);
|
|
88
142
|
const absolutePath = path.join(context.rootDir, relativePath);
|
|
89
|
-
|
|
143
|
+
const bytes = deliveryRecordBytes(built.record);
|
|
144
|
+
if (retention === undefined) {
|
|
145
|
+
await context.artifacts.writeTextFile(absolutePath, bytes);
|
|
146
|
+
return { kind: "ok", summary: `recorded ${relativePath}` };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const retained = await applyDeliveryRecordRetention({
|
|
150
|
+
rootDir: context.rootDir,
|
|
151
|
+
storageNamespace: context.config.storageNamespace,
|
|
152
|
+
scope: retention.scope,
|
|
153
|
+
keepSuperseded: retention.keepSuperseded,
|
|
154
|
+
recordBasePath: context.config.deliveryRecordPath,
|
|
155
|
+
current: {
|
|
156
|
+
relativePath,
|
|
157
|
+
deliverableDigest: decision.candidate.deliverable.digest,
|
|
158
|
+
bytes,
|
|
159
|
+
},
|
|
160
|
+
writeCurrent: () => context.artifacts.writeTextFile(absolutePath, bytes),
|
|
161
|
+
});
|
|
162
|
+
if (!retained.ok) {
|
|
163
|
+
return {
|
|
164
|
+
kind: "blocked",
|
|
165
|
+
blockers: [
|
|
166
|
+
commandBlocker({
|
|
167
|
+
code: retained.code,
|
|
168
|
+
sourceId: "delivery-harness.cli.record",
|
|
169
|
+
summary: "Tracked delivery-record retention did not complete.",
|
|
170
|
+
details: retained.detail,
|
|
171
|
+
remediations: [
|
|
172
|
+
{
|
|
173
|
+
id: "repair-record-retention",
|
|
174
|
+
kind: "manual_action",
|
|
175
|
+
summary: "Preserve the exact owned record bytes in Git, repair the named ownership conflict, then retry record with the same scope and bound.",
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
}),
|
|
179
|
+
],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
90
182
|
|
|
91
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
kind: "ok",
|
|
185
|
+
summary: `recorded ${relativePath}${retained.pruned.length === 0 ? "" : `; pruned Git-preserved ${retained.pruned.join(", ")}`}`,
|
|
186
|
+
};
|
|
92
187
|
},
|
|
93
188
|
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { classifyCandidateDrift, digestCanonical, evaluatePreparationReceipt, runAdmission, type CandidateBinding } from "@agent-delivery-harness/kernel";
|
|
2
|
+
import { installedRelease, policyDigest, reconciliationActions, recoveryBlocker, recoveryRun } from "../ordinary-context.ts";
|
|
3
|
+
import type { CommandDescriptor } from "../boundary.ts";
|
|
4
|
+
|
|
5
|
+
const USAGE = "Usage: delivery-harness resume [--run <run-id>]";
|
|
6
|
+
|
|
7
|
+
export const resumeCommand: CommandDescriptor = {
|
|
8
|
+
name: "resume", sourceId: "delivery-harness.cli.resume",
|
|
9
|
+
summary: "Read saved ordinary context and recheck evidence without executing or replaying work.",
|
|
10
|
+
usage: USAGE,
|
|
11
|
+
async run(context) {
|
|
12
|
+
if (context.args.length !== 0 && (context.args.length !== 2 || context.args[0] !== "--run")) return { kind: "usage", message: USAGE };
|
|
13
|
+
const run = await recoveryRun(context.rootDir, context.args[1]);
|
|
14
|
+
if (!run.ok) return { kind: "blocked", blockers: [run.blocker] };
|
|
15
|
+
const saved = [...run.events].reverse().find(event => event.kind === "context.saved");
|
|
16
|
+
if (!saved) return { kind: "blocked", blockers: [recoveryBlocker("resume_context_missing", "This run has no saved ordinary context. Recover the contract from its work item and save it explicitly.")] };
|
|
17
|
+
const p = saved.payload;
|
|
18
|
+
const actions = reconciliationActions(run.events);
|
|
19
|
+
const blockers = actions.some(action => action.outcome === "unknown" || action.inconsistent)
|
|
20
|
+
? [recoveryBlocker("resume_action_unreconciled", "An external action has no consistent observed outcome. Reconcile its reference with host tools before any repeat.")] : [];
|
|
21
|
+
const wiring = await context.wire();
|
|
22
|
+
const capture = await wiring.captureCandidate();
|
|
23
|
+
const drift: string[] = [];
|
|
24
|
+
if (p["policyDigest"] !== policyDigest(context)) drift.push("policy_changed");
|
|
25
|
+
try { if (digestCanonical(p["release"]) !== digestCanonical(await installedRelease(context.rootDir))) drift.push("release_changed"); }
|
|
26
|
+
catch { drift.push("release_unreadable"); }
|
|
27
|
+
if (capture.ok) {
|
|
28
|
+
const binding = p["candidateBinding"] as Record<string, string>;
|
|
29
|
+
const expected: CandidateBinding = { treeSha: saved.candidateTreeSha!, deliverable: { digest: binding["deliverableDigest"]!, identity: binding["identity"]! },
|
|
30
|
+
base: { ref: binding["baseRef"]!, tipSha: binding["baseTipSha"]!, mergeBaseSha: binding["mergeBaseSha"]! }, workspaceId: binding["workspaceId"]! };
|
|
31
|
+
drift.push(...classifyCandidateDrift(expected, capture.candidate));
|
|
32
|
+
if (expected.deliverable.identity !== capture.candidate.deliverable.identity) drift.push("identity_changed");
|
|
33
|
+
if (expected.base.ref !== capture.candidate.base.ref) drift.push("base_ref_changed");
|
|
34
|
+
} else blockers.push(...capture.blockers);
|
|
35
|
+
// Reuse the ordinary sensors, with no prompt, live-result injection, or
|
|
36
|
+
// provider invocation. They read receipts/evidence; this journal grants nothing.
|
|
37
|
+
const preparation = capture.ok ? await evaluatePreparationReceipt(context.rootDir, { config: context.config, candidate: capture.candidate }, wiring.storageOptions) : undefined;
|
|
38
|
+
const admission = capture.ok ? await runAdmission({ rootDir: context.rootDir, config: context.config, context: context.classifyContext() },
|
|
39
|
+
{ captureCandidate: wiring.captureCandidate, projectActivation: wiring.projectActivation, ...wiring.storageOptions }) : undefined;
|
|
40
|
+
if (capture.ok && admission?.candidate && classifyCandidateDrift(capture.candidate, admission.candidate).length > 0) drift.push("candidate_observation_changed");
|
|
41
|
+
try { if (digestCanonical(p["release"]) !== digestCanonical(await installedRelease(context.rootDir)) && !drift.includes("release_changed")) drift.push("release_changed"); }
|
|
42
|
+
catch { if (!drift.includes("release_unreadable")) drift.push("release_unreadable"); }
|
|
43
|
+
if (admission) blockers.push(...admission.blockers);
|
|
44
|
+
// Raw-tree movement alone is reported, but existing sensors decide whether
|
|
45
|
+
// the change is neutral. No new identity or freshness rule is introduced.
|
|
46
|
+
if (drift.some(item => item !== "raw_tree_changed")) blockers.push(recoveryBlocker("resume_context_stale", "Saved candidate, policy, or release bindings changed; revalidate the contract and affected work before saving fresh context."));
|
|
47
|
+
const reuseAllowed = blockers.length === 0 && admission?.admitted === true;
|
|
48
|
+
context.write(JSON.stringify({ spec: "ordinary-resume/1", runId: run.runId,
|
|
49
|
+
observationOnly: true, automaticReplay: false, savedContext: p, actions, drift,
|
|
50
|
+
preparation: preparation ?? { prepared: false }, admission: admission ?? { admitted: false }, reuseAllowed }));
|
|
51
|
+
return blockers.length > 0 ? { kind: "blocked", blockers } : { kind: "ok" };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -8,12 +8,19 @@
|
|
|
8
8
|
import { evaluatePreparationReceipt } from "@agent-delivery-harness/kernel";
|
|
9
9
|
import { commandBlocker } from "../boundary.ts";
|
|
10
10
|
import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
|
|
11
|
+
import { buildReviewContext, OutcomeError } from "../review-evidence.ts";
|
|
12
|
+
|
|
13
|
+
const USAGE = "Usage: delivery-harness review-context [--json]";
|
|
11
14
|
|
|
12
15
|
export const reviewContextCommand: CommandDescriptor = {
|
|
13
16
|
name: "review-context",
|
|
14
17
|
sourceId: "delivery-harness.cli.review-context",
|
|
15
18
|
summary: "Show the reviewable-change context for the prepared candidate.",
|
|
19
|
+
usage: USAGE,
|
|
16
20
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
21
|
+
if (context.args.length > 0 && !(context.args.length === 1 && context.args[0] === "--json")) {
|
|
22
|
+
return { kind: "usage", message: `review-context accepts only --json.\n${USAGE}` };
|
|
23
|
+
}
|
|
17
24
|
const wiring = await context.wire();
|
|
18
25
|
const capture = await wiring.captureCandidate();
|
|
19
26
|
if (!capture.ok) {
|
|
@@ -49,6 +56,18 @@ export const reviewContextCommand: CommandDescriptor = {
|
|
|
49
56
|
return { kind: "blocked", blockers };
|
|
50
57
|
}
|
|
51
58
|
|
|
59
|
+
if (context.args[0] === "--json") {
|
|
60
|
+
try {
|
|
61
|
+
const document = await buildReviewContext(context.rootDir, context.config, capture.candidate, evaluation.receipt);
|
|
62
|
+
return { kind: "ok", summary: JSON.stringify(document, null, 2) };
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (!(error instanceof OutcomeError)) throw error;
|
|
65
|
+
return { kind: "blocked", blockers: [commandBlocker({
|
|
66
|
+
code: "review_context_unavailable", sourceId: "delivery-harness.cli.review-context", summary: error.message,
|
|
67
|
+
remediations: [{ id: "restore-review-inputs", kind: "manual_action", summary: "Restore the installed workflow release and compiled policy charters before acquiring review." }],
|
|
68
|
+
})] };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
52
71
|
const projection = await wiring.projectActivation(capture.candidate);
|
|
53
72
|
const active = projection.relevantLineCount >= context.config.activationThreshold || projection.hasRelevantBinaryChange;
|
|
54
73
|
return {
|