@cassiomc1/forgeloop 1.9.0 → 1.10.1
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/AGENT_COMPATIBILITY.md +15 -0
- package/DELEGATION_PROTOCOL.md +6 -0
- package/DOCS_INDEX.md +13 -2
- package/LOOP_ENGINEERING.md +17 -4
- package/LOOP_SYSTEM_DESIGN.md +56 -0
- package/ORCHESTRATOR_INTEGRATION.md +9 -0
- package/PROTOCOL_INTEGRATION.md +31 -0
- package/QUALITY_SCORECARD.md +2 -2
- package/README.md +53 -0
- package/TERMINOLOGY.md +12 -0
- package/THREAT_MODEL.md +24 -0
- package/completions/_forgeloop +2 -1
- package/completions/forgeloop.bash +3 -1
- package/completions/forgeloop.fish +9 -1
- package/docs/ADVISORY_CONTEXT.md +174 -0
- package/docs/AGENT_PROTOCOL_SUMMARY.md +28 -2
- package/docs/ARTIFACT_REFERENCE.md +29 -0
- package/docs/CLI_REFERENCE.md +40 -0
- package/docs/CODE_ATTESTATION.md +9 -0
- package/docs/CROSS_HARNESS_CONTINUITY.md +85 -0
- package/docs/DOCUMENTATION_GUIDE.md +11 -4
- package/docs/EXECUTION_PROFILE_BENCHMARKS.md +10 -0
- package/docs/GETTING_STARTED.md +22 -0
- package/docs/KNOWLEDGE_SOURCES.md +10 -0
- package/docs/MCP.md +29 -1
- package/docs/PACKAGE_CONTENTS.md +83 -0
- package/docs/RECIPES.md +80 -0
- package/docs/RELEASE_CHECKLIST.md +22 -0
- package/docs/REVISION_PROVIDERS.md +9 -0
- package/docs/TROUBLESHOOTING.md +66 -2
- package/docs/UNIVERSAL_INTEGRATION.md +60 -0
- package/docs/assets/diagrams/forgeloop-code-attestation-flow.html +13 -2
- package/docs/assets/diagrams/forgeloop-code-attestation-flow.receipt.json +6 -6
- package/docs/assets/diagrams/forgeloop-code-attestation-flow.svg +10 -1
- package/docs/assets/diagrams/forgeloop-engineering-flow.html +22 -11
- package/docs/assets/diagrams/forgeloop-engineering-flow.receipt.json +6 -6
- package/docs/assets/diagrams/forgeloop-engineering-flow.svg +17 -8
- package/docs/assets/diagrams/forgeloop-verification-trust-flow.html +14 -3
- package/docs/assets/diagrams/forgeloop-verification-trust-flow.receipt.json +6 -6
- package/docs/assets/diagrams/forgeloop-verification-trust-flow.svg +10 -1
- package/docs/diagrams/README.md +17 -0
- package/docs/diagrams/forgeloop-code-attestation-flow.workflow.json +383 -57
- package/docs/diagrams/forgeloop-engineering-flow.workflow.json +374 -55
- package/docs/diagrams/forgeloop-verification-trust-flow.workflow.json +328 -47
- package/docs/diagrams/reviews/forgeloop-code-attestation-flow.review.json +4 -4
- package/docs/diagrams/reviews/forgeloop-engineering-flow.review.json +4 -4
- package/docs/diagrams/reviews/forgeloop-verification-trust-flow.review.json +4 -4
- package/package.json +14 -4
- package/schemas/handoff-envelope.schema.json +1 -0
- package/scripts/CI_VALIDATORS.md +24 -0
- package/scripts/check-changelog-freshness.mjs +27 -3
- package/scripts/check-critical-coverage.mjs +9 -0
- package/scripts/generate-agent-protocol-summary.mjs +18 -0
- package/src/cli.js +6 -0
- package/src/commands/doctor.js +11 -10
- package/src/commands/handoff-accept.js +36 -0
- package/src/commands/handoff-list.js +28 -2
- package/src/commands/handoff-show.js +27 -2
- package/src/commands/reconcile-continuity.js +4 -0
- package/src/core/actions.js +2 -2
- package/src/core/advisory-context/constants.js +74 -0
- package/src/core/advisory-context/provider.js +287 -0
- package/src/core/advisory-context/service.js +140 -0
- package/src/core/approvals.js +2 -2
- package/src/core/artifacts.js +3 -3
- package/src/core/checks.js +0 -33
- package/src/core/cli-command-definitions.js +17 -0
- package/src/core/command-executors.js +12 -0
- package/src/core/command-input.js +11 -1
- package/src/core/completion.js +2 -2
- package/src/core/continuity-lint.js +89 -0
- package/src/core/continuity-reconciliation.js +16 -0
- package/src/core/continuity.js +10 -11
- package/src/core/error-codes.js +113 -0
- package/src/core/events.js +37 -5
- package/src/core/execution-profile-context.js +15 -1
- package/src/core/execution-profile.js +18 -5
- package/src/core/filesystem.js +18 -2
- package/src/core/handoff-acceptance.js +277 -0
- package/src/core/handoff.js +41 -8
- package/src/core/integration-invocation-policy.js +19 -2
- package/src/core/integration-resources.js +21 -1
- package/src/core/next-action-pending-actions.js +255 -0
- package/src/core/next-action-phases.js +26 -764
- package/src/core/next-action-planned-phase.js +51 -0
- package/src/core/next-action-quality-guidance.js +19 -0
- package/src/core/next-action-recovery-phases.js +97 -0
- package/src/core/next-action-refresh.js +20 -0
- package/src/core/next-action-review-phase.js +189 -0
- package/src/core/next-action-verification-phase.js +192 -0
- package/src/core/portable-context.js +103 -0
- package/src/core/protocol-info.js +18 -2
- package/src/core/runtime-context.js +31 -0
- package/src/core/task-recovery.js +2 -2
- package/src/core/transaction-maintenance.js +70 -0
- package/src/core/transaction.js +31 -10
- package/src/core/work-state.js +5 -5
- package/src/integration.d.ts +135 -3
- package/src/integration.js +23 -0
- package/src/core/cli-metadata.js +0 -23
- package/src/core/decision-classification.js +0 -55
- package/src/core/gates.js +0 -57
- package/src/core/workflow-compatibility.js +0 -151
package/src/core/events.js
CHANGED
|
@@ -11,7 +11,7 @@ import { PROTOCOL_VERSION } from "./protocol.js";
|
|
|
11
11
|
import { isRecoverableCompletionEvidenceCode } from "./completion-recovery.js";
|
|
12
12
|
|
|
13
13
|
import { taskArtifactPath } from "./task-paths.js";
|
|
14
|
-
import {
|
|
14
|
+
import { getTaskTransaction, withTaskTransaction } from "./transaction.js";
|
|
15
15
|
|
|
16
16
|
import { assertDiagnosisDetails } from "./diagnosis-model.js";
|
|
17
17
|
import {
|
|
@@ -160,6 +160,16 @@ export function validateKnownEventDetails(event) {
|
|
|
160
160
|
}
|
|
161
161
|
assertFingerprint(event.details.digest, "HANDOFF_CREATED details.digest");
|
|
162
162
|
return;
|
|
163
|
+
case "HANDOFF_ACCEPTED":
|
|
164
|
+
if (!event.details || typeof event.details !== "object" || Array.isArray(event.details)
|
|
165
|
+
|| typeof event.details.handoffId !== "string" || !/^handoff-[A-Za-z0-9_-]+$/.test(event.details.handoffId)
|
|
166
|
+
|| typeof event.details.handoffDigest !== "string"
|
|
167
|
+
|| typeof event.details.consumerId !== "string" || !event.details.consumerId.trim()
|
|
168
|
+
|| (event.details.harness !== undefined && (typeof event.details.harness !== "string" || !event.details.harness.trim()))) {
|
|
169
|
+
throw protocolError("E_EVENT_INVALID", "HANDOFF_ACCEPTED requires a valid handoffId, handoffDigest, and consumerId");
|
|
170
|
+
}
|
|
171
|
+
assertFingerprint(event.details.handoffDigest, "HANDOFF_ACCEPTED details.handoffDigest");
|
|
172
|
+
return;
|
|
163
173
|
case "RESPONSIBILITY_SET":
|
|
164
174
|
assertStructuredArtifactEvent(event, ["responsibilityFingerprint"], "RESPONSIBILITY_SET");
|
|
165
175
|
if (typeof event.details.label !== "string" || !event.details.label) {
|
|
@@ -301,7 +311,7 @@ export function buildProtocolEvent(input, { checkpoint } = {}) {
|
|
|
301
311
|
}
|
|
302
312
|
|
|
303
313
|
export async function previewProtocolEvent(target, input, packageRoot, options = {}) {
|
|
304
|
-
const activeTransaction =
|
|
314
|
+
const activeTransaction = (await getTaskTransaction(target));
|
|
305
315
|
if (!activeTransaction) {
|
|
306
316
|
return withTaskTransaction({
|
|
307
317
|
target,
|
|
@@ -327,7 +337,7 @@ export async function readEvents(target, packageRoot, options = {}) {
|
|
|
327
337
|
const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
|
|
328
338
|
await assertSafePath(target, relPath);
|
|
329
339
|
const eventsPath = ensureWithin(target, relPath);
|
|
330
|
-
const transaction =
|
|
340
|
+
const transaction = (await getTaskTransaction(target));
|
|
331
341
|
if (transaction) {
|
|
332
342
|
const stagedText = await transaction.readText(relPath);
|
|
333
343
|
if (stagedText !== null) return parseEventsText(stagedText, relPath, packageRoot);
|
|
@@ -345,7 +355,7 @@ export async function readEventTail(target, packageRoot, options = {}) {
|
|
|
345
355
|
const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
|
|
346
356
|
await assertSafePath(target, relPath);
|
|
347
357
|
const eventsPath = ensureWithin(target, relPath);
|
|
348
|
-
const transaction =
|
|
358
|
+
const transaction = (await getTaskTransaction(target));
|
|
349
359
|
if (transaction) {
|
|
350
360
|
const stagedText = await transaction.readText(relPath);
|
|
351
361
|
if (stagedText !== null) {
|
|
@@ -401,7 +411,7 @@ async function parseEventsText(text, relPath, packageRoot) {
|
|
|
401
411
|
}
|
|
402
412
|
|
|
403
413
|
export async function appendProtocolEvent(target, input, packageRoot, options = {}) {
|
|
404
|
-
const activeTransaction =
|
|
414
|
+
const activeTransaction = (await getTaskTransaction(target));
|
|
405
415
|
if (typeof input?.taskId !== "string" || !input.taskId) throw protocolError("E_EVENT_INVALID", "event taskId is required");
|
|
406
416
|
if (typeof input?.event !== "string" || !input.event) throw protocolError("E_EVENT_INVALID", "event type is required");
|
|
407
417
|
const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
|
|
@@ -513,6 +523,8 @@ export async function validateEventLedger(target, packageRoot, options = {}) {
|
|
|
513
523
|
const seen = new Set();
|
|
514
524
|
let lastMilestone = -1;
|
|
515
525
|
const milestoneCounts = new Map();
|
|
526
|
+
const createdHandoffs = new Map();
|
|
527
|
+
const acceptedHandoffs = new Set();
|
|
516
528
|
for (const [index, event] of events.entries()) {
|
|
517
529
|
if (event.seq !== index + 1) {
|
|
518
530
|
errors.push({ code: "E_EVENT_INVALID", message: `event sequence must be ${index + 1}` });
|
|
@@ -584,6 +596,26 @@ export async function validateEventLedger(target, packageRoot, options = {}) {
|
|
|
584
596
|
if (event.event === "COMPLETION_REJECTED" && !seen.has("VERIFICATION_STARTED")) {
|
|
585
597
|
errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: "completion rejected before verification started" });
|
|
586
598
|
}
|
|
599
|
+
if (event.event === "HANDOFF_CREATED") {
|
|
600
|
+
if (event.details?.handoffId) {
|
|
601
|
+
createdHandoffs.set(event.details.handoffId, event.details.digest);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (event.event === "HANDOFF_ACCEPTED") {
|
|
605
|
+
const hId = event.details?.handoffId;
|
|
606
|
+
if (hId) {
|
|
607
|
+
if (acceptedHandoffs.has(hId)) {
|
|
608
|
+
errors.push({ code: "E_HANDOFF_ALREADY_ACCEPTED", message: `duplicate HANDOFF_ACCEPTED for handoffId ${hId}` });
|
|
609
|
+
} else {
|
|
610
|
+
acceptedHandoffs.add(hId);
|
|
611
|
+
if (!createdHandoffs.has(hId)) {
|
|
612
|
+
errors.push({ code: "E_HANDOFF_ACCEPTANCE_INCONSISTENT", message: `HANDOFF_ACCEPTED has no preceding HANDOFF_CREATED for handoffId ${hId}` });
|
|
613
|
+
} else if (createdHandoffs.get(hId) !== event.details?.handoffDigest) {
|
|
614
|
+
errors.push({ code: "E_HANDOFF_ACCEPTANCE_INCONSISTENT", message: `HANDOFF_ACCEPTED digest mismatch for handoffId ${hId}` });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
587
619
|
}
|
|
588
620
|
validateLegacyRecoveryMigrations(events, errors, {
|
|
589
621
|
allowUnmigratedLegacyRecoveryEvents: options?.allowUnmigratedLegacyRecoveryEvents === true,
|
|
@@ -112,6 +112,7 @@ export function projectExecutionProfileContext({
|
|
|
112
112
|
route,
|
|
113
113
|
state,
|
|
114
114
|
nextAction,
|
|
115
|
+
runtimeContext,
|
|
115
116
|
} = {}) {
|
|
116
117
|
if (typeof taskId !== "string" || !taskId) throw contextError("E_TASK_REQUIRED", "taskId is required");
|
|
117
118
|
if (!contract || typeof contract !== "object" || Array.isArray(contract)) {
|
|
@@ -126,6 +127,18 @@ export function projectExecutionProfileContext({
|
|
|
126
127
|
const profile = route.executionProfile ? assertExecutionProfile(route.executionProfile) : legacyExecutionProfile();
|
|
127
128
|
const resolvedProfile = profile.resolved;
|
|
128
129
|
const policy = getExecutionProfileContextPolicy(resolvedProfile);
|
|
130
|
+
const available = [...OPTIONAL_CONTEXT_BY_PROFILE[resolvedProfile]];
|
|
131
|
+
const advisoryProviders = runtimeContext?.advisoryContextProviders;
|
|
132
|
+
const hasAdvisory = Boolean(
|
|
133
|
+
advisoryProviders && (
|
|
134
|
+
advisoryProviders instanceof Map
|
|
135
|
+
? advisoryProviders.size > 0
|
|
136
|
+
: Object.keys(advisoryProviders).length > 0
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
if (hasAdvisory && !available.includes("advisory-context")) {
|
|
140
|
+
available.push("advisory-context");
|
|
141
|
+
}
|
|
129
142
|
return {
|
|
130
143
|
schemaVersion: 1,
|
|
131
144
|
protocolVersion: 1,
|
|
@@ -140,7 +153,7 @@ export function projectExecutionProfileContext({
|
|
|
140
153
|
verificationRequirements: verificationRequirements(contract),
|
|
141
154
|
contextPolicy: policy,
|
|
142
155
|
optionalContext: {
|
|
143
|
-
available
|
|
156
|
+
available,
|
|
144
157
|
loaded: [],
|
|
145
158
|
},
|
|
146
159
|
invariants: { ...PROFILE_INVARIANTS },
|
|
@@ -173,5 +186,6 @@ export async function buildExecutionProfileContext({
|
|
|
173
186
|
route: routeArtifact.value,
|
|
174
187
|
state,
|
|
175
188
|
nextAction,
|
|
189
|
+
runtimeContext,
|
|
176
190
|
});
|
|
177
191
|
}
|
|
@@ -66,24 +66,37 @@ function contractCollections(contract) {
|
|
|
66
66
|
|
|
67
67
|
function contractSignals(contract) {
|
|
68
68
|
const collections = contractCollections(contract);
|
|
69
|
+
const obligations = [];
|
|
70
|
+
const types = new Set();
|
|
71
|
+
function collect(requirement) {
|
|
72
|
+
if (typeof requirement === "string") obligations.push(requirement);
|
|
73
|
+
else if (requirement && typeof requirement === "object") {
|
|
74
|
+
if (typeof requirement.text === "string") obligations.push(requirement.text);
|
|
75
|
+
types.add(requirement.type);
|
|
76
|
+
if (Array.isArray(requirement.requirements)) requirement.requirements.forEach(collect);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
collections.successCriteria.forEach(collect);
|
|
80
|
+
if (Array.isArray(contract?.verification)) contract.verification.forEach(collect);
|
|
69
81
|
const riskText = collections.risks.join(" ").toLowerCase();
|
|
82
|
+
// Constraints and stop conditions describe boundaries, not obligations.
|
|
83
|
+
// Declared route risks remain authoritative even when a constraint excludes
|
|
84
|
+
// an operation; do not try to interpret prose negation as authorization.
|
|
70
85
|
const allText = [
|
|
71
86
|
...collections.deliverables,
|
|
72
|
-
...
|
|
73
|
-
...collections.constraints,
|
|
74
|
-
...collections.stopConditions,
|
|
87
|
+
...obligations,
|
|
75
88
|
].join(" ").toLowerCase();
|
|
76
89
|
const combinedText = `${riskText} ${allText}`;
|
|
77
90
|
const signals = {
|
|
78
91
|
secrets: /\bsecrets?\b|\bcredentials?\b|\bpasswords?\b|\bprivate keys?\b/.test(combinedText),
|
|
79
92
|
personalData: /\bpersonal[- ]data\b|\bpii\b|\bsensitive data\b/.test(combinedText),
|
|
80
|
-
publication: /\bpublication\b|\bpublish(?:ing|ed)?\b|\bdeploy(?:ment|ed)?\b/.test(combinedText),
|
|
93
|
+
publication: types.has("PUBLICATION") || /\bpublication\b|\bpublish(?:ing|ed)?\b|\bdeploy(?:ment|ed)?\b/.test(combinedText),
|
|
81
94
|
destructive: /\bdestructive\b|\birreversible\b|\bdrop database\b|\bdelete production\b/.test(combinedText),
|
|
82
95
|
migration: /\bmigration\b|\bmigrate\b|\bschema change\b|\birreversible persistence\b/.test(combinedText),
|
|
83
96
|
payment: /\bpayments?\b|\bcheckout\b|\bbilling\b/.test(combinedText),
|
|
84
97
|
externalMutation: /\bexternal mutation\b|\bmutate(?:s|d)? external\b|\bpublish(?:ing|ed)?\b/.test(combinedText),
|
|
85
98
|
authoritySensitiveExternalMutation: /\bauthority[- ]sensitive\b|\bhost[- ]authorized external\b|\bexternal mutation requiring authority\b/.test(combinedText),
|
|
86
|
-
broadProductionValidation: /\bbroad production validation\b|\bproduction validation\b|\bvalidate in production\b/.test(combinedText),
|
|
99
|
+
broadProductionValidation: types.has("PRODUCTION_READINESS") || /\bbroad production validation\b|\bproduction validation\b|\bvalidate in production\b/.test(combinedText),
|
|
87
100
|
};
|
|
88
101
|
return signals;
|
|
89
102
|
}
|
package/src/core/filesystem.js
CHANGED
|
@@ -73,11 +73,27 @@ export function ensureWithin(root, relativePath) {
|
|
|
73
73
|
return path.join(root, normalized);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
function normalizeWindowsPathForComparison(value) {
|
|
77
|
+
const lowerValue = value.toLowerCase();
|
|
78
|
+
let normalized = value;
|
|
79
|
+
if (lowerValue.startsWith("\\\\?\\unc\\")) {
|
|
80
|
+
normalized = `\\\\${value.slice(8)}`;
|
|
81
|
+
} else if (lowerValue.startsWith("\\\\.\\unc\\")) {
|
|
82
|
+
normalized = `\\\\${value.slice(8)}`;
|
|
83
|
+
} else if (lowerValue.startsWith("\\\\?\\")) {
|
|
84
|
+
normalized = value.slice(4);
|
|
85
|
+
} else if (lowerValue.startsWith("\\\\.\\")) {
|
|
86
|
+
normalized = value.slice(4);
|
|
87
|
+
}
|
|
88
|
+
return path.win32.normalize(normalized).toLowerCase();
|
|
89
|
+
}
|
|
90
|
+
|
|
76
91
|
export function isPathWithin(root, candidate, { platform = process.platform } = {}) {
|
|
77
92
|
const pathApi = platform === "win32" ? path.win32 : path;
|
|
78
93
|
const normalizeForComparison = (value) => {
|
|
79
|
-
|
|
80
|
-
|
|
94
|
+
return platform === "win32"
|
|
95
|
+
? normalizeWindowsPathForComparison(value)
|
|
96
|
+
: pathApi.normalize(value);
|
|
81
97
|
};
|
|
82
98
|
const relative = pathApi.relative(
|
|
83
99
|
normalizeForComparison(root),
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { canonicalFingerprint } from "./artifacts.js";
|
|
2
|
+
import { readContract } from "./contract.js";
|
|
3
|
+
import { readPersistedRoute } from "./route-artifact.js";
|
|
4
|
+
import { readWorkState } from "./work-state.js";
|
|
5
|
+
import { assertStateIdentity } from "./completion-relationships.js";
|
|
6
|
+
import { compareRepositoryFingerprint, readCanonicalHandoff } from "./handoff.js";
|
|
7
|
+
import { appendProtocolEvent, validateEventLedger } from "./events.js";
|
|
8
|
+
import { currentChangedPaths, currentRepositoryFingerprint } from "./repository.js";
|
|
9
|
+
import { withTaskTransaction } from "./transaction.js";
|
|
10
|
+
import { getPackageRoot } from "./templates.js";
|
|
11
|
+
import {
|
|
12
|
+
normalizePortableText,
|
|
13
|
+
assertPortableContextSafe,
|
|
14
|
+
} from "./portable-context.js";
|
|
15
|
+
import {
|
|
16
|
+
E_HANDOFF_ACCEPTANCE_UNBOUND,
|
|
17
|
+
E_HANDOFF_STALE,
|
|
18
|
+
E_HANDOFF_ALREADY_ACCEPTED,
|
|
19
|
+
E_HANDOFF_ACCEPTANCE_INCONSISTENT,
|
|
20
|
+
} from "./error-codes.js";
|
|
21
|
+
|
|
22
|
+
function acceptanceError(code, message) {
|
|
23
|
+
const error = new Error(message);
|
|
24
|
+
error.name = "HandoffAcceptanceError";
|
|
25
|
+
error.code = code;
|
|
26
|
+
return error;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function uniqueSorted(values) {
|
|
30
|
+
return [...new Set(values.filter(Boolean))].sort();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveHandoffAcceptance(input = {}, legacyHandoff) {
|
|
34
|
+
const projection = Array.isArray(input)
|
|
35
|
+
? { events: input, handoff: legacyHandoff }
|
|
36
|
+
: input;
|
|
37
|
+
const {
|
|
38
|
+
events = [],
|
|
39
|
+
handoff,
|
|
40
|
+
ledgerValid = true,
|
|
41
|
+
ledgerErrors = [],
|
|
42
|
+
} = projection ?? {};
|
|
43
|
+
|
|
44
|
+
if (ledgerValid !== true) {
|
|
45
|
+
return {
|
|
46
|
+
status: "INCONSISTENT",
|
|
47
|
+
reasonCodes: uniqueSorted(ledgerErrors.map((error) => typeof error === "string" ? error : error?.code)),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!handoff?.state?.workStateFingerprint) {
|
|
52
|
+
return { status: "UNBOUND" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const acceptedEvents = (events ?? []).filter(
|
|
56
|
+
(e) => e.event === "HANDOFF_ACCEPTED" && e.details?.handoffId === handoff.handoffId,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (acceptedEvents.length === 0) {
|
|
60
|
+
return { status: "OPEN" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (acceptedEvents.length > 1) {
|
|
64
|
+
return { status: "INCONSISTENT" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const accepted = acceptedEvents[0];
|
|
68
|
+
if (accepted.details?.handoffDigest !== handoff.artifactDigest) {
|
|
69
|
+
return { status: "INCONSISTENT" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
status: "ACCEPTED",
|
|
74
|
+
consumerId: accepted.details?.consumerId,
|
|
75
|
+
...(accepted.details?.harness ? { harness: accepted.details.harness } : {}),
|
|
76
|
+
acceptedAt: accepted.at,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function readHandoffAcceptanceLedger(target, packageRoot = getPackageRoot(), { taskId } = {}) {
|
|
81
|
+
try {
|
|
82
|
+
return await validateEventLedger(target, packageRoot, { taskId });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return {
|
|
85
|
+
valid: false,
|
|
86
|
+
events: [],
|
|
87
|
+
errors: [{
|
|
88
|
+
code: error.code ?? "E_EVENT_INVALID",
|
|
89
|
+
message: error.message,
|
|
90
|
+
}],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function acceptCanonicalHandoff(target, {
|
|
96
|
+
taskId,
|
|
97
|
+
handoffId,
|
|
98
|
+
consumerId,
|
|
99
|
+
harness,
|
|
100
|
+
packageRoot = getPackageRoot(),
|
|
101
|
+
} = {}) {
|
|
102
|
+
if (!target || typeof target !== "string") {
|
|
103
|
+
throw acceptanceError("E_TARGET_REQUIRED", "target path is required");
|
|
104
|
+
}
|
|
105
|
+
if (!taskId || typeof taskId !== "string") {
|
|
106
|
+
throw acceptanceError("E_TASK_REQUIRED", "taskId is required");
|
|
107
|
+
}
|
|
108
|
+
if (!handoffId || typeof handoffId !== "string") {
|
|
109
|
+
throw acceptanceError("E_HANDOFF_INVALID", "handoffId is required");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const normalizedConsumerId = normalizePortableText(consumerId, {
|
|
113
|
+
label: "consumerId",
|
|
114
|
+
maxLength: 128,
|
|
115
|
+
});
|
|
116
|
+
assertPortableContextSafe(normalizedConsumerId, { label: "consumerId" });
|
|
117
|
+
|
|
118
|
+
const normalizedHarness = harness !== undefined
|
|
119
|
+
? normalizePortableText(harness, {
|
|
120
|
+
label: "harness",
|
|
121
|
+
maxLength: 64,
|
|
122
|
+
optional: true,
|
|
123
|
+
})
|
|
124
|
+
: null;
|
|
125
|
+
if (normalizedHarness) {
|
|
126
|
+
assertPortableContextSafe(normalizedHarness, { label: "harness" });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return withTaskTransaction(
|
|
130
|
+
{
|
|
131
|
+
target,
|
|
132
|
+
taskId,
|
|
133
|
+
packageRoot,
|
|
134
|
+
operation: "handoff-accept",
|
|
135
|
+
recordCommitEvent: true,
|
|
136
|
+
},
|
|
137
|
+
async () => {
|
|
138
|
+
const { value: handoff } = await readCanonicalHandoff(target, {
|
|
139
|
+
taskId,
|
|
140
|
+
handoffId,
|
|
141
|
+
packageRoot,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (!handoff.state?.workStateFingerprint) {
|
|
145
|
+
throw acceptanceError(
|
|
146
|
+
E_HANDOFF_ACCEPTANCE_UNBOUND,
|
|
147
|
+
"Handoff snapshot is unbound (lacks state.workStateFingerprint); create a fresh handoff",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const currentState = await readWorkState(target, { packageRoot, taskId });
|
|
152
|
+
if (!currentState) {
|
|
153
|
+
throw acceptanceError(
|
|
154
|
+
E_HANDOFF_STALE,
|
|
155
|
+
"Canonical work state is unavailable for handoff acceptance",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const currentWorkStateFingerprint = canonicalFingerprint(currentState);
|
|
160
|
+
if (currentWorkStateFingerprint !== handoff.state.workStateFingerprint) {
|
|
161
|
+
throw acceptanceError(
|
|
162
|
+
E_HANDOFF_STALE,
|
|
163
|
+
"Current work state has drifted from handoff workStateFingerprint",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const contract = await readContract(target, packageRoot, { taskId });
|
|
168
|
+
const route = await readPersistedRoute(target, packageRoot, { taskId });
|
|
169
|
+
try {
|
|
170
|
+
assertStateIdentity({ contract, route, state: currentState });
|
|
171
|
+
} catch (error) {
|
|
172
|
+
throw acceptanceError(
|
|
173
|
+
E_HANDOFF_STALE,
|
|
174
|
+
`Current canonical task artifacts are not coherent with one another: ${error.message}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (contract.fingerprint !== handoff.state.contractFingerprint) {
|
|
178
|
+
throw acceptanceError(
|
|
179
|
+
E_HANDOFF_STALE,
|
|
180
|
+
"Current contract fingerprint has drifted from handoff contractFingerprint",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if ((route.fingerprint ?? null) !== (handoff.state.routeFingerprint ?? null)) {
|
|
184
|
+
throw acceptanceError(
|
|
185
|
+
E_HANDOFF_STALE,
|
|
186
|
+
"Current route fingerprint has drifted from handoff routeFingerprint",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const repositoryFingerprint = await currentRepositoryFingerprint(target);
|
|
191
|
+
if (compareRepositoryFingerprint(handoff.state.repositoryFingerprint, repositoryFingerprint) === "MISMATCH") {
|
|
192
|
+
throw acceptanceError(
|
|
193
|
+
E_HANDOFF_STALE,
|
|
194
|
+
"Current repository branch or HEAD has drifted from handoff snapshot",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const changedPaths = await currentChangedPaths(target);
|
|
199
|
+
const currentList = [...new Set((changedPaths ?? []).map((p) => p.replaceAll("\\", "/")))].sort();
|
|
200
|
+
const handoffList = [...new Set((handoff.state.changedPaths ?? []).map((p) => p.replaceAll("\\", "/")))].sort();
|
|
201
|
+
if (JSON.stringify(currentList) !== JSON.stringify(handoffList)) {
|
|
202
|
+
throw acceptanceError(
|
|
203
|
+
E_HANDOFF_STALE,
|
|
204
|
+
"Current changed paths have drifted from handoff snapshot",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const ledgerResult = await validateEventLedger(target, packageRoot, { taskId });
|
|
209
|
+
if (!ledgerResult.valid) {
|
|
210
|
+
throw acceptanceError(
|
|
211
|
+
E_HANDOFF_ACCEPTANCE_INCONSISTENT,
|
|
212
|
+
"Task event ledger is invalid",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const events = ledgerResult.events;
|
|
217
|
+
const createdEvent = events.find(
|
|
218
|
+
(e) => e.event === "HANDOFF_CREATED" && e.details?.handoffId === handoffId,
|
|
219
|
+
);
|
|
220
|
+
if (!createdEvent || createdEvent.details?.digest !== handoff.artifactDigest) {
|
|
221
|
+
throw acceptanceError(
|
|
222
|
+
E_HANDOFF_ACCEPTANCE_INCONSISTENT,
|
|
223
|
+
"No matching HANDOFF_CREATED event with matching digest found in ledger",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const existingAccepted = events.filter(
|
|
228
|
+
(e) => e.event === "HANDOFF_ACCEPTED" && e.details?.handoffId === handoffId,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
if (existingAccepted.length > 0) {
|
|
232
|
+
const prev = existingAccepted[0];
|
|
233
|
+
if (prev.details?.consumerId === normalizedConsumerId) {
|
|
234
|
+
return {
|
|
235
|
+
accepted: true,
|
|
236
|
+
idempotent: true,
|
|
237
|
+
handoffId,
|
|
238
|
+
consumerId: normalizedConsumerId,
|
|
239
|
+
harness: prev.details?.harness ?? null,
|
|
240
|
+
acceptedAt: prev.at,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
throw acceptanceError(
|
|
244
|
+
E_HANDOFF_ALREADY_ACCEPTED,
|
|
245
|
+
`Handoff ${handoffId} has already been accepted by consumer "${prev.details?.consumerId}"`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const eventDetails = {
|
|
250
|
+
handoffId,
|
|
251
|
+
handoffDigest: handoff.artifactDigest,
|
|
252
|
+
consumerId: normalizedConsumerId,
|
|
253
|
+
...(normalizedHarness ? { harness: normalizedHarness } : {}),
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const appended = await appendProtocolEvent(
|
|
257
|
+
target,
|
|
258
|
+
{
|
|
259
|
+
taskId,
|
|
260
|
+
event: "HANDOFF_ACCEPTED",
|
|
261
|
+
details: eventDetails,
|
|
262
|
+
},
|
|
263
|
+
packageRoot,
|
|
264
|
+
{ taskId },
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
accepted: true,
|
|
269
|
+
idempotent: false,
|
|
270
|
+
handoffId,
|
|
271
|
+
consumerId: normalizedConsumerId,
|
|
272
|
+
harness: normalizedHarness ?? null,
|
|
273
|
+
acceptedAt: appended.at,
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
);
|
|
277
|
+
}
|
package/src/core/handoff.js
CHANGED
|
@@ -2,12 +2,13 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { readdir } from "node:fs/promises";
|
|
3
3
|
|
|
4
4
|
import { canonicalFingerprint, readJsonArtifact, writeJsonArtifact } from "./artifacts.js";
|
|
5
|
-
import {
|
|
5
|
+
import { assertPortableContextSafe, normalizePortableText } from "./portable-context.js";
|
|
6
6
|
import { appendProtocolEvent } from "./events.js";
|
|
7
|
-
import { currentChangedPaths } from "./repository.js";
|
|
7
|
+
import { currentChangedPaths, currentRepositoryFingerprint } from "./repository.js";
|
|
8
8
|
import { readContract } from "./contract.js";
|
|
9
9
|
import { readPersistedRoute } from "./route-artifact.js";
|
|
10
10
|
import { readWorkState } from "./work-state.js";
|
|
11
|
+
import { assertStateIdentity } from "./completion-relationships.js";
|
|
11
12
|
import { resolveTaskClaimState } from "./task-claim-state.js";
|
|
12
13
|
import { readContinuity } from "./continuity.js";
|
|
13
14
|
import { getPackageRoot } from "./templates.js";
|
|
@@ -26,12 +27,13 @@ function handoffError(code, message, artifacts = []) {
|
|
|
26
27
|
return error;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
function optionalText(value, label) {
|
|
30
|
+
function optionalText(value, label, maxLength = 2000) {
|
|
30
31
|
if (value === undefined || value === null) return null;
|
|
31
|
-
|
|
32
|
+
try {
|
|
33
|
+
return normalizePortableText(value, { label, maxLength, optional: true });
|
|
34
|
+
} catch (error) {
|
|
32
35
|
throw handoffError("E_HANDOFF_INVALID", `${label} must be a non-empty string when provided`);
|
|
33
36
|
}
|
|
34
|
-
return value;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
function pathList(value) {
|
|
@@ -43,8 +45,22 @@ function handoffWithoutDigest(value) {
|
|
|
43
45
|
return body;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
export function compareRepositoryFingerprint(expected, current) {
|
|
49
|
+
const expectedUnavailable = !expected || (expected.branch === null && expected.head === null);
|
|
50
|
+
const currentUnavailable = !current || (current.branch === null && current.head === null);
|
|
51
|
+
if (expectedUnavailable && currentUnavailable) return "NOT_VERIFIED";
|
|
52
|
+
if (expectedUnavailable !== currentUnavailable) return "MISMATCH";
|
|
53
|
+
return expected.branch === current.branch && expected.head === current.head
|
|
54
|
+
? "MATCH"
|
|
55
|
+
: "MISMATCH";
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
export function validateHandoffDigest(handoff) {
|
|
47
|
-
|
|
59
|
+
try {
|
|
60
|
+
assertPortableContextSafe(handoff);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw handoffError("E_HANDOFF_INVALID", error.message);
|
|
63
|
+
}
|
|
48
64
|
if (typeof handoff?.artifactDigest !== "string"
|
|
49
65
|
|| handoff.artifactDigest !== canonicalFingerprint(handoffWithoutDigest(handoff))) {
|
|
50
66
|
throw handoffError("E_HANDOFF_TAMPERED", "Handoff artifact digest does not match its canonical content");
|
|
@@ -73,17 +89,33 @@ export async function buildCanonicalHandoff(target, {
|
|
|
73
89
|
createdAt = new Date().toISOString(),
|
|
74
90
|
} = {}) {
|
|
75
91
|
if (!taskId) throw handoffError("E_HANDOFF_STATE_UNAVAILABLE", "taskId is required to build a handoff");
|
|
76
|
-
const [state, contract, route, claims, changedPaths, continuity] = await Promise.all([
|
|
92
|
+
const [state, contract, route, claims, changedPaths, continuity, repositoryFingerprint] = await Promise.all([
|
|
77
93
|
readWorkState(target, { packageRoot, taskId }),
|
|
78
94
|
readContract(target, packageRoot, { taskId }),
|
|
79
95
|
readPersistedRoute(target, packageRoot, { taskId }),
|
|
80
96
|
resolveTaskClaimState(target, { packageRoot, taskId }),
|
|
81
97
|
currentChangedPaths(target),
|
|
82
98
|
currentContinuity(target, packageRoot, taskId),
|
|
99
|
+
currentRepositoryFingerprint(target),
|
|
83
100
|
]);
|
|
84
101
|
if (!state || !contract || !route || changedPaths === null || !claims.valid) {
|
|
85
102
|
throw handoffError("E_HANDOFF_STATE_UNAVAILABLE", "Canonical task state, route, claims, and changed paths are required for a handoff");
|
|
86
103
|
}
|
|
104
|
+
try {
|
|
105
|
+
assertStateIdentity({ contract, route, state });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw handoffError(
|
|
108
|
+
"E_HANDOFF_STATE_UNAVAILABLE",
|
|
109
|
+
`Canonical task artifacts are not coherent for handoff creation: ${error.message}`,
|
|
110
|
+
error.artifacts ?? [],
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (compareRepositoryFingerprint(state.repositoryFingerprint, repositoryFingerprint) === "MISMATCH") {
|
|
114
|
+
throw handoffError(
|
|
115
|
+
"E_HANDOFF_STATE_UNAVAILABLE",
|
|
116
|
+
"Canonical repository fingerprint has drifted from current repository state",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
87
119
|
const checkItems = [...(state.checks ?? [])];
|
|
88
120
|
const executionRefs = pathList(checkItems.map((check) => check.executionRef).filter(Boolean));
|
|
89
121
|
const checkIds = pathList(checkItems.map((check) => check.id).filter(Boolean));
|
|
@@ -101,9 +133,10 @@ export async function buildCanonicalHandoff(target, {
|
|
|
101
133
|
phase: WORK_PHASES.includes(state.phase) ? state.phase : "UNKNOWN",
|
|
102
134
|
revision: state.revision ?? 0,
|
|
103
135
|
verificationCycle: state.verificationCycle ?? 1,
|
|
136
|
+
workStateFingerprint: canonicalFingerprint(state),
|
|
104
137
|
contractFingerprint: contract.fingerprint,
|
|
105
138
|
routeFingerprint: route.fingerprint ?? null,
|
|
106
|
-
repositoryFingerprint
|
|
139
|
+
repositoryFingerprint,
|
|
107
140
|
writeClaims: pathList(claims.effectiveWriteClaims ?? []),
|
|
108
141
|
changedPaths: pathList(changedPaths),
|
|
109
142
|
},
|
|
@@ -42,7 +42,7 @@ const LOOP_MUTATION_COMMANDS = Object.freeze(new Set([
|
|
|
42
42
|
"record-terminal-result", "complete",
|
|
43
43
|
"record-intervention", "record-hypothesis-disposition",
|
|
44
44
|
"usage-record",
|
|
45
|
-
"workspace-bind", "handoff-create", "responsibility-set", "verify-scope", "attestation-create",
|
|
45
|
+
"workspace-bind", "handoff-create", "handoff-accept", "responsibility-set", "verify-scope", "attestation-create",
|
|
46
46
|
]));
|
|
47
47
|
|
|
48
48
|
const STATIC_RISK_CLASSES = Object.freeze({
|
|
@@ -189,10 +189,27 @@ export function getForgeLoopCapabilities({ packageVersion = null } = {}) {
|
|
|
189
189
|
explicitRebinding: false,
|
|
190
190
|
},
|
|
191
191
|
canonicalHandoffs: {
|
|
192
|
-
version:
|
|
192
|
+
version: 2,
|
|
193
193
|
supported: true,
|
|
194
194
|
immutable: true,
|
|
195
195
|
lifecycleAuthority: false,
|
|
196
|
+
evidenceAuthority: false,
|
|
197
|
+
exactlyOnceAcceptance: true,
|
|
198
|
+
acceptanceLedgerBacked: true,
|
|
199
|
+
acceptanceCommand: "handoff-accept",
|
|
200
|
+
acceptanceStatuses: ["OPEN", "ACCEPTED", "UNBOUND", "INCONSISTENT"],
|
|
201
|
+
},
|
|
202
|
+
advisoryContextProviders: {
|
|
203
|
+
version: 1,
|
|
204
|
+
supported: true,
|
|
205
|
+
providerNeutral: true,
|
|
206
|
+
integrationApiOnly: true,
|
|
207
|
+
lazy: true,
|
|
208
|
+
optIn: true,
|
|
209
|
+
persistedByForgeLoop: false,
|
|
210
|
+
lifecycleAuthority: false,
|
|
211
|
+
evidenceAuthority: false,
|
|
212
|
+
executable: false,
|
|
196
213
|
},
|
|
197
214
|
responsibilityConstraints: {
|
|
198
215
|
version: 1,
|
|
@@ -14,6 +14,7 @@ import { readJsonArtifact } from "./artifacts.js";
|
|
|
14
14
|
import { taskDirectory } from "./task-paths.js";
|
|
15
15
|
import { resolveWorkspaceBindingStatus } from "./workspace-binding.js";
|
|
16
16
|
import { listCanonicalHandoffs } from "./handoff.js";
|
|
17
|
+
import { readHandoffAcceptanceLedger, resolveHandoffAcceptance } from "./handoff-acceptance.js";
|
|
17
18
|
import { resolveResponsibilityStatus } from "./responsibility.js";
|
|
18
19
|
import { readVerificationScope } from "./verification-scope.js";
|
|
19
20
|
import { resolveAttestationStatus } from "./attestation.js";
|
|
@@ -196,7 +197,26 @@ export async function readForgeLoopIntegrationResource(uri, {
|
|
|
196
197
|
}
|
|
197
198
|
if (uri === "task/handoffs") {
|
|
198
199
|
const handoffs = await listCanonicalHandoffs(projectPath, { packageRoot, taskId });
|
|
199
|
-
|
|
200
|
+
const ledger = await readHandoffAcceptanceLedger(projectPath, packageRoot, { taskId });
|
|
201
|
+
const projectedHandoffs = handoffs.map((handoff) => {
|
|
202
|
+
const resolved = resolveHandoffAcceptance({
|
|
203
|
+
events: ledger.events,
|
|
204
|
+
handoff,
|
|
205
|
+
ledgerValid: ledger.valid,
|
|
206
|
+
ledgerErrors: ledger.errors,
|
|
207
|
+
});
|
|
208
|
+
return {
|
|
209
|
+
...handoff,
|
|
210
|
+
acceptance: {
|
|
211
|
+
status: resolved.status,
|
|
212
|
+
consumerId: resolved.consumerId ?? null,
|
|
213
|
+
harness: resolved.harness ?? null,
|
|
214
|
+
acceptedAt: resolved.acceptedAt ?? null,
|
|
215
|
+
...(resolved.reasonCodes ? { reasonCodes: [...resolved.reasonCodes] } : {}),
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
return { uri, taskId, data: { taskId, count: projectedHandoffs.length, handoffs: projectedHandoffs } };
|
|
200
220
|
}
|
|
201
221
|
if (uri === "task/responsibility") {
|
|
202
222
|
return { uri, taskId, data: await resolveResponsibilityStatus(projectPath, { packageRoot, taskId }) };
|