@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.
Files changed (103) hide show
  1. package/AGENT_COMPATIBILITY.md +15 -0
  2. package/DELEGATION_PROTOCOL.md +6 -0
  3. package/DOCS_INDEX.md +13 -2
  4. package/LOOP_ENGINEERING.md +17 -4
  5. package/LOOP_SYSTEM_DESIGN.md +56 -0
  6. package/ORCHESTRATOR_INTEGRATION.md +9 -0
  7. package/PROTOCOL_INTEGRATION.md +31 -0
  8. package/QUALITY_SCORECARD.md +2 -2
  9. package/README.md +53 -0
  10. package/TERMINOLOGY.md +12 -0
  11. package/THREAT_MODEL.md +24 -0
  12. package/completions/_forgeloop +2 -1
  13. package/completions/forgeloop.bash +3 -1
  14. package/completions/forgeloop.fish +9 -1
  15. package/docs/ADVISORY_CONTEXT.md +174 -0
  16. package/docs/AGENT_PROTOCOL_SUMMARY.md +28 -2
  17. package/docs/ARTIFACT_REFERENCE.md +29 -0
  18. package/docs/CLI_REFERENCE.md +40 -0
  19. package/docs/CODE_ATTESTATION.md +9 -0
  20. package/docs/CROSS_HARNESS_CONTINUITY.md +85 -0
  21. package/docs/DOCUMENTATION_GUIDE.md +11 -4
  22. package/docs/EXECUTION_PROFILE_BENCHMARKS.md +10 -0
  23. package/docs/GETTING_STARTED.md +22 -0
  24. package/docs/KNOWLEDGE_SOURCES.md +10 -0
  25. package/docs/MCP.md +29 -1
  26. package/docs/PACKAGE_CONTENTS.md +83 -0
  27. package/docs/RECIPES.md +80 -0
  28. package/docs/RELEASE_CHECKLIST.md +22 -0
  29. package/docs/REVISION_PROVIDERS.md +9 -0
  30. package/docs/TROUBLESHOOTING.md +66 -2
  31. package/docs/UNIVERSAL_INTEGRATION.md +60 -0
  32. package/docs/assets/diagrams/forgeloop-code-attestation-flow.html +13 -2
  33. package/docs/assets/diagrams/forgeloop-code-attestation-flow.receipt.json +6 -6
  34. package/docs/assets/diagrams/forgeloop-code-attestation-flow.svg +10 -1
  35. package/docs/assets/diagrams/forgeloop-engineering-flow.html +22 -11
  36. package/docs/assets/diagrams/forgeloop-engineering-flow.receipt.json +6 -6
  37. package/docs/assets/diagrams/forgeloop-engineering-flow.svg +17 -8
  38. package/docs/assets/diagrams/forgeloop-verification-trust-flow.html +14 -3
  39. package/docs/assets/diagrams/forgeloop-verification-trust-flow.receipt.json +6 -6
  40. package/docs/assets/diagrams/forgeloop-verification-trust-flow.svg +10 -1
  41. package/docs/diagrams/README.md +17 -0
  42. package/docs/diagrams/forgeloop-code-attestation-flow.workflow.json +383 -57
  43. package/docs/diagrams/forgeloop-engineering-flow.workflow.json +374 -55
  44. package/docs/diagrams/forgeloop-verification-trust-flow.workflow.json +328 -47
  45. package/docs/diagrams/reviews/forgeloop-code-attestation-flow.review.json +4 -4
  46. package/docs/diagrams/reviews/forgeloop-engineering-flow.review.json +4 -4
  47. package/docs/diagrams/reviews/forgeloop-verification-trust-flow.review.json +4 -4
  48. package/package.json +14 -4
  49. package/schemas/handoff-envelope.schema.json +1 -0
  50. package/scripts/CI_VALIDATORS.md +24 -0
  51. package/scripts/check-changelog-freshness.mjs +27 -3
  52. package/scripts/check-critical-coverage.mjs +9 -0
  53. package/scripts/generate-agent-protocol-summary.mjs +18 -0
  54. package/src/cli.js +6 -0
  55. package/src/commands/doctor.js +11 -10
  56. package/src/commands/handoff-accept.js +36 -0
  57. package/src/commands/handoff-list.js +28 -2
  58. package/src/commands/handoff-show.js +27 -2
  59. package/src/commands/reconcile-continuity.js +4 -0
  60. package/src/core/actions.js +2 -2
  61. package/src/core/advisory-context/constants.js +74 -0
  62. package/src/core/advisory-context/provider.js +287 -0
  63. package/src/core/advisory-context/service.js +140 -0
  64. package/src/core/approvals.js +2 -2
  65. package/src/core/artifacts.js +3 -3
  66. package/src/core/checks.js +0 -33
  67. package/src/core/cli-command-definitions.js +17 -0
  68. package/src/core/command-executors.js +12 -0
  69. package/src/core/command-input.js +11 -1
  70. package/src/core/completion.js +2 -2
  71. package/src/core/continuity-lint.js +89 -0
  72. package/src/core/continuity-reconciliation.js +16 -0
  73. package/src/core/continuity.js +10 -11
  74. package/src/core/error-codes.js +113 -0
  75. package/src/core/events.js +37 -5
  76. package/src/core/execution-profile-context.js +15 -1
  77. package/src/core/execution-profile.js +18 -5
  78. package/src/core/filesystem.js +18 -2
  79. package/src/core/handoff-acceptance.js +277 -0
  80. package/src/core/handoff.js +41 -8
  81. package/src/core/integration-invocation-policy.js +19 -2
  82. package/src/core/integration-resources.js +21 -1
  83. package/src/core/next-action-pending-actions.js +255 -0
  84. package/src/core/next-action-phases.js +26 -764
  85. package/src/core/next-action-planned-phase.js +51 -0
  86. package/src/core/next-action-quality-guidance.js +19 -0
  87. package/src/core/next-action-recovery-phases.js +97 -0
  88. package/src/core/next-action-refresh.js +20 -0
  89. package/src/core/next-action-review-phase.js +189 -0
  90. package/src/core/next-action-verification-phase.js +192 -0
  91. package/src/core/portable-context.js +103 -0
  92. package/src/core/protocol-info.js +18 -2
  93. package/src/core/runtime-context.js +31 -0
  94. package/src/core/task-recovery.js +2 -2
  95. package/src/core/transaction-maintenance.js +70 -0
  96. package/src/core/transaction.js +31 -10
  97. package/src/core/work-state.js +5 -5
  98. package/src/integration.d.ts +135 -3
  99. package/src/integration.js +23 -0
  100. package/src/core/cli-metadata.js +0 -23
  101. package/src/core/decision-classification.js +0 -55
  102. package/src/core/gates.js +0 -57
  103. package/src/core/workflow-compatibility.js +0 -151
@@ -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 { getActiveTaskTransaction, withTaskTransaction } from "./transaction.js";
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 = getActiveTaskTransaction();
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 = getActiveTaskTransaction();
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 = getActiveTaskTransaction();
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 = getActiveTaskTransaction();
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: [...OPTIONAL_CONTEXT_BY_PROFILE[resolvedProfile]],
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
- ...collections.successCriteria,
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
  }
@@ -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
- const normalized = pathApi.normalize(value);
80
- return platform === "win32" ? normalized.toLowerCase() : normalized;
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
+ }
@@ -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 { assertSecretFree } from "./receipt.js";
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
- if (typeof value !== "string" || value.trim() === "") {
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
- assertSecretFree(handoff);
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: state.repositoryFingerprint ?? { branch: null, head: null },
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: 1,
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
- return { uri, taskId, data: { taskId, count: handoffs.length, handoffs } };
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 }) };