sortie-dogs 0.12.9 → 0.12.11
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/dist/asset-version.d.ts +1 -1
- package/dist/asset-version.js +1 -1
- package/dist/core/goal-bound.d.ts +2 -0
- package/dist/core/goal-bound.js +2 -1
- package/dist/core/operator-mission.js +23 -13
- package/dist/core/operator-runtime.d.ts +5 -1
- package/dist/core/operator-runtime.js +40 -23
- package/dist/core/path.d.ts +7 -0
- package/dist/core/path.js +15 -0
- package/dist/core/validate-manifest.js +8 -4
- package/dist/plugin/gate.js +79 -9
- package/dist/plugin/goal-completion.d.ts +17 -0
- package/dist/plugin/goal-completion.js +55 -0
- package/dist/plugin/index.js +26 -115
- package/dist/plugin/mission-review.js +2 -1
- package/dist/plugin/profiled.js +27 -12
- package/dist/plugin/protected-snapshot.d.ts +18 -0
- package/dist/plugin/protected-snapshot.js +98 -0
- package/dist/plugin/runtime-bridge.d.ts +2 -0
- package/dist/runtime-mission-assets.js +29 -1
- package/package.json +1 -1
package/dist/asset-version.d.ts
CHANGED
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
* installed project marker without importing every asset body.
|
|
4
4
|
*/
|
|
5
5
|
export declare const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
|
|
6
|
-
export declare const V010_RUNTIME_ASSET_VERSION = "0.12.
|
|
6
|
+
export declare const V010_RUNTIME_ASSET_VERSION = "0.12.11-operation-recovery-v1";
|
|
7
7
|
export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION;
|
package/dist/asset-version.js
CHANGED
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
* installed project marker without importing every asset body.
|
|
4
4
|
*/
|
|
5
5
|
export const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
|
|
6
|
-
export const V010_RUNTIME_ASSET_VERSION = "0.12.
|
|
6
|
+
export const V010_RUNTIME_ASSET_VERSION = "0.12.11-operation-recovery-v1";
|
|
@@ -37,6 +37,8 @@ export interface GoalEvidence {
|
|
|
37
37
|
readonly manifest_path: string;
|
|
38
38
|
readonly source_paths: readonly string[];
|
|
39
39
|
readonly candidate_paths: readonly string[];
|
|
40
|
+
/** Missing on legacy evidence: retain its original all-paths snapshot recipe. */
|
|
41
|
+
readonly source_policy?: "project-files-v1";
|
|
40
42
|
};
|
|
41
43
|
readonly execution: {
|
|
42
44
|
readonly command: readonly string[];
|
package/dist/core/goal-bound.js
CHANGED
|
@@ -61,7 +61,8 @@ export function validGoalEvidence(value, state) {
|
|
|
61
61
|
const protectedBindingValid = protectedBinding !== undefined && HASH.test(protectedBinding.manifest_hash) &&
|
|
62
62
|
text(protectedBinding.project_root) && text(protectedBinding.manifest_path) && Array.isArray(protectedBinding.source_paths) &&
|
|
63
63
|
protectedBinding.source_paths.every(text) && Array.isArray(protectedBinding.candidate_paths) &&
|
|
64
|
-
protectedBinding.candidate_paths.every(text)
|
|
64
|
+
protectedBinding.candidate_paths.every(text) &&
|
|
65
|
+
(protectedBinding.source_policy === undefined || protectedBinding.source_policy === "project-files-v1");
|
|
65
66
|
const matches = criteria.length > 0 && criteria.length === value.measurement.criterion_ids.length &&
|
|
66
67
|
criteria.every((criterion) => criterion.target === value.measurement.target &&
|
|
67
68
|
criterion.entrypoint === value.measurement.entrypoint && criterion.workload === value.measurement.workload &&
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { normalizeRelativeScope } from "./path.js";
|
|
5
5
|
import { parseOperatorPlan } from "./operator-runtime.js";
|
|
6
6
|
import { profileAgent } from "./runtime-profile.js";
|
|
7
7
|
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
@@ -286,7 +286,7 @@ export function missionPlan(mission, raw) {
|
|
|
286
286
|
const entries = value[field] ?? [];
|
|
287
287
|
if (!Array.isArray(entries) || !entries.every(item => typeof item === "string"))
|
|
288
288
|
throw new Error(`mission-unit-${index + 1}: ${field} must be paths`);
|
|
289
|
-
return [...new Set(entries.map(item =>
|
|
289
|
+
return [...new Set(entries.map(item => normalizeRelativeScope(item)))];
|
|
290
290
|
};
|
|
291
291
|
const ids = value.requirement_ids ?? mission.requirements.map(item => item.id);
|
|
292
292
|
if (!Array.isArray(ids) || ids.length === 0 || !ids.every(id => mission.requirements.some(item => item.id === id))) {
|
|
@@ -331,26 +331,36 @@ export function missionPlan(mission, raw) {
|
|
|
331
331
|
units });
|
|
332
332
|
}
|
|
333
333
|
export function missionPacket(mission, run) {
|
|
334
|
+
const currentReview = mission.review !== undefined && mission.review.runID === (run?.runID ?? mission.runID);
|
|
335
|
+
const reviewAccepted = currentReview && missionReviewAccepted(mission.review);
|
|
334
336
|
return { mission_id: mission.id, phase: mission.phase, coordinator_session_id: mission.coordinator,
|
|
335
337
|
requirements: mission.requirements, original_request_refs: mission.requests.map(item => `user:${item.id}`),
|
|
336
338
|
submission: mission.submission, progress: mission.progress,
|
|
339
|
+
execution_summary: { completed_units: run?.units.filter(unit => unit.status === "succeeded").length ?? 0,
|
|
340
|
+
running_units: run?.units.filter(unit => unit.status === "running").map(unit => ({ id: unit.unit.id, title: unit.unit.title, child_session_id: unit.childSessionID })) ?? [],
|
|
341
|
+
pending_units: run?.units.filter(unit => unit.status === "pending").length ?? 0,
|
|
342
|
+
failed_units: run?.units.filter(unit => unit.status === "failed").length ?? 0,
|
|
343
|
+
historical_failed_attempts: mission.progress.filter(unit => unit.status === "failed").length,
|
|
344
|
+
accepted: mission.phase === "completed" },
|
|
337
345
|
review: mission.review ? { risk_tags: mission.review.risk, verdict: mission.review.verdict,
|
|
346
|
+
run_id: mission.review.runID, current_run: currentReview,
|
|
338
347
|
source_fingerprint: mission.review.source, reviewer_session_id: mission.review.child ?? null,
|
|
339
348
|
result: mission.review.result ?? null, evidence_gap_reviews: mission.review.evidenceGapReviews ?? 0,
|
|
340
|
-
evidence_gap_review_limit: MISSION_EVIDENCE_GAP_REVIEW_LIMIT, accepted:
|
|
341
|
-
passed: mission.review.verdict === "PASS", permits_submission:
|
|
349
|
+
evidence_gap_review_limit: MISSION_EVIDENCE_GAP_REVIEW_LIMIT, accepted: reviewAccepted,
|
|
350
|
+
passed: currentReview && mission.review.verdict === "PASS", permits_submission: reviewAccepted } : null,
|
|
342
351
|
...(run ? { run_id: run.runID, status: run.phase, decision: run.decision,
|
|
343
352
|
units: run.units.map(unit => ({ id: unit.unit.id, title: unit.unit.title, status: unit.status,
|
|
344
353
|
child_session_id: unit.childSessionID, result_class: unit.resultClass, evidence: unit.evidence })) } : {}),
|
|
345
|
-
next_action: mission.phase === "
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
: run?.phase === "awaiting-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
+
next_action: mission.phase === "completed" ? "Mission completed. Report the accepted result and retained review gaps; no further dispatch or completion call is needed."
|
|
355
|
+
: mission.phase === "submitted" && mission.submission?.status === "ready"
|
|
356
|
+
? "Operator: compare the submitted candidate with the original requirements and actual evidence, then complete_mission if satisfied. Report remaining evidence gaps; they are not a review PASS."
|
|
357
|
+
: run?.phase === "awaiting-decision" ? "Coordinator: correct the cause and call plan_units with the remaining work and all requirements; budget is cumulative."
|
|
358
|
+
: run?.phase === "awaiting-acceptance" ? (currentReview && mission.review?.verdict === "evidence-gaps" && !reviewAccepted
|
|
359
|
+
? "Coordinator: the Reviewer found only missing evidence. Supply it through review_mission traces (or at most one evidence-only unit) and re-review once; do not re-implement. At the evidence-gap limit, submit_mission ready with the gaps listed."
|
|
360
|
+
: reviewAccepted
|
|
361
|
+
? "Coordinator: review permits submission. Submit the candidate with any remaining gaps; do not repeat passed validation or review. Operator performs final acceptance."
|
|
362
|
+
: "Coordinator: address recorded findings or obtain the required independent review, then submit_mission. Operator compares all requirements with source/evidence before complete_mission.")
|
|
363
|
+
: "Coordinator: continue the next useful unit within original requirements. Return only a completion candidate, user-only decision, or scope/budget extension." };
|
|
354
364
|
}
|
|
355
365
|
/** Models forward a short capability, never recopy the host's evidence hashes and source packet. */
|
|
356
366
|
export function missionReviewTask(mission) {
|
|
@@ -248,7 +248,11 @@ export declare class OperatorRuntime {
|
|
|
248
248
|
sessionID: string;
|
|
249
249
|
callID: string;
|
|
250
250
|
}, supersededRunID?: string, terminalChildren?: readonly string[]): Promise<OperatorState>;
|
|
251
|
-
|
|
251
|
+
/** Stage a settled mission's replacement; rejected preparation never cancels the usable run. */
|
|
252
|
+
replanMission(root: string, runID: string, raw: unknown, dispatcher?: {
|
|
253
|
+
sessionID: string;
|
|
254
|
+
callID: string;
|
|
255
|
+
}): Promise<OperatorState>;
|
|
252
256
|
private prepareOnce;
|
|
253
257
|
operatorTask(state: OperatorState): OperatorTask;
|
|
254
258
|
/** Root-visible handle for the bounded operations delegate; its contract stays host-internal. */
|
|
@@ -5,7 +5,7 @@ import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { acceptanceContinuityFingerprint, inspectAcceptanceContinuity, normalizeAcceptanceCriteria, ACCEPTANCE_CONTINUITY_EXTENSION, MAX_ACCEPTANCE_CONTINUITY_BYTES, MAX_ACCEPTANCE_CRITERIA } from "./acceptance-continuity.js";
|
|
7
7
|
import { expandGoalDeclaration, goalDeclarationDefaults, goalDeclarationFieldDiagnostics, hasGoalCommandAliasConflict } from "./goal-declaration-format.js";
|
|
8
|
-
import { normalizeManifestPath, normalizeRelativePath } from "./path.js";
|
|
8
|
+
import { normalizeManifestPath, normalizeManifestScope, normalizeRelativePath } from "./path.js";
|
|
9
9
|
import { CONTRACT_TEXT_LIMITS, validateHandoffSchema, validateOperationManifestSchema } from "./validate-schema.js";
|
|
10
10
|
import { validateManifest } from "./validate-manifest.js";
|
|
11
11
|
import { profileAgent, RUNTIME_PROFILES } from "./runtime-profile.js";
|
|
@@ -44,7 +44,10 @@ const text = (value) => typeof value === "string" && value.trim().length > 0 &&
|
|
|
44
44
|
export function operatorGitPathAuthorized(path, scopes, platform = process.platform) {
|
|
45
45
|
const key = (value) => platform === "win32" ? value.toLowerCase() : value;
|
|
46
46
|
const candidate = key(path);
|
|
47
|
-
return scopes.some(scope =>
|
|
47
|
+
return scopes.some(scope => {
|
|
48
|
+
const base = key(normalizeManifestScope(scope).path);
|
|
49
|
+
return candidate === base || candidate.startsWith(`${base}/`);
|
|
50
|
+
});
|
|
48
51
|
}
|
|
49
52
|
const contractError = (diagnostic) => { throw new OperatorContractError([diagnostic]); };
|
|
50
53
|
const planError = (pointer, code, rule, repair_kind = "repair-field") => contractError({ document: "plan", pointer, code, rule, repair_kind });
|
|
@@ -175,7 +178,7 @@ export function parseOperatorPlan(value) {
|
|
|
175
178
|
for (const [unitIndex, unit] of value.units.entries()) {
|
|
176
179
|
if (!record(unit) || !exactKeys(unit, ["id", "title", "objective", "read", "write", "validation", "acceptance_indices"]) ||
|
|
177
180
|
!identifier(unit.id) || ids.has(unit.id) || !text(unit.title) || !text(unit.objective) ||
|
|
178
|
-
!strings(unit.read) || !strings(unit.write
|
|
181
|
+
!strings(unit.read) || !strings(unit.write) || !strings(unit.validation, true))
|
|
179
182
|
return planError(`/units/${unitIndex}`, "operator-unit-invalid", "operator-unit-shape");
|
|
180
183
|
unit.validation.forEach((command, index) => rejectValidationAnnotation(command, `/units/${unitIndex}/validation/${index}`));
|
|
181
184
|
ids.add(unit.id);
|
|
@@ -686,19 +689,9 @@ export class OperatorRuntime {
|
|
|
686
689
|
prepareMission(root, raw, dispatcher, supersededRunID, terminalChildren = []) {
|
|
687
690
|
return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher, supersededRunID, terminalChildren }));
|
|
688
691
|
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
if (state.units.some(unit => unit.status === "running") || state.gitLifecycle !== null)
|
|
693
|
-
throw new Error("mission-replan-worker-still-active");
|
|
694
|
-
if (["cancelled", "completed"].includes(state.phase))
|
|
695
|
-
return;
|
|
696
|
-
// Keep the old run and proof for inspection. Replanning does not erase failed checks or spend.
|
|
697
|
-
await writeFile(`${this.file(root)}.${state.runID}.archive`, JSON.stringify(state), { flag: "wx", mode: 0o600 });
|
|
698
|
-
state.phase = "cancelled";
|
|
699
|
-
state.decision = "mission-replan";
|
|
700
|
-
await this.save(state);
|
|
701
|
-
});
|
|
692
|
+
/** Stage a settled mission's replacement; rejected preparation never cancels the usable run. */
|
|
693
|
+
replanMission(root, runID, raw, dispatcher) {
|
|
694
|
+
return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher, replaceRunID: runID }));
|
|
702
695
|
}
|
|
703
696
|
async prepareOnce(root, raw, scopeApprovalTurnID, mission) {
|
|
704
697
|
const previous = await this.read(root);
|
|
@@ -725,6 +718,16 @@ export class OperatorRuntime {
|
|
|
725
718
|
}
|
|
726
719
|
const plan = parseOperatorPlan(immutableReplacement);
|
|
727
720
|
const validatedPlanHash = hash(JSON.stringify(plan));
|
|
721
|
+
const replanning = mission?.replaceRunID !== undefined;
|
|
722
|
+
if (replanning) {
|
|
723
|
+
if (!previous || previous.runID !== mission.replaceRunID || ["cancelled", "completed"].includes(previous.phase)) {
|
|
724
|
+
throw new Error("mission-replan-run-mismatch");
|
|
725
|
+
}
|
|
726
|
+
if (previous.units.some(unit => unit.status === "running") || previous.gitLifecycle !== null)
|
|
727
|
+
throw new Error("mission-replan-worker-still-active");
|
|
728
|
+
if (previous.planHash === validatedPlanHash)
|
|
729
|
+
return previous;
|
|
730
|
+
}
|
|
728
731
|
if (mission?.supersededRunID !== undefined && previous?.supersededRunID === mission.supersededRunID &&
|
|
729
732
|
previous.planHash === validatedPlanHash && previous.phase !== "cancelled")
|
|
730
733
|
return previous;
|
|
@@ -750,14 +753,17 @@ export class OperatorRuntime {
|
|
|
750
753
|
if (plan.git_lifecycle !== undefined) {
|
|
751
754
|
await ensureGitManagedStateExcluded(this.projectRoot, this.profile, this.gitPath);
|
|
752
755
|
}
|
|
753
|
-
if (previous && !["completed", "cancelled"].includes(previous.phase)) {
|
|
756
|
+
if (previous && !replanning && !["completed", "cancelled"].includes(previous.phase)) {
|
|
754
757
|
if (previous.planHash === validatedPlanHash)
|
|
755
758
|
return previous;
|
|
756
759
|
throw new Error("operator-active-contract-immutable");
|
|
757
760
|
}
|
|
758
761
|
// Cancellation stops execution, not the accepted user order. A replacement
|
|
759
762
|
// plan must carry the original ordered criteria; only a completed root clears them.
|
|
760
|
-
|
|
763
|
+
// Treat the predecessor as retired only in this staged replacement. Its durable state remains
|
|
764
|
+
// intact through schema checks, control writes and archive creation, until save atomically switches it.
|
|
765
|
+
const parent = replanning ? { ...previous, phase: "cancelled", decision: "mission-replan" }
|
|
766
|
+
: (!superseding || retainAcceptance) && previous?.phase === "cancelled" ? previous : undefined;
|
|
761
767
|
if (parent && ["explicit-cancellation", "agent-changed"].includes(parent.decision ?? "")) {
|
|
762
768
|
const cancelled = parent.units.flatMap(unit => (parent.decision === "agent-changed" || unit.status === "cancelled") && unit.childSessionID !== null
|
|
763
769
|
? [unit.childSessionID] : []);
|
|
@@ -897,13 +903,15 @@ export class OperatorRuntime {
|
|
|
897
903
|
const contents = [JSON.stringify(handoff), JSON.stringify(manifest)];
|
|
898
904
|
controls.push({ path: handoffPath, content: contents[0] }, { path: manifestPath, content: contents[1] });
|
|
899
905
|
const prompt = ["role: implementation", `task_id: ${taskID}`, `project_root: ${this.projectRoot}`,
|
|
900
|
-
`source_manifest: ${unit.write.join(", ")}`, `operation_manifest: ${manifestRelative}`, `handoff_path: ${handoffPath}`,
|
|
906
|
+
`source_manifest: ${(unit.write.length ? unit.write : unit.read).join(", ") || "none"}`, `operation_manifest: ${manifestRelative}`, `handoff_path: ${handoffPath}`,
|
|
901
907
|
`goal_declaration_path: ${declarationPath}`, "acceptance:", ...plan.acceptance.map(value => ` - ${value}`),
|
|
902
908
|
"validation:", ...unit.validation.map(value => ` - ${value}`),
|
|
903
909
|
mission ? "Read-only investigation commands are unrestricted. Use shell to reproduce and diagnose without asking for command registration. Keep all writes, including generated/transient outputs and cleanup, inside unit.write. Run formal validation exactly as listed, in order and in separate calls, so the host records its real result. If a write scope or formal check must change, return the precise change to your Coordinator; it can extend/redeclare immediately within the original requirements. Diagnostic success is not formal acceptance evidence."
|
|
904
910
|
: "Execute validation in its declared order. Earlier entries may be approved generator, build, formatter, or exact cleanup commands required before canonical criterion tests. Every persistent or transient generator output must be declared in unit.write. Cleanup may remove only declared unit.write outputs and must be an explicit ordered command after generation and before post-commit or canonical validation; never add an ignore rule or remove an undeclared path. If any necessary command, input, output, or cleanup is missing, do not run an undeclared command or variant and do not use resume evidence tooling to invent permission; return a contract-repair decision.",
|
|
905
911
|
"Preserve existing public API success and error return semantics unless acceptance explicitly changes them, and cover those compatibility boundaries in the declared validation.",
|
|
906
|
-
|
|
912
|
+
mission
|
|
913
|
+
? "Do not spawn nested subagents for consultation. Required technical or user decisions that must precede implementation belong to the root before dispatch; use the confirmed decisions and evidence declared in the unit objective and inputs. If a required prior decision is missing, return the exact contract gap to the parent instead of implementing dependent changes, inventing consent, or asking the user to repeat an already recorded decision. Independent SourceReview is a separate post-implementation and post-validation review: do not require it before implementation or formal validation, and do not stop this Task solely because it has not yet occurred. Complete the assigned implementation and all listed formal validation commands, then return the real evidence so the Coordinator can dispatch the independent review_mission with applicable risk_tags (for example, public-logic). Do not conduct or delegate that independent review yourself. Reflect supplied prior Reviewer FINDINGS within this unit's scope."
|
|
914
|
+
: "Do not spawn nested subagents for consultation. Required consultations belong to the root before dispatch; use the confirmed decisions and evidence declared in the unit objective and inputs. If required consultation results or user decisions are missing, return the exact contract gap to the parent instead of attempting a deeper Task, inventing consent, or asking the user to repeat an already recorded decision.",
|
|
907
915
|
...(plan.git_lifecycle !== undefined && index === plan.units.length - 1 ? [
|
|
908
916
|
`git_post_commit_validation: ${JSON.stringify(plan.git_lifecycle.post_commit_validation)}`,
|
|
909
917
|
"Complete every source write before invoking any git_post_commit_validation command. Its first exact invocation is the host commit boundary; after it, source mutation and undeclared shell commands are denied. Run every listed command and return its real evidence.",
|
|
@@ -944,10 +952,19 @@ export class OperatorRuntime {
|
|
|
944
952
|
await handle.close();
|
|
945
953
|
}
|
|
946
954
|
}
|
|
947
|
-
if (superseding && previous) {
|
|
948
|
-
|
|
955
|
+
if ((superseding || replanning) && previous) {
|
|
956
|
+
// A crash before the state switch may leave a checkpoint while the old run later advances.
|
|
957
|
+
// Sequence-qualified replan snapshots keep that history without blocking a subsequent correction.
|
|
958
|
+
const archive = `${this.file(root)}.${previous.runID}${replanning ? `.${previous.sequence}` : ""}.archive`;
|
|
949
959
|
try {
|
|
950
|
-
await
|
|
960
|
+
const handle = await open(archive, "wx", 0o600);
|
|
961
|
+
created.push(archive);
|
|
962
|
+
try {
|
|
963
|
+
await handle.writeFile(JSON.stringify(previous));
|
|
964
|
+
}
|
|
965
|
+
finally {
|
|
966
|
+
await handle.close();
|
|
967
|
+
}
|
|
951
968
|
}
|
|
952
969
|
catch (error) {
|
|
953
970
|
if (error.code !== "EEXIST" ||
|
package/dist/core/path.d.ts
CHANGED
|
@@ -21,3 +21,10 @@ export declare function normalizeRelativePath(input: string): string;
|
|
|
21
21
|
* error instead of being resolved away.
|
|
22
22
|
*/
|
|
23
23
|
export declare function normalizeManifestPath(input: string): ManifestPath;
|
|
24
|
+
/** A trailing /** (or slash) declares a directory, including one not created yet.
|
|
25
|
+
* Tool destinations still use normalizeManifestPath: this notation belongs to declarations only.
|
|
26
|
+
*/
|
|
27
|
+
export declare function normalizeManifestScope(input: string): ManifestPath & {
|
|
28
|
+
readonly directory: boolean;
|
|
29
|
+
};
|
|
30
|
+
export declare function normalizeRelativeScope(input: string): string;
|
package/dist/core/path.js
CHANGED
|
@@ -52,3 +52,18 @@ export function normalizeManifestPath(input) {
|
|
|
52
52
|
}
|
|
53
53
|
return { kind: "relative", path: normalizeRelativePath(input) };
|
|
54
54
|
}
|
|
55
|
+
/** A trailing /** (or slash) declares a directory, including one not created yet.
|
|
56
|
+
* Tool destinations still use normalizeManifestPath: this notation belongs to declarations only.
|
|
57
|
+
*/
|
|
58
|
+
export function normalizeManifestScope(input) {
|
|
59
|
+
const unified = input.replaceAll("\\", "/");
|
|
60
|
+
const directory = unified.endsWith("/**") || unified.endsWith("/");
|
|
61
|
+
const path = unified.endsWith("/**") ? unified.slice(0, -3) : unified;
|
|
62
|
+
return { ...normalizeManifestPath(path), directory };
|
|
63
|
+
}
|
|
64
|
+
export function normalizeRelativeScope(input) {
|
|
65
|
+
const scope = normalizeManifestScope(input);
|
|
66
|
+
if (scope.kind !== "relative")
|
|
67
|
+
throw new RelativePathError("absolute");
|
|
68
|
+
return scope.path + (scope.directory ? "/**" : "");
|
|
69
|
+
}
|
|
@@ -31,7 +31,7 @@ function collectManifestPaths(paths, pointer, diagnostics) {
|
|
|
31
31
|
paths.forEach((path, index) => {
|
|
32
32
|
let normalized;
|
|
33
33
|
try {
|
|
34
|
-
normalized = pathUtils.
|
|
34
|
+
normalized = pathUtils.normalizeManifestScope(path);
|
|
35
35
|
}
|
|
36
36
|
catch (error) {
|
|
37
37
|
if (!(error instanceof pathUtils.RelativePathError))
|
|
@@ -48,7 +48,7 @@ function collectManifestPaths(paths, pointer, diagnostics) {
|
|
|
48
48
|
else if (normalized.kind === "relative") {
|
|
49
49
|
// Absolute manifest targets authorize tools only. Handoff scope/source and Git paths remain
|
|
50
50
|
// repository-relative and therefore never compare equal to an external target.
|
|
51
|
-
normalizedPaths.add(normalized.path);
|
|
51
|
+
normalizedPaths.add(normalized.path + (normalized.directory ? "/**" : ""));
|
|
52
52
|
}
|
|
53
53
|
});
|
|
54
54
|
return normalizedPaths;
|
|
@@ -79,7 +79,7 @@ function addPathDiagnostics(diagnostics, paths, allowed, code, pointer) {
|
|
|
79
79
|
const reported = new Set();
|
|
80
80
|
paths.forEach((path, index) => {
|
|
81
81
|
const normalized = normalizePath(path);
|
|
82
|
-
if (normalized !== undefined && (allowed
|
|
82
|
+
if (normalized !== undefined && (scopeAllows(allowed, normalized) || reported.has(normalized)))
|
|
83
83
|
return;
|
|
84
84
|
if (normalized !== undefined)
|
|
85
85
|
reported.add(normalized);
|
|
@@ -91,6 +91,10 @@ function addPathDiagnostics(diagnostics, paths, allowed, code, pointer) {
|
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
}
|
|
94
|
+
function scopeAllows(scopes, path) {
|
|
95
|
+
return scopes.has(path) || [...scopes].some(scope => scope.endsWith("/**") &&
|
|
96
|
+
(path === scope.slice(0, -3) || path.startsWith(scope.slice(0, -2))));
|
|
97
|
+
}
|
|
94
98
|
/** Compare one schema-valid handoff with one schema-valid operation manifest. */
|
|
95
99
|
export function validateManifest(handoff, manifest, changedPaths, changedPathsProvided, options = {}) {
|
|
96
100
|
const diagnostics = [];
|
|
@@ -105,7 +109,7 @@ export function validateManifest(handoff, manifest, changedPaths, changedPathsPr
|
|
|
105
109
|
handoff.sources?.forEach((source, index) => {
|
|
106
110
|
const normalized = normalizePath(source.path);
|
|
107
111
|
if (normalized !== undefined &&
|
|
108
|
-
(readableOrWritable
|
|
112
|
+
(scopeAllows(readableOrWritable, normalized) || reportedSources.has(normalized)))
|
|
109
113
|
return;
|
|
110
114
|
if (normalized !== undefined)
|
|
111
115
|
reportedSources.add(normalized);
|
package/dist/plugin/gate.js
CHANGED
|
@@ -2,7 +2,7 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { lstat, realpath } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, isAbsolute, relative, resolve, win32 } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
|
-
import { RelativePathError, normalizeManifestPath, normalizeRelativePath } from "../core/path.js";
|
|
5
|
+
import { RelativePathError, normalizeManifestPath, normalizeManifestScope, normalizeRelativePath } from "../core/path.js";
|
|
6
6
|
import { validateOperationManifestSchema } from "../core/validate-schema.js";
|
|
7
7
|
export class WriteDeniedError extends Error {
|
|
8
8
|
reason;
|
|
@@ -144,6 +144,13 @@ function curlOutput(tokens) {
|
|
|
144
144
|
return undefined;
|
|
145
145
|
continue;
|
|
146
146
|
}
|
|
147
|
+
if (token === "-w" || token === "--write-out" || token.startsWith("--write-out=")) {
|
|
148
|
+
const format = token.startsWith("--write-out=") ? token.slice(12) : tokens[++index];
|
|
149
|
+
// curl write-out can open additional files or read a format file. Only stdout metadata is pathless.
|
|
150
|
+
if (format === undefined || format.startsWith("@") || /%output\{/u.test(format))
|
|
151
|
+
return undefined;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
147
154
|
if (token === "-o" || token === "--output") {
|
|
148
155
|
const candidate = tokens[++index];
|
|
149
156
|
if (candidate === undefined || candidate.startsWith("-") || output !== undefined)
|
|
@@ -259,8 +266,40 @@ function isRemoteGitHubMutation(tokens) {
|
|
|
259
266
|
function isReadOnlyGitCommand(tokens) {
|
|
260
267
|
const command = tokens[1]?.toLowerCase() ?? "";
|
|
261
268
|
return READ_ONLY_GIT_COMMANDS.has(command) ||
|
|
269
|
+
(command === "tag" && tokens.length >= 3 && ["--points-at", "--contains", "--list", "-l"].includes(tokens[2]) &&
|
|
270
|
+
tokens.slice(3).every(token => !token.startsWith("-"))) ||
|
|
262
271
|
(command === "branch" && tokens.length === 3 && tokens[2] === "--show-current");
|
|
263
272
|
}
|
|
273
|
+
function gitArchiveOutput(tokens) {
|
|
274
|
+
let output, tree = false, paths = false;
|
|
275
|
+
for (let index = 2; index < tokens.length; index++) {
|
|
276
|
+
const token = tokens[index];
|
|
277
|
+
if (token === "--" && tree) {
|
|
278
|
+
paths = true;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (paths || !token.startsWith("-")) {
|
|
282
|
+
tree = true;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (token === "-o" || token === "--output") {
|
|
286
|
+
const value = tokens[++index];
|
|
287
|
+
if (!value || value.startsWith("-") || output !== undefined)
|
|
288
|
+
return undefined;
|
|
289
|
+
output = value;
|
|
290
|
+
}
|
|
291
|
+
else if (token.startsWith("--output=")) {
|
|
292
|
+
if (output !== undefined || !token.slice(9))
|
|
293
|
+
return undefined;
|
|
294
|
+
output = token.slice(9);
|
|
295
|
+
}
|
|
296
|
+
else if (/^--(?:format|prefix)=.+$/u.test(token) || /^-[0-9]$/u.test(token))
|
|
297
|
+
continue;
|
|
298
|
+
else
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
return tree ? { output } : undefined;
|
|
302
|
+
}
|
|
264
303
|
function exactGitAddPaths(tokens) {
|
|
265
304
|
if (tokens.length < 4 || tokens[2] !== "--")
|
|
266
305
|
return undefined;
|
|
@@ -632,6 +671,18 @@ function shellPaths(command, powershell, depth = 0) {
|
|
|
632
671
|
else
|
|
633
672
|
ambiguous = true;
|
|
634
673
|
}
|
|
674
|
+
else if (executable === "git" && tokens[1] === "archive") {
|
|
675
|
+
const archive = gitArchiveOutput(tokens);
|
|
676
|
+
if (archive === undefined) {
|
|
677
|
+
applies = true;
|
|
678
|
+
ambiguous = true;
|
|
679
|
+
issue ??= commandIssue(source, "unsupported-git-archive-form", "use git archive --format=tar --output=<scoped-file> <local-ref>");
|
|
680
|
+
}
|
|
681
|
+
else if (archive.output !== undefined) {
|
|
682
|
+
applies = true;
|
|
683
|
+
paths.push(archive.output);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
635
686
|
else if (executable === "git" && isReadOnlyGitCommand(tokens)) {
|
|
636
687
|
// Explicitly read-only git subcommands.
|
|
637
688
|
}
|
|
@@ -677,8 +728,10 @@ function shellPaths(command, powershell, depth = 0) {
|
|
|
677
728
|
...(issue ? { issue } : {}),
|
|
678
729
|
};
|
|
679
730
|
}
|
|
680
|
-
function issuePath(issue) {
|
|
681
|
-
return `segment=${issue.segment}; cause=${issue.cause}; hint=${issue.hint};
|
|
731
|
+
function issuePath(issue, formatCorrection = false) {
|
|
732
|
+
return `segment=${issue.segment}; cause=${issue.cause}; hint=${issue.hint}; ` + (formatCorrection
|
|
733
|
+
? "retry=after-format-correction; action=correct-format-within-current-manifest; preserve operation and destinations; otherwise return exact required correction to Coordinator"
|
|
734
|
+
: "retry=false; action=return-denial-to-coordinator");
|
|
682
735
|
}
|
|
683
736
|
export function describeUnclassifiedCommand(tool, args) {
|
|
684
737
|
const extracted = extractWritePaths(tool, args);
|
|
@@ -925,7 +978,7 @@ function isWithin(root, target) {
|
|
|
925
978
|
}
|
|
926
979
|
async function canonicalManifestScopes(project, entries) {
|
|
927
980
|
const scopes = await Promise.all(entries.map(async (entry) => {
|
|
928
|
-
const normalized =
|
|
981
|
+
const normalized = normalizeManifestScope(entry);
|
|
929
982
|
const absolute = normalized.kind === "relative"
|
|
930
983
|
? project.absolute(normalized.path)
|
|
931
984
|
: resolve(normalized.path);
|
|
@@ -987,10 +1040,13 @@ export async function createWriteGate(project, value, toolDirectory = project.ro
|
|
|
987
1040
|
}
|
|
988
1041
|
}
|
|
989
1042
|
const writable = new Set();
|
|
1043
|
+
const explicitDirectories = new Set();
|
|
990
1044
|
const externalEntries = [];
|
|
991
1045
|
try {
|
|
992
1046
|
for (const entry of manifest.write) {
|
|
993
|
-
const normalized =
|
|
1047
|
+
const normalized = normalizeManifestScope(entry);
|
|
1048
|
+
if (normalized.directory)
|
|
1049
|
+
explicitDirectories.add(normalized.path);
|
|
994
1050
|
if (normalized.kind === "relative")
|
|
995
1051
|
writable.add(normalized.path);
|
|
996
1052
|
else {
|
|
@@ -1015,7 +1071,7 @@ export async function createWriteGate(project, value, toolDirectory = project.ro
|
|
|
1015
1071
|
}
|
|
1016
1072
|
}
|
|
1017
1073
|
catch (error) {
|
|
1018
|
-
if ((declaredDirectories.has(path) || inferredDirectories.has(path)) &&
|
|
1074
|
+
if ((explicitDirectories.has(path) || declaredDirectories.has(path) || inferredDirectories.has(path)) &&
|
|
1019
1075
|
isRecord(error) && error.code === "ENOENT") {
|
|
1020
1076
|
writableDirectories.push({ path, realPath: await nearestExistingRealPath(project.absolute(path)) });
|
|
1021
1077
|
}
|
|
@@ -1059,7 +1115,11 @@ export async function createWriteGate(project, value, toolDirectory = project.ro
|
|
|
1059
1115
|
if (!isRecord(error) || error.code !== "ENOENT") {
|
|
1060
1116
|
throw new WriteDeniedError("manifest-unavailable", "<unknown>", { cause: error });
|
|
1061
1117
|
}
|
|
1062
|
-
|
|
1118
|
+
if (explicitDirectories.has(path.replaceAll("\\", "/"))) {
|
|
1119
|
+
externalDirectories.push({ path, realPath: await nearestExistingRealPath(path) });
|
|
1120
|
+
}
|
|
1121
|
+
else
|
|
1122
|
+
externalFiles.push({ path, anchorRealPath: await nearestExistingRealPath(resolve(path, "..")) });
|
|
1063
1123
|
}
|
|
1064
1124
|
}
|
|
1065
1125
|
const matchingExternalScopes = (absolute) => ({
|
|
@@ -1192,7 +1252,9 @@ export async function createWriteGate(project, value, toolDirectory = project.ro
|
|
|
1192
1252
|
return;
|
|
1193
1253
|
if (extracted.ambiguous || (extracted.paths.length === 0 && !extracted.gitCommit)) {
|
|
1194
1254
|
if (extracted.issue !== undefined) {
|
|
1195
|
-
|
|
1255
|
+
const formatCorrection = options?.investigativeShell === true &&
|
|
1256
|
+
["unsupported-curl-form", "unsupported-git-archive-form", "unsupported-webrequest-form"].includes(extracted.issue.cause);
|
|
1257
|
+
throw new WriteDeniedError("unclassified-command", issuePath(extracted.issue, formatCorrection));
|
|
1196
1258
|
}
|
|
1197
1259
|
throw new WriteDeniedError("path-required", "<missing-path>");
|
|
1198
1260
|
}
|
|
@@ -1208,7 +1270,15 @@ export async function createWriteGate(project, value, toolDirectory = project.ro
|
|
|
1208
1270
|
throw new WriteDeniedError("project-boundary", path, { cause: error });
|
|
1209
1271
|
}
|
|
1210
1272
|
}
|
|
1211
|
-
|
|
1273
|
+
try {
|
|
1274
|
+
await checkPath(nativeFileTool ? resolve(toolDirectory, path) : path);
|
|
1275
|
+
}
|
|
1276
|
+
catch (error) {
|
|
1277
|
+
if (options?.investigativeShell && error instanceof WriteDeniedError && error.reason === "manifest-scope") {
|
|
1278
|
+
error.message += " Coordinator: expand_unit with the exact missing output directory as dir/**, preserving settled units and cumulative spend; then dispatch the returned Task. No user approval is needed for an in-request correction.";
|
|
1279
|
+
}
|
|
1280
|
+
throw error;
|
|
1281
|
+
}
|
|
1212
1282
|
}
|
|
1213
1283
|
if (extracted.gitCommit)
|
|
1214
1284
|
await checkCachedSet();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type GoalEvidence, type GoalFlightState } from "../core/goal-bound.js";
|
|
2
|
+
export interface CompletionBlocker {
|
|
3
|
+
readonly reason: "active-reservations" | "missing-contract" | "missing-evidence" | "missing-binding" | "snapshot-unavailable" | "source-changed" | "candidate-changed";
|
|
4
|
+
readonly criterion_id?: string;
|
|
5
|
+
readonly source_paths?: readonly string[];
|
|
6
|
+
readonly candidate_paths?: readonly string[];
|
|
7
|
+
readonly expected?: string;
|
|
8
|
+
readonly actual?: string;
|
|
9
|
+
readonly legacy_control_snapshot?: boolean;
|
|
10
|
+
readonly next_action: string;
|
|
11
|
+
}
|
|
12
|
+
export interface CompletionReadiness {
|
|
13
|
+
readonly ready: boolean;
|
|
14
|
+
readonly blockers: readonly CompletionBlocker[];
|
|
15
|
+
}
|
|
16
|
+
/** The same read-only decision drives completion and public status, without settling or rerunning work. */
|
|
17
|
+
export declare function goalCompletionReadiness(state: GoalFlightState, evidence: readonly GoalEvidence[]): Promise<CompletionReadiness>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { validGoalEvidence } from "../core/goal-bound.js";
|
|
2
|
+
import { isRuntimeControlPath, refreshProtectedSnapshot } from "./protected-snapshot.js";
|
|
3
|
+
/** The same read-only decision drives completion and public status, without settling or rerunning work. */
|
|
4
|
+
export async function goalCompletionReadiness(state, evidence) {
|
|
5
|
+
const blockers = [];
|
|
6
|
+
if (state.outstanding_reservations.length)
|
|
7
|
+
blockers.push({ reason: "active-reservations",
|
|
8
|
+
next_action: "Reconcile the outstanding native tasks before acceptance; do not repeat completion while work is reserved." });
|
|
9
|
+
if (!state.goal_id || !state.acceptance_fingerprint || !state.acceptance_contract?.criteria.length) {
|
|
10
|
+
blockers.push({ reason: "missing-contract", next_action: "Restore the current run's registered goal and unchanged requirements before completion." });
|
|
11
|
+
}
|
|
12
|
+
const snapshots = new Map();
|
|
13
|
+
for (const criterion of state.acceptance_contract?.criteria ?? []) {
|
|
14
|
+
const proof = [...evidence].reverse().find(entry => entry.measurement.criterion_ids.includes(criterion.criterion_id) && validGoalEvidence(entry, state));
|
|
15
|
+
if (!state.satisfied_criteria.includes(criterion.criterion_id) || !proof) {
|
|
16
|
+
blockers.push({ reason: "missing-evidence", criterion_id: criterion.criterion_id,
|
|
17
|
+
next_action: "Coordinator: obtain the missing criterion evidence within the existing requirements and cumulative budget; retain already valid checks." });
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (criterion.source_binding !== "current-protected" && criterion.candidate_binding !== "current-protected")
|
|
21
|
+
continue;
|
|
22
|
+
const binding = proof.protected_binding;
|
|
23
|
+
if (!binding) {
|
|
24
|
+
blockers.push({ reason: "missing-binding", criterion_id: criterion.criterion_id,
|
|
25
|
+
next_action: "Coordinator: obtain host-observed validation bound to this criterion; prose cannot replace a missing snapshot." });
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const key = JSON.stringify(binding);
|
|
29
|
+
let pending = snapshots.get(key);
|
|
30
|
+
if (!pending) {
|
|
31
|
+
pending = refreshProtectedSnapshot(binding.project_root, binding).catch(() => undefined);
|
|
32
|
+
snapshots.set(key, pending);
|
|
33
|
+
}
|
|
34
|
+
const current = await pending;
|
|
35
|
+
const scope = { criterion_id: criterion.criterion_id, source_paths: binding.source_paths, candidate_paths: binding.candidate_paths };
|
|
36
|
+
if (!current) {
|
|
37
|
+
blockers.push({ ...scope, reason: "snapshot-unavailable",
|
|
38
|
+
next_action: "Inspect the pinned manifest and declared paths for missing, changed or unreadable files. Correct the identified cause before validation or completion; do not retry unchanged." });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (criterion.source_binding === "current-protected" && current.source !== proof.identity.source) {
|
|
42
|
+
const legacy = !binding.source_policy && binding.source_paths.some(path => path === "" || path === "." || isRuntimeControlPath(path));
|
|
43
|
+
blockers.push({ ...scope, reason: "source-changed", expected: proof.identity.source, actual: current.source,
|
|
44
|
+
...(legacy ? { legacy_control_snapshot: true } : {}),
|
|
45
|
+
next_action: legacy
|
|
46
|
+
? "Legacy evidence includes mutable runtime/Git records. Coordinator: obtain fresh evidence with the current snapshot policy in the same mission and cumulative budget. Do not edit the ledger, reset requirements, or repeat completion unchanged."
|
|
47
|
+
: "Coordinator: inspect the changed source scopes and validate only the affected criterion after the candidate is settled; do not repeat unrelated passed checks or completion unchanged." });
|
|
48
|
+
}
|
|
49
|
+
if (criterion.candidate_binding === "current-protected" && current.candidate !== proof.identity.candidate) {
|
|
50
|
+
blockers.push({ ...scope, reason: "candidate-changed", expected: proof.identity.candidate, actual: current.candidate,
|
|
51
|
+
next_action: "Coordinator: the declared output changed after validation. Validate the intended current candidate and update its review before final acceptance; do not repeat completion unchanged." });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { ready: blockers.length === 0, blockers };
|
|
55
|
+
}
|
package/dist/plugin/index.js
CHANGED
|
@@ -7,7 +7,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
9
|
import { createHash, randomUUID } from "node:crypto";
|
|
10
|
-
import { lstat, mkdir, open, readFile,
|
|
10
|
+
import { lstat, mkdir, open, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
11
11
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
12
12
|
import { RUNTIME_ASSET_VERSION } from "../asset-version.js";
|
|
13
13
|
import { GOAL_DECLARATION_FORMAT, GOAL_DELIVERY_INTENTS, GOAL_DELIVERY_MODES, GOAL_CRITERION_ENUMS, expandGoalDeclaration } from "../core/goal-declaration-format.js";
|
|
@@ -18,7 +18,7 @@ import { admitLunaFabric } from "../core/luna-fabric-contract.js";
|
|
|
18
18
|
import { summarizeExperienceEvidence } from "../core/experience-evidence-summary.js";
|
|
19
19
|
import { selectExperienceRoute } from "../core/experience-route-policy.js";
|
|
20
20
|
import { resolveExperienceRouting } from "../core/experience-routing-runtime.js";
|
|
21
|
-
import {
|
|
21
|
+
import { normalizeRelativePath, RelativePathError } from "../core/path.js";
|
|
22
22
|
import { ScopeLeaseError, ScopeLeaseRegistry } from "../core/scope-lease-registry.js";
|
|
23
23
|
import { produceWorktreeCommitArtifact, recoverWorktreeCommitArtifact, resolveValidationExecutable, WorktreeCommitArtifactError, } from "../core/worktree-commit-artifact.js";
|
|
24
24
|
import { normalizeWorktreeScope } from "../core/worktree-scope.js";
|
|
@@ -51,6 +51,8 @@ import { collectRunMetrics, createSortieResult, createGoalReport, insertRunMetri
|
|
|
51
51
|
import { STABLE_RUNTIME_PROFILE } from "../core/runtime-profile.js";
|
|
52
52
|
import { evidenceFromObservedExecution } from "../core/observed-goal-evidence.js";
|
|
53
53
|
import { receiptBoundTerminalText } from "./receipt-presentation.js";
|
|
54
|
+
import { protectedSnapshot, refreshProtectedSnapshot } from "./protected-snapshot.js";
|
|
55
|
+
import { goalCompletionReadiness } from "./goal-completion.js";
|
|
54
56
|
const INPUT_LIMITS = { config: 64 * 1024, manifest: 512 * 1024, handoff: 2 * 1024 * 1024, parallel: 512 * 1024 };
|
|
55
57
|
const INSPECTION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
|
|
56
58
|
const ACTIVE_SESSION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
|
|
@@ -233,94 +235,6 @@ async function durableScopeRoot(projectRoot) {
|
|
|
233
235
|
return undefined;
|
|
234
236
|
}
|
|
235
237
|
}
|
|
236
|
-
async function protectedScopeDigest(projectRoot, paths, manifestHash) {
|
|
237
|
-
const entries = [];
|
|
238
|
-
const canonicalRoot = await realpath(projectRoot);
|
|
239
|
-
const visit = async (absolute) => {
|
|
240
|
-
const scoped = relative(projectRoot, absolute).replaceAll("\\", "/");
|
|
241
|
-
if (scoped === ".." || scoped.startsWith("../") || isAbsolute(scoped))
|
|
242
|
-
return false;
|
|
243
|
-
const metadata = await lstat(absolute).catch((error) => {
|
|
244
|
-
if (isRecord(error) && error.code === "ENOENT")
|
|
245
|
-
return undefined;
|
|
246
|
-
throw error;
|
|
247
|
-
});
|
|
248
|
-
if (metadata === undefined) {
|
|
249
|
-
entries.push([scoped, "missing"]);
|
|
250
|
-
return true;
|
|
251
|
-
}
|
|
252
|
-
if (metadata.isSymbolicLink()) {
|
|
253
|
-
// Package managers place executable links under node_modules/.bin. Record
|
|
254
|
-
// both the link and its target bytes, but never follow a link out of the
|
|
255
|
-
// project or recurse through a linked directory (which may form a cycle).
|
|
256
|
-
const target = await realpath(absolute).catch(() => undefined);
|
|
257
|
-
if (target === undefined)
|
|
258
|
-
return false;
|
|
259
|
-
const relativeTarget = relative(canonicalRoot, target);
|
|
260
|
-
if (relativeTarget === ".." || relativeTarget.startsWith("../") || isAbsolute(relativeTarget) ||
|
|
261
|
-
!(await stat(target)).isFile())
|
|
262
|
-
return false;
|
|
263
|
-
const link = await readlink(absolute);
|
|
264
|
-
const contents = await readFile(target);
|
|
265
|
-
entries.push([scoped, `symlink:${link}`, createHash("sha256").update(contents).digest("hex")]);
|
|
266
|
-
return true;
|
|
267
|
-
}
|
|
268
|
-
if (metadata.isDirectory()) {
|
|
269
|
-
entries.push([scoped, "directory"]);
|
|
270
|
-
const children = await readdir(absolute);
|
|
271
|
-
for (const child of children.sort())
|
|
272
|
-
if (!await visit(join(absolute, child)))
|
|
273
|
-
return false;
|
|
274
|
-
return true;
|
|
275
|
-
}
|
|
276
|
-
if (!metadata.isFile())
|
|
277
|
-
return false;
|
|
278
|
-
entries.push([scoped, "file", createHash("sha256").update(await readFile(absolute)).digest("hex")]);
|
|
279
|
-
return true;
|
|
280
|
-
};
|
|
281
|
-
for (const path of [...new Set(paths)].sort())
|
|
282
|
-
if (!await visit(path))
|
|
283
|
-
return undefined;
|
|
284
|
-
return goalFingerprint({ manifest_hash: `sha256:${manifestHash}`, entries });
|
|
285
|
-
}
|
|
286
|
-
async function protectedSnapshot(authorization) {
|
|
287
|
-
const manifestSource = await readFile(authorization.manifestPath).catch(() => undefined);
|
|
288
|
-
if (manifestSource === undefined)
|
|
289
|
-
return undefined;
|
|
290
|
-
const manifestHash = createHash("sha256").update(manifestSource).digest("hex");
|
|
291
|
-
if (manifestHash !== authorization.manifestHash)
|
|
292
|
-
return undefined;
|
|
293
|
-
const relativePath = relative(authorization.projectRoot, authorization.manifestPath).replaceAll("\\", "/");
|
|
294
|
-
const manifest = JSON.parse(manifestSource.toString("utf8"));
|
|
295
|
-
const actualPaths = (entries) => entries.map((entry) => {
|
|
296
|
-
const path = normalizeManifestPath(entry);
|
|
297
|
-
return path.kind === "relative" ? resolve(authorization.projectRoot, path.path) : resolve(path.path);
|
|
298
|
-
});
|
|
299
|
-
// Ownership keys may be case-folded even on POSIX (for example DrvFS). Hash the actual manifest paths.
|
|
300
|
-
const candidatePaths = actualPaths(manifest.write);
|
|
301
|
-
const sourcePaths = [...new Set([...actualPaths(manifest.read), ...candidatePaths])];
|
|
302
|
-
const source = await protectedScopeDigest(authorization.projectRoot, sourcePaths, manifestHash);
|
|
303
|
-
const candidate = await protectedScopeDigest(authorization.projectRoot, candidatePaths, manifestHash);
|
|
304
|
-
if (source === undefined || candidate === undefined || relativePath.startsWith("../") || isAbsolute(relativePath))
|
|
305
|
-
return undefined;
|
|
306
|
-
return { binding: { manifest_hash: `sha256:${manifestHash}`, project_root: authorization.projectRoot,
|
|
307
|
-
manifest_path: relativePath,
|
|
308
|
-
source_paths: sourcePaths.map((path) => relative(authorization.projectRoot, path).replaceAll("\\", "/")),
|
|
309
|
-
candidate_paths: candidatePaths.map((path) => relative(authorization.projectRoot, path).replaceAll("\\", "/")) },
|
|
310
|
-
source, candidate };
|
|
311
|
-
}
|
|
312
|
-
async function refreshProtectedSnapshot(projectRoot, binding) {
|
|
313
|
-
const manifestPath = resolve(projectRoot, binding.manifest_path);
|
|
314
|
-
const manifestSource = await readFile(manifestPath).catch(() => undefined);
|
|
315
|
-
if (manifestSource === undefined)
|
|
316
|
-
return undefined;
|
|
317
|
-
const manifestHash = createHash("sha256").update(manifestSource).digest("hex");
|
|
318
|
-
if (`sha256:${manifestHash}` !== binding.manifest_hash)
|
|
319
|
-
return undefined;
|
|
320
|
-
const source = await protectedScopeDigest(projectRoot, binding.source_paths.map((path) => resolve(projectRoot, path)), manifestHash);
|
|
321
|
-
const candidate = await protectedScopeDigest(projectRoot, binding.candidate_paths.map((path) => resolve(projectRoot, path)), manifestHash);
|
|
322
|
-
return source === undefined || candidate === undefined ? undefined : { source, candidate };
|
|
323
|
-
}
|
|
324
238
|
function isRecord(value) {
|
|
325
239
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
326
240
|
}
|
|
@@ -1919,34 +1833,23 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
1919
1833
|
origin_user_message_id: ticket.origin_user_message_id,
|
|
1920
1834
|
} } };
|
|
1921
1835
|
}
|
|
1922
|
-
async function terminalGoal(sessionID, stopReason, status, explicitAcceptance = false) {
|
|
1836
|
+
async function terminalGoal(sessionID, stopReason, status, explicitAcceptance = false, onReadiness) {
|
|
1923
1837
|
const ledger = await goalLedger(sessionID);
|
|
1924
|
-
const state =
|
|
1925
|
-
if (state.
|
|
1926
|
-
return state.receipt
|
|
1838
|
+
const { state, records } = await ledger.readGoal();
|
|
1839
|
+
if (state.receipt !== null)
|
|
1840
|
+
return state.receipt;
|
|
1927
1841
|
if (status === "succeeded" && !explicitAcceptance &&
|
|
1928
1842
|
await input.runtimeBridge?.requiresExplicitAcceptance?.(sessionID) === true)
|
|
1929
1843
|
return undefined;
|
|
1930
|
-
const records = (await ledger.readGoal()).records;
|
|
1931
1844
|
const settledEvidence = records.flatMap(({ event }) => event.kind === "unit.settled" || event.kind === "unit.evidence-reconciled" ? event.evidence : []);
|
|
1932
|
-
if (status === "succeeded"
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
for (const criterion of state.acceptance_contract.criteria) {
|
|
1938
|
-
if (criterion.source_binding !== "current-protected" && criterion.candidate_binding !== "current-protected")
|
|
1939
|
-
continue;
|
|
1940
|
-
const evidence = [...settledEvidence].reverse().find((entry) => entry.measurement.criterion_ids.includes(criterion.criterion_id) && validGoalEvidence(entry, state));
|
|
1941
|
-
if (evidence?.protected_binding === undefined)
|
|
1942
|
-
return undefined;
|
|
1943
|
-
const current = await refreshProtectedSnapshot(evidence.protected_binding.project_root, evidence.protected_binding).catch(() => undefined);
|
|
1944
|
-
if (current === undefined ||
|
|
1945
|
-
(criterion.source_binding === "current-protected" && current.source !== evidence.identity.source) ||
|
|
1946
|
-
(criterion.candidate_binding === "current-protected" && current.candidate !== evidence.identity.candidate))
|
|
1947
|
-
return undefined;
|
|
1948
|
-
}
|
|
1845
|
+
if (status === "succeeded") {
|
|
1846
|
+
const readiness = await goalCompletionReadiness(state, settledEvidence);
|
|
1847
|
+
onReadiness?.(readiness);
|
|
1848
|
+
if (!readiness.ready)
|
|
1849
|
+
return undefined;
|
|
1949
1850
|
}
|
|
1851
|
+
if (state.goal_id === null || state.outstanding_reservations.length > 0 || state.acceptance_fingerprint === null)
|
|
1852
|
+
return undefined;
|
|
1950
1853
|
const started = [...records].reverse().find(({ event }) => event.kind === "goal.accepted")?.event.at ?? new Date().toISOString();
|
|
1951
1854
|
const ended = new Date().toISOString();
|
|
1952
1855
|
const startTime = Date.parse(started);
|
|
@@ -7857,7 +7760,10 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
7857
7760
|
return null;
|
|
7858
7761
|
const reserved = state.outstanding_reservations.length;
|
|
7859
7762
|
return { max_units: state.budget.max_units, consumed_units: state.consumed_units,
|
|
7860
|
-
reserved_units: reserved, remaining_units: Math.max(0, state.budget.max_units - state.consumed_units - reserved)
|
|
7763
|
+
reserved_units: reserved, remaining_units: Math.max(0, state.budget.max_units - state.consumed_units - reserved),
|
|
7764
|
+
settled_cost_usd: state.consumed_cost_usd, cost_limit_usd: state.budget.cost_usd,
|
|
7765
|
+
cost_status: state.consumed_cost_usd === null ? "unknown-usage" : reserved > 0 ? "in-flight-not-final" : "settled",
|
|
7766
|
+
cost_note: "Goal Worker ledger only; excludes unfinalized native requests, orchestration/review and external campaign spend. Zero settled cost does not mean free execution." };
|
|
7861
7767
|
},
|
|
7862
7768
|
registerGoalDeclaration: async (root, prompt, missionRevision) => {
|
|
7863
7769
|
if (!isCoordinatorSession(root) && !await recoverCoordinatorRoot(root))
|
|
@@ -8006,6 +7912,10 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
8006
7912
|
return !snapshot.records.some(({ event }) => event.kind === "dispatch.reserved" && event.reservation_id === expected);
|
|
8007
7913
|
},
|
|
8008
7914
|
recoverUnitEvidence,
|
|
7915
|
+
completionReadiness: async (root) => {
|
|
7916
|
+
const { state, records } = await (await goalLedger(root)).readGoal();
|
|
7917
|
+
return goalCompletionReadiness(state, records.flatMap(({ event }) => event.kind === "unit.settled" || event.kind === "unit.evidence-reconciled" ? event.evidence : []));
|
|
7918
|
+
},
|
|
8009
7919
|
completeRoot: async (sessionID, acceptanceFingerprint) => {
|
|
8010
7920
|
if (!isCoordinatorSession(sessionID) && !await recoverCoordinatorRoot(sessionID))
|
|
8011
7921
|
throw new Error("operator-coordinator-required");
|
|
@@ -8017,9 +7927,10 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
8017
7927
|
throw new Error("operator-goal-stopped");
|
|
8018
7928
|
// Same proof/freshness/reservation gate used by terminal text, requested
|
|
8019
7929
|
// explicitly by the root instead of inferred from a model's wording.
|
|
8020
|
-
|
|
7930
|
+
let completion;
|
|
7931
|
+
const receipt = goal.receipt ?? await terminalGoal(sessionID, "completed", "succeeded", true, result => { completion = result; });
|
|
8021
7932
|
if (receipt?.status !== "succeeded")
|
|
8022
|
-
return { status: "awaiting-evidence" };
|
|
7933
|
+
return { status: "awaiting-evidence", completion };
|
|
8023
7934
|
// Acceptance must return its receipt to the active tool call. Revoking
|
|
8024
7935
|
// continuation timers is not a user-requested session cancellation.
|
|
8025
7936
|
await continuation.stopAutomaticRecovery(sessionID, false, true);
|
|
@@ -8,6 +8,7 @@ import { TOOL_ENVIRONMENT } from "../runtime-mission-assets.js";
|
|
|
8
8
|
import { MISSION_REVIEW_REFERENCE } from "../core/operator-mission.js";
|
|
9
9
|
import { canonicalAgent } from "../core/runtime-profile.js";
|
|
10
10
|
import { taskChildSessionID } from "./task-result-repair.js";
|
|
11
|
+
import { normalizeManifestScope } from "../core/path.js";
|
|
11
12
|
const exec = promisify(execFile);
|
|
12
13
|
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
13
14
|
/** Recover from native completion records, never from the pending review's inherited child ID. */
|
|
@@ -75,7 +76,7 @@ export async function completedMissionReviewPrompts(mission, profile, root, requ
|
|
|
75
76
|
/** Pin all scoped tracked/untracked source bytes, including deletions; display a bounded excerpt only. */
|
|
76
77
|
export async function missionReviewSource(directory, run) {
|
|
77
78
|
const hash = createHash("sha256").update(JSON.stringify(run.units.map(unit => ({ unit: unit.unit, hashes: unit.hashes }))));
|
|
78
|
-
const writes = [...new Set(run.units.flatMap(unit => unit.unit.write))];
|
|
79
|
+
const writes = [...new Set(run.units.flatMap(unit => unit.unit.write).map(path => path === "." ? path : normalizeManifestScope(path).path))];
|
|
79
80
|
if (writes.length === 0)
|
|
80
81
|
return { fingerprint: `sha256:${hash.digest("hex")}`,
|
|
81
82
|
excerpt: "[Read-only units: no declared output files. Review the supplied observations, traces and validation evidence.]" };
|
package/dist/plugin/profiled.js
CHANGED
|
@@ -751,7 +751,9 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
751
751
|
: `Use these host counters, not the model-authored goal_budget_units estimate. Continue the same-goal remediation in this turn within unchanged acceptance and approved scope; normal time, cost, validation and dispatch gates still apply. ${packet.next_action}`;
|
|
752
752
|
packet.next_action = `${counters} ${action}`;
|
|
753
753
|
}
|
|
754
|
-
|
|
754
|
+
const completion = state.phase === "awaiting-acceptance" ? await control.completionReadiness(state.rootSessionID) : undefined;
|
|
755
|
+
return { ...packet, budget, ...(completion ? { completion,
|
|
756
|
+
...(!completion.ready ? { next_action: completion.blockers.map(item => item.next_action).join("\n") } : {}) } : {}) };
|
|
755
757
|
}
|
|
756
758
|
/**
|
|
757
759
|
* Proposal accounting without the submitted packet body.
|
|
@@ -772,7 +774,15 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
772
774
|
const mission = root && context.sessionID === root ? await reconcileMissionDispatch(root) : root ? await missions.read(root) : undefined;
|
|
773
775
|
// Observation is available to every owned role. Only the root reconciles dispatch above.
|
|
774
776
|
if (mission) {
|
|
775
|
-
|
|
777
|
+
const run = await operators.read(root);
|
|
778
|
+
const completion = run?.phase === "awaiting-acceptance" ? await control.completionReadiness(root) : undefined;
|
|
779
|
+
return JSON.stringify({ ...missionDispatchPacket(mission, run), budget: await control.currentBudget(root),
|
|
780
|
+
...(completion ? { completion, ...(!completion.ready ? { next_action: completion.blockers.map(item => item.next_action).join("\n") } : {}) } : {}) });
|
|
781
|
+
}
|
|
782
|
+
if (root && context.sessionID !== root) {
|
|
783
|
+
const owned = await operators.read(root);
|
|
784
|
+
if (owned?.units.some(unit => unit.childSessionID === context.sessionID))
|
|
785
|
+
return JSON.stringify(await operatorPacket(owned));
|
|
776
786
|
}
|
|
777
787
|
await requireRoot(context.sessionID);
|
|
778
788
|
const relocated = await relocatedMission(context.sessionID);
|
|
@@ -910,13 +920,16 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
910
920
|
if (input.returnReportTransport === "tool-result" && result.receipt?.status === "succeeded") {
|
|
911
921
|
const text = `✅ **DONE** \`${state.runID}\` — ${state.acceptance.length} acceptance requirements verified.\n\n` +
|
|
912
922
|
`**変更点:** ${state.units.map(unit => unit.unit.title).join("; ")}\n\n` +
|
|
913
|
-
`**確認結果:**
|
|
923
|
+
`**確認結果:** 宣言検証合格 — ${[...new Set(state.units.flatMap(unit => unit.unit.validation))].join("; ")}` +
|
|
924
|
+
(mission?.review ? `\n独立レビュー: ${mission.review.verdict === "evidence-gaps" ? "証拠不足を残して受入れ(レビューPASSではない)" : mission.review.verdict}.` : "") + "\n\n**次:** なし";
|
|
914
925
|
const rendered = await control.renderReturnReport(context.sessionID, text, goalFingerprint(result.receipt)).catch(() => undefined);
|
|
915
926
|
if (rendered)
|
|
916
927
|
panel = returnReportPanel(rendered);
|
|
917
928
|
}
|
|
918
929
|
return JSON.stringify({ status: result.status, run_id: state.runID,
|
|
919
930
|
acceptance_fingerprint: state.acceptanceFingerprint, receipt: result.receipt ?? null,
|
|
931
|
+
...(result.completion ? { completion: result.completion,
|
|
932
|
+
next_action: result.completion.blockers.map(item => item.next_action).join("\n") } : {}),
|
|
920
933
|
...(panel ? { return_report: panel,
|
|
921
934
|
return_report_instruction: "Append return_report verbatim exactly once to your final answer as Markdown, outside any code fence. It is a host-authored receipt display, not a new task or input. Do not summarize, recalculate or request another model turn for it." } : {}) });
|
|
922
935
|
} };
|
|
@@ -1228,14 +1241,15 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
1228
1241
|
if (actor === root && (mission.coordinator !== null || plan.units.length !== 1))
|
|
1229
1242
|
throw new Error("mission-coordinator-required: dispatch the returned Coordinator task");
|
|
1230
1243
|
const same = previous?.planHash === createHash("sha256").update(JSON.stringify(plan)).digest("hex");
|
|
1231
|
-
|
|
1244
|
+
const replanning = previous && !["completed", "cancelled"].includes(previous.phase) && !same;
|
|
1245
|
+
if (replanning) {
|
|
1232
1246
|
if (!reason?.trim())
|
|
1233
1247
|
throw new Error("mission-replan-reason-required: name the observed correction or write-scope extension");
|
|
1234
|
-
await operators.retireMissionRun(root);
|
|
1235
1248
|
}
|
|
1236
1249
|
const budget = await control.currentBudget(root);
|
|
1237
1250
|
if (budget && budget.remaining_units < plan.units.length && !same)
|
|
1238
|
-
throw new Error(
|
|
1251
|
+
throw new Error(`mission-budget-exhausted: plan needs ${plan.units.length} units; ${budget.remaining_units} remain. ` +
|
|
1252
|
+
"The current run is retained. Correct the plan within the remaining budget, or report a necessary cumulative extension to Operator.");
|
|
1239
1253
|
// Cancellation marks the durable units before interrupting their native sessions. A cancelled
|
|
1240
1254
|
// status alone therefore cannot prove the old Worker stopped or its reservation settled.
|
|
1241
1255
|
const cancelledPredecessor = previous?.phase === "cancelled" &&
|
|
@@ -1247,8 +1261,9 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
1247
1261
|
get: async (id) => payload(await session("get", { path: { id }, query: { directory: input.directory } })),
|
|
1248
1262
|
children: async (id) => payload(await session("children", { path: { id }, query: { directory: input.directory } })),
|
|
1249
1263
|
}) : [];
|
|
1250
|
-
const
|
|
1251
|
-
|
|
1264
|
+
const dispatcher = actor === root ? undefined : { sessionID: actor, callID: mission.callID };
|
|
1265
|
+
const state = replanning ? await operators.replanMission(root, previous.runID, plan, dispatcher)
|
|
1266
|
+
: await operators.prepareMission(root, plan, dispatcher, mission.supersededRunID, terminalChildren);
|
|
1252
1267
|
await restorePriorAcceptance(root, state);
|
|
1253
1268
|
await control.registerGoalDeclaration(root, state.units[0].task.prompt, true);
|
|
1254
1269
|
control.enableUnits(root, state.units.filter(unit => unit.status === "pending").length);
|
|
@@ -1262,7 +1277,7 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
1262
1277
|
});
|
|
1263
1278
|
return JSON.stringify(await operators.next(root, actor));
|
|
1264
1279
|
}
|
|
1265
|
-
tools[planUnits] = { description: "Coordinator (or single-unit Fast-lane Operator): declare useful units, then dispatch the returned Worker immediately. Host generates IDs, handoff, manifest and proof mapping. Keep every original requirement covered. Final validation command in each unit proves that unit; read-only diagnostic commands need no registration. Recalling with reason replaces settled work within unchanged requirements and cumulative budget; include required scope extensions here.",
|
|
1280
|
+
tools[planUnits] = { description: "Coordinator (or single-unit Fast-lane Operator): declare useful units, then dispatch the returned Worker immediately. Host generates IDs, handoff, manifest and proof mapping. Keep every original requirement covered. Use write: [] for read-only verification; use dir/** for directory outputs including not-yet-created trees. Final validation command in each unit proves that unit; read-only diagnostic commands need no registration. Recalling with reason replaces settled work within unchanged requirements and cumulative budget; include required scope extensions here. Rejected budget, contract or control-storage preparation preserves the existing run so you can correct the plan directly.",
|
|
1266
1281
|
args: { units: { type: "array", minItems: 1, maxItems: 32, items: { type: "object", additionalProperties: false,
|
|
1267
1282
|
properties: { title: { type: "string" }, objective: { type: "string" }, read: stringList, write: stringList,
|
|
1268
1283
|
validation: stringList, requirement_ids: stringList }, required: ["title", "objective", "write", "validation"] } },
|
|
@@ -1310,6 +1325,7 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
1310
1325
|
`candidate_id: ${mission.id}`, `review_phase: ${phase}`, "canonical_validation_exit: 0", `risk_tags: [${risk.join(", ")}]`,
|
|
1311
1326
|
"Review this candidate independently. Use the language of the requirements/traces. Invoke no tools. First line: exactly PASS, FINDINGS or EVIDENCE_GAPS.",
|
|
1312
1327
|
"Use EVIDENCE_GAPS only when no finding establishes a source/test defect and every finding is proof the supplied artifact cannot settle, including process history later established on the base. Any concrete defect uses FINDINGS.",
|
|
1328
|
+
"This Reviewer's native outcome and final acceptance can only be observed after this review. List those as deferred Operator checks, not as a reason to request another review. Still assess all available source, validation and historical evidence independently.",
|
|
1313
1329
|
`acceptance: ${JSON.stringify(run.acceptance)}`, `changedLogicSummary: ${JSON.stringify(traces)}`,
|
|
1314
1330
|
...run.acceptance.map((_, i) => `acceptance[${i}] -> changedLogicSummary[${i}]`),
|
|
1315
1331
|
`manifest: ${JSON.stringify(run.units.map(unit => unit.unit))}`, `sourceFingerprint: ${source.fingerprint}`,
|
|
@@ -1347,8 +1363,7 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
1347
1363
|
tools[completeMission] = { description: "Operator only: after comparing the original request, source, actual checks and required independent review, explicitly accept the whole mission. Returns the measured 🐾 report on success; never treat Worker start or one passing check as completion.",
|
|
1348
1364
|
args: {}, execute: async (_args, context) => {
|
|
1349
1365
|
await requireRoot(context.sessionID);
|
|
1350
|
-
|
|
1351
|
-
await assertMissionReview(context.sessionID, mission);
|
|
1366
|
+
await missions.required(context.sessionID);
|
|
1352
1367
|
const run = await operators.required(context.sessionID);
|
|
1353
1368
|
const result = await tools[complete].execute({ run_id: run.runID, acceptance_fingerprint: run.acceptanceFingerprint }, context);
|
|
1354
1369
|
if (JSON.parse(result).status === "succeeded")
|
|
@@ -1630,7 +1645,7 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
1630
1645
|
const allowedRelease = request.tool === release;
|
|
1631
1646
|
const allowedValidation = ["bash", "shell"].includes(request.tool.toLowerCase()) && typeof args.command === "string" &&
|
|
1632
1647
|
repairAccess.expected_command !== null && normalizeCommand(args.command) === repairAccess.expected_command;
|
|
1633
|
-
if (!allowedRead && !allowedBind && !allowedRelease && !allowedValidation) {
|
|
1648
|
+
if (request.tool !== status && !allowedRead && !allowedBind && !allowedRelease && !allowedValidation) {
|
|
1634
1649
|
throw new Error("operator-contract-repair-validation-only");
|
|
1635
1650
|
}
|
|
1636
1651
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type GoalEvidence } from "../core/goal-bound.js";
|
|
2
|
+
type Binding = NonNullable<GoalEvidence["protected_binding"]>;
|
|
3
|
+
/** Live orchestration/Git bookkeeping is readable context, not the source being validated. */
|
|
4
|
+
export declare function isRuntimeControlPath(path: string): boolean;
|
|
5
|
+
export declare function protectedSnapshot(authorization: {
|
|
6
|
+
manifestPath: string;
|
|
7
|
+
manifestHash: string;
|
|
8
|
+
projectRoot: string;
|
|
9
|
+
}): Promise<{
|
|
10
|
+
readonly binding: Binding;
|
|
11
|
+
readonly source: string;
|
|
12
|
+
readonly candidate: string;
|
|
13
|
+
} | undefined>;
|
|
14
|
+
export declare function refreshProtectedSnapshot(projectRoot: string, binding: Binding): Promise<{
|
|
15
|
+
readonly source: string;
|
|
16
|
+
readonly candidate: string;
|
|
17
|
+
} | undefined>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readFile, readdir, readlink, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { goalFingerprint } from "../core/goal-bound.js";
|
|
5
|
+
import { normalizeManifestScope } from "../core/path.js";
|
|
6
|
+
import { RUNTIME_PROFILES } from "../core/runtime-profile.js";
|
|
7
|
+
const controlRoots = new Set([".git", ...Object.values(RUNTIME_PROFILES).map(profile => profile.stateDirectory)]);
|
|
8
|
+
/** Live orchestration/Git bookkeeping is readable context, not the source being validated. */
|
|
9
|
+
export function isRuntimeControlPath(path) {
|
|
10
|
+
const first = path.replaceAll("\\", "/").split("/")[0];
|
|
11
|
+
return controlRoots.has(process.platform === "win32" ? first.toLowerCase() : first);
|
|
12
|
+
}
|
|
13
|
+
async function protectedScopeDigest(projectRoot, paths, manifestHash, sourcePolicy) {
|
|
14
|
+
const entries = [];
|
|
15
|
+
const canonicalRoot = await realpath(projectRoot);
|
|
16
|
+
const visit = async (absolute) => {
|
|
17
|
+
const scoped = relative(projectRoot, absolute).replaceAll("\\", "/");
|
|
18
|
+
if (scoped === ".." || scoped.startsWith("../") || isAbsolute(scoped))
|
|
19
|
+
return false;
|
|
20
|
+
if (sourcePolicy === "project-files-v1" && isRuntimeControlPath(scoped))
|
|
21
|
+
return true;
|
|
22
|
+
const metadata = await lstat(absolute).catch((error) => {
|
|
23
|
+
if (error.code === "ENOENT")
|
|
24
|
+
return undefined;
|
|
25
|
+
throw error;
|
|
26
|
+
});
|
|
27
|
+
if (metadata === undefined) {
|
|
28
|
+
entries.push([scoped, "missing"]);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
if (metadata.isSymbolicLink()) {
|
|
32
|
+
const target = await realpath(absolute).catch(() => undefined);
|
|
33
|
+
if (target === undefined)
|
|
34
|
+
return false;
|
|
35
|
+
const relativeTarget = relative(canonicalRoot, target);
|
|
36
|
+
if (relativeTarget === ".." || relativeTarget.startsWith("../") || isAbsolute(relativeTarget) ||
|
|
37
|
+
!(await stat(target)).isFile())
|
|
38
|
+
return false;
|
|
39
|
+
const link = await readlink(absolute);
|
|
40
|
+
entries.push([scoped, `symlink:${link}`, createHash("sha256").update(await readFile(target)).digest("hex")]);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
if (metadata.isDirectory()) {
|
|
44
|
+
entries.push([scoped, "directory"]);
|
|
45
|
+
for (const child of (await readdir(absolute)).sort())
|
|
46
|
+
if (!await visit(join(absolute, child)))
|
|
47
|
+
return false;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
if (!metadata.isFile())
|
|
51
|
+
return false;
|
|
52
|
+
entries.push([scoped, "file", createHash("sha256").update(await readFile(absolute)).digest("hex")]);
|
|
53
|
+
return true;
|
|
54
|
+
};
|
|
55
|
+
for (const path of [...new Set(paths)].sort())
|
|
56
|
+
if (!await visit(path))
|
|
57
|
+
return undefined;
|
|
58
|
+
return goalFingerprint({ manifest_hash: `sha256:${manifestHash}`, entries });
|
|
59
|
+
}
|
|
60
|
+
export async function protectedSnapshot(authorization) {
|
|
61
|
+
const manifestSource = await readFile(authorization.manifestPath).catch(() => undefined);
|
|
62
|
+
if (manifestSource === undefined)
|
|
63
|
+
return undefined;
|
|
64
|
+
const manifestHash = createHash("sha256").update(manifestSource).digest("hex");
|
|
65
|
+
if (manifestHash !== authorization.manifestHash)
|
|
66
|
+
return undefined;
|
|
67
|
+
const relativePath = relative(authorization.projectRoot, authorization.manifestPath).replaceAll("\\", "/");
|
|
68
|
+
const manifest = JSON.parse(manifestSource.toString("utf8"));
|
|
69
|
+
const actualPaths = (entries) => entries.map((entry) => {
|
|
70
|
+
const path = normalizeManifestScope(entry);
|
|
71
|
+
return path.kind === "relative" ? resolve(authorization.projectRoot, path.path) : resolve(path.path);
|
|
72
|
+
});
|
|
73
|
+
const candidatePaths = actualPaths(manifest.write);
|
|
74
|
+
const sourcePaths = [...new Set([...actualPaths(manifest.read), ...candidatePaths])];
|
|
75
|
+
const source = await protectedScopeDigest(authorization.projectRoot, sourcePaths, manifestHash, "project-files-v1");
|
|
76
|
+
// Explicit outputs and the exact operation manifest remain pinned, including control-like paths.
|
|
77
|
+
const candidate = await protectedScopeDigest(authorization.projectRoot, candidatePaths, manifestHash);
|
|
78
|
+
if (source === undefined || candidate === undefined || relativePath.startsWith("../") || isAbsolute(relativePath))
|
|
79
|
+
return undefined;
|
|
80
|
+
return { binding: { manifest_hash: `sha256:${manifestHash}`, project_root: authorization.projectRoot,
|
|
81
|
+
manifest_path: relativePath, source_policy: "project-files-v1",
|
|
82
|
+
source_paths: sourcePaths.map(path => relative(authorization.projectRoot, path).replaceAll("\\", "/")),
|
|
83
|
+
candidate_paths: candidatePaths.map(path => relative(authorization.projectRoot, path).replaceAll("\\", "/")) }, source, candidate };
|
|
84
|
+
}
|
|
85
|
+
export async function refreshProtectedSnapshot(projectRoot, binding) {
|
|
86
|
+
const manifestSource = await readFile(resolve(projectRoot, binding.manifest_path)).catch(() => undefined);
|
|
87
|
+
if (manifestSource === undefined)
|
|
88
|
+
return undefined;
|
|
89
|
+
const manifestHash = createHash("sha256").update(manifestSource).digest("hex");
|
|
90
|
+
if (`sha256:${manifestHash}` !== binding.manifest_hash)
|
|
91
|
+
return undefined;
|
|
92
|
+
// Legacy evidence retains its original recipe; a new policy cannot relabel stale proof as fresh.
|
|
93
|
+
if (binding.source_policy !== undefined && binding.source_policy !== "project-files-v1")
|
|
94
|
+
return undefined;
|
|
95
|
+
const source = await protectedScopeDigest(projectRoot, binding.source_paths.map(path => resolve(projectRoot, path)), manifestHash, binding.source_policy);
|
|
96
|
+
const candidate = await protectedScopeDigest(projectRoot, binding.candidate_paths.map(path => resolve(projectRoot, path)), manifestHash);
|
|
97
|
+
return source === undefined || candidate === undefined ? undefined : { source, candidate };
|
|
98
|
+
}
|
|
@@ -105,9 +105,11 @@ export interface RuntimeBridge {
|
|
|
105
105
|
manifestHash: string;
|
|
106
106
|
goalFingerprint: string;
|
|
107
107
|
}): Promise<readonly GoalEvidence[]>;
|
|
108
|
+
completionReadiness(rootSessionID: string): Promise<import("./goal-completion.js").CompletionReadiness>;
|
|
108
109
|
completeRoot(rootSessionID: string, acceptanceFingerprint: string): Promise<{
|
|
109
110
|
status: "succeeded" | "awaiting-evidence";
|
|
110
111
|
receipt?: GoalTerminalReceipt;
|
|
112
|
+
completion?: import("./goal-completion.js").CompletionReadiness;
|
|
111
113
|
}>;
|
|
112
114
|
stopAutomaticRecovery(rootSessionID: string): Promise<void>;
|
|
113
115
|
stopRoot(rootSessionID: string): Promise<void>;
|
|
@@ -3,6 +3,17 @@ import { STRATEGY_TRIGGERS, SOURCE_REVIEW_RISK_TAGS } from "./core/consultation.
|
|
|
3
3
|
import { SCOUT_EVIDENCE_CODES } from "./core/scout-contract.js";
|
|
4
4
|
/** Repository-local dependency environment shared by all units; excluded from reviewed and captured source. */
|
|
5
5
|
export const TOOL_ENVIRONMENT = ".sortie-env";
|
|
6
|
+
const OPERATION_GUIDE = `## Practical operation guide
|
|
7
|
+
|
|
8
|
+
Use file paths for exact outputs and dir/** for a directory tree, including a directory that does not
|
|
9
|
+
exist yet. The host interprets dir/** consistently for writes, validation fingerprints and review.
|
|
10
|
+
Create the directory with mkdir -p dir; do not add parent directories merely to make child writes work.
|
|
11
|
+
For local source snapshots use git archive --format=tar --output=dir/source.tar <local-ref>.
|
|
12
|
+
For tag observation use git tag --points-at <commit>. For HTTPS downloads use curl -fLsS -o dir/file <url>;
|
|
13
|
+
--write-out '%{http_code} %{size_download}\\n' may print metadata to stdout. Declare all actual outputs.
|
|
14
|
+
Reuse pinned artifacts and successful checks when the request permits and the inputs are unchanged.
|
|
15
|
+
Do not repeat candidate discovery, dependency setup or validation merely because a Worker changed.
|
|
16
|
+
`;
|
|
6
17
|
function controls(profile, names) {
|
|
7
18
|
return names.map(name => ` ${profile.toolPrefix}${name}: true`).join("\n");
|
|
8
19
|
}
|
|
@@ -62,6 +73,10 @@ Ordinary defects return to Coordinator, not the user. Ask through question only
|
|
|
62
73
|
an extension beyond the original requirements, or a cumulative budget increase. Resume the same work after
|
|
63
74
|
the answer. Do not reset spend, silently shrink acceptance, or create substitute goals.
|
|
64
75
|
|
|
76
|
+
When bounded process reflections are injected, pass the relevant prevention in Coordinator feedback while
|
|
77
|
+
preserving the original requirements. After a resolved repeated process failure, record its verified cause/prevention
|
|
78
|
+
through reflection if enabled; retain concrete evidence and prefer a durable fix for recurring causes.
|
|
79
|
+
|
|
65
80
|
Retain 🐾 Sortie presentation and measured return panels. Append complete_mission's return_report verbatim
|
|
66
81
|
once. Do not invent scores, medals, costs, savings, models or successful checks. Release/publish requires the
|
|
67
82
|
existing user authorization and project gates; npm publication remains manual.
|
|
@@ -98,6 +113,7 @@ through shell. There is no proposal/approval/contract-repair round trip in this
|
|
|
98
113
|
Investigate only enough to start the first useful Worker. Prefer a targeted read/reproduction over a broad
|
|
99
114
|
inventory or speculative full design. Call ${profile.toolPrefix}plan_units with concise units:
|
|
100
115
|
title, objective, read/write file or directory scopes, validation commands, optionally requirement_ids.
|
|
116
|
+
For read-only verification, use write: []; do not invent an output file or request write access to inputs.
|
|
101
117
|
Keep all original requirements covered; omitted requirement_ids means all. Last validation command proves
|
|
102
118
|
that unit. If your quick check shows repository-declared dependencies or the test runner are missing, keep
|
|
103
119
|
setup inside the first unit: declare its checks through the repository-local tool environment ${TOOL_ENVIRONMENT}/
|
|
@@ -111,10 +127,16 @@ For a needed write-scope addition within the original request, call expand_unit
|
|
|
111
127
|
reason; the host returns a replacement contract without Operator approval. For a changed approach, formal
|
|
112
128
|
check or reviewer finding, call plan_units with the corrected units and a short observed reason. Replanning
|
|
113
129
|
preserves every requirement, failed-check history and cumulative budget. Never replace an active Worker.
|
|
130
|
+
Rejected budget/contract checks or control-storage preparation leave the old run available. Correct the
|
|
131
|
+
reported cause and call plan_units again; a local plan repair needs no cancellation or user approval.
|
|
132
|
+
On a returned denial, correct the exact command form or output scope before redispatch; reuse established
|
|
133
|
+
candidate identity, setup and evidence in the next objective instead of requesting the entire investigation again.
|
|
114
134
|
Workers freely investigate within their unit and execute exact formal checks for host recording. Do not
|
|
115
135
|
require them to predeclare exploratory commands. Require meaningful evidence, not extra testing for its
|
|
116
136
|
own sake. Do not repeat passed checks unless source changes or unresolved concerns justify it.
|
|
117
137
|
|
|
138
|
+
${OPERATION_GUIDE}
|
|
139
|
+
|
|
118
140
|
Scout is optional for one precise missing fact. Its prompt includes missing_evidence_code:
|
|
119
141
|
${SCOUT_EVIDENCE_CODES.join(" | ")}, an exact project_root and at most four known_paths.
|
|
120
142
|
Advisor is optional for one material decision; include strategy_trigger: ${STRATEGY_TRIGGERS.join(" | ")}.
|
|
@@ -180,7 +202,13 @@ are complete. Never fabricate logs, costs or exits. Do not stage outside declare
|
|
|
180
202
|
publish, or take over Coordinator decisions. The parent releases your write binding after return.
|
|
181
203
|
|
|
182
204
|
A local tool/permission/handoff defect returns PROCESS_DEFECT: local: <condition> and its exact diagnostic
|
|
183
|
-
once to Coordinator
|
|
205
|
+
once to Coordinator when its remedy requires a changed contract. If the host explicitly returns
|
|
206
|
+
action=correct-format-within-current-manifest, correct the supported command form in this same Task,
|
|
207
|
+
preserving the operation, inputs and destinations, then continue. An unchanged denied request is not a correction.
|
|
208
|
+
If correcting the form changes the intended operation or requires another output, return the exact required
|
|
209
|
+
correction to Coordinator. Do not repeat the same refused operation. Only a proven external dependency or
|
|
184
210
|
user-only choice uses TRUE_BLOCKER: external: <condition> or TRUE_BLOCKER: user-decision: <condition>.
|
|
211
|
+
|
|
212
|
+
${OPERATION_GUIDE}
|
|
185
213
|
`;
|
|
186
214
|
}
|