@cassiomc1/forgeloop 1.3.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/.github/copilot-instructions.md +1 -0
  2. package/AGENTS.md +1 -0
  3. package/CLAUDE.md +1 -0
  4. package/DOCS_INDEX.md +20 -8
  5. package/EXECUTION_STATE.md +60 -0
  6. package/LOOP_ENGINEERING.md +135 -5
  7. package/LOOP_SYSTEM_DESIGN.md +54 -1
  8. package/PROTOCOL_INTEGRATION.md +87 -0
  9. package/QUALITY_SCORECARD.md +2 -0
  10. package/README.md +69 -9
  11. package/TERMINOLOGY.md +15 -0
  12. package/THIRD_PARTY_NOTICES.md +30 -0
  13. package/THREAT_MODEL.md +59 -1
  14. package/docs/ARTIFACT_REFERENCE.md +183 -0
  15. package/docs/CLI_REFERENCE.md +391 -6
  16. package/docs/CROSS_HARNESS_CONTINUITY.md +23 -0
  17. package/docs/DIAGNOSTIC_MODEL.md +181 -0
  18. package/docs/DOCUMENTATION_GUIDE.md +36 -13
  19. package/docs/EXECUTION_TRACE.md +76 -0
  20. package/docs/GETTING_STARTED.md +1 -0
  21. package/docs/MCP.md +159 -0
  22. package/docs/RECIPES.md +149 -0
  23. package/docs/RELEASE_CHECKLIST_1_4.md +38 -0
  24. package/docs/RELEASE_CHECKLIST_1_5_MCP.md +78 -0
  25. package/docs/TROUBLESHOOTING.md +217 -3
  26. package/docs/UNIVERSAL_INTEGRATION.md +48 -0
  27. package/docs/assets/diagrams/forgeloop-engineering-flow.html +13797 -0
  28. package/docs/assets/diagrams/forgeloop-engineering-flow.receipt.json +37 -0
  29. package/docs/assets/diagrams/forgeloop-engineering-flow.svg +5002 -0
  30. package/docs/diagrams/README.md +55 -0
  31. package/docs/diagrams/forgeloop-engineering-flow.workflow.json +122 -0
  32. package/docs/diagrams/manifest.json +42 -0
  33. package/docs/diagrams/reviews/forgeloop-engineering-flow.review.json +20 -0
  34. package/package.json +21 -8
  35. package/schemas/action.schema.json +100 -0
  36. package/schemas/approval.schema.json +51 -0
  37. package/schemas/capability-policy.schema.json +41 -0
  38. package/schemas/diagnostic-case.schema.json +85 -0
  39. package/schemas/execution-receipt.schema.json +16 -0
  40. package/schemas/hypothesis-disposition.schema.json +16 -0
  41. package/schemas/intervention.schema.json +27 -0
  42. package/schemas/policy-lock.schema.json +1 -0
  43. package/schemas/policy-snapshot.schema.json +2 -0
  44. package/schemas/task-recovery.schema.json +61 -0
  45. package/schemas/trajectory-evaluation.schema.json +64 -0
  46. package/schemas/trajectory-scenario.schema.json +42 -0
  47. package/src/cli.js +267 -347
  48. package/src/commands/action-authorize.js +41 -0
  49. package/src/commands/action-propose.js +10 -0
  50. package/src/commands/action-reconcile.js +10 -0
  51. package/src/commands/action-record.js +47 -0
  52. package/src/commands/action-show.js +10 -0
  53. package/src/commands/action-verify.js +10 -0
  54. package/src/commands/advance.js +7 -2
  55. package/src/commands/approval-request.js +64 -0
  56. package/src/commands/approval-resolve.js +10 -0
  57. package/src/commands/audit.js +5 -0
  58. package/src/commands/baseline.js +3 -3
  59. package/src/commands/eval.js +6 -0
  60. package/src/commands/history.js +18 -0
  61. package/src/commands/init.js +2 -2
  62. package/src/commands/inspect.js +55 -0
  63. package/src/commands/metrics.js +7 -0
  64. package/src/commands/next.js +8 -2
  65. package/src/commands/policy-discover.js +2 -2
  66. package/src/commands/progress.js +6 -2
  67. package/src/commands/record-diagnosis.js +37 -1
  68. package/src/commands/record-hypothesis-disposition.js +45 -0
  69. package/src/commands/record-intervention.js +35 -0
  70. package/src/commands/reflect.js +38 -0
  71. package/src/commands/report.js +9 -1
  72. package/src/commands/run-action.js +18 -0
  73. package/src/commands/status.js +17 -0
  74. package/src/commands/task-create.js +39 -1
  75. package/src/commands/task-list.js +14 -1
  76. package/src/commands/task-lock-status.js +2 -2
  77. package/src/commands/task-recover.js +202 -0
  78. package/src/commands/task-repair-legacy-recovery.js +417 -0
  79. package/src/commands/task-resume.js +172 -0
  80. package/src/commands/task-scope.js +23 -4
  81. package/src/commands/task-show.js +18 -4
  82. package/src/commands/trace.js +34 -0
  83. package/src/commands/validate-protocol.js +40 -15
  84. package/src/core/action-authorization.js +106 -0
  85. package/src/core/action-constants.js +86 -0
  86. package/src/core/action-execution.js +105 -0
  87. package/src/core/action-ledger-projection.js +302 -0
  88. package/src/core/action-model.js +581 -0
  89. package/src/core/action-readiness.js +141 -0
  90. package/src/core/action-reconciliation-policy.js +49 -0
  91. package/src/core/action-reconciliation.js +66 -0
  92. package/src/core/action-verification.js +111 -0
  93. package/src/core/actions.js +462 -0
  94. package/src/core/approvals.js +405 -0
  95. package/src/core/artifact-registry.js +60 -0
  96. package/src/core/audit.js +45 -4
  97. package/src/core/bundles.js +30 -0
  98. package/src/core/capability-policy.js +226 -0
  99. package/src/core/cli-command-definitions.js +260 -5
  100. package/src/core/command-executors.js +543 -0
  101. package/src/core/command-input.js +107 -0
  102. package/src/core/command-runtime.js +117 -0
  103. package/src/core/completion-artifacts.js +39 -15
  104. package/src/core/completion-ownership.js +88 -0
  105. package/src/core/completion-recovery-rebind.js +194 -0
  106. package/src/core/completion.js +70 -0
  107. package/src/core/continuity-reconciliation.js +24 -5
  108. package/src/core/diagnostic-model.js +396 -0
  109. package/src/core/diagnostic-projection.js +51 -0
  110. package/src/core/diagnostic-record.js +360 -0
  111. package/src/core/error-codes.js +461 -1
  112. package/src/core/events.js +171 -2
  113. package/src/core/execution-prerequisites.js +4 -1
  114. package/src/core/execution.js +26 -188
  115. package/src/core/failure-signature.js +70 -0
  116. package/src/core/failure-surface.js +57 -0
  117. package/src/core/filesystem.js +55 -6
  118. package/src/core/history.js +110 -0
  119. package/src/core/hypothesis-projection.js +85 -0
  120. package/src/core/information-gain-projection.js +283 -0
  121. package/src/core/information-gain.js +138 -0
  122. package/src/core/inspect.js +132 -7
  123. package/src/core/integration-invocation-policy.js +217 -0
  124. package/src/core/integration-limits.js +20 -0
  125. package/src/core/integration-resources.js +178 -0
  126. package/src/core/next-action-model.js +94 -0
  127. package/src/core/next-action.js +490 -3
  128. package/src/core/phase.js +42 -22
  129. package/src/core/policy-engine.js +113 -6
  130. package/src/core/preflight-consistency.js +31 -5
  131. package/src/core/preflight.js +19 -2
  132. package/src/core/prepared-execution.js +227 -0
  133. package/src/core/progress.js +41 -4
  134. package/src/core/project-root.js +21 -0
  135. package/src/core/protocol-info.js +61 -0
  136. package/src/core/protocol.js +14 -0
  137. package/src/core/receipt.js +1 -0
  138. package/src/core/reconcile-closure.js +35 -10
  139. package/src/core/recovery-history.js +116 -0
  140. package/src/core/reflection.js +305 -0
  141. package/src/core/resumability.js +57 -3
  142. package/src/core/schema-validation.js +9 -0
  143. package/src/core/strategy-analysis.js +97 -0
  144. package/src/core/task-claim-state.js +272 -0
  145. package/src/core/task-command.js +5 -1
  146. package/src/core/task-conflict-inspection.js +321 -0
  147. package/src/core/task-context.js +32 -29
  148. package/src/core/task-discovery.js +14 -1
  149. package/src/core/task-lock.js +216 -22
  150. package/src/core/task-paths.js +31 -2
  151. package/src/core/task-recovery-migration.js +192 -0
  152. package/src/core/task-recovery.js +205 -0
  153. package/src/core/task-scope.js +33 -1
  154. package/src/core/task-snapshot.js +53 -0
  155. package/src/core/templates.js +9 -0
  156. package/src/core/trace.js +548 -0
  157. package/src/core/trajectory-evaluation.js +71 -0
  158. package/src/core/trajectory-metrics.js +80 -0
  159. package/src/core/transaction.js +36 -2
  160. package/src/core/work-state.js +10 -5
  161. package/src/integration.js +47 -0
  162. package/docs/assets/forgeloop-flow.svg +0 -1
  163. package/docs/forgeloop-flow.mmd +0 -51
@@ -136,13 +136,15 @@ export async function detectPolicyCapability(target, packageRoot) {
136
136
  const baselineRel = PROJECT_ARTIFACT_PATHS.policyBaseline;
137
137
  const discoveryRel = PROJECT_ARTIFACT_PATHS.policyDiscovery;
138
138
  const lockRel = PROJECT_ARTIFACT_PATHS.policyLock;
139
+ const capabilityRel = PROJECT_ARTIFACT_PATHS.capabilityPolicy;
139
140
 
140
141
  const hasRules = await fileExists(path.join(target, rulesRel));
141
142
  const hasBaseline = await fileExists(path.join(target, baselineRel));
142
143
  const hasDiscovery = await fileExists(path.join(target, discoveryRel));
143
144
  const hasLock = await fileExists(path.join(target, lockRel));
145
+ const hasCapabilityPolicy = await fileExists(path.join(target, capabilityRel));
144
146
 
145
- if (!hasRules && !hasBaseline && !hasDiscovery && !hasLock) {
147
+ if (!hasRules && !hasBaseline && !hasDiscovery && !hasLock && !hasCapabilityPolicy) {
146
148
  return "NOT_PRESENT";
147
149
  }
148
150
 
@@ -151,6 +153,13 @@ export async function detectPolicyCapability(target, packageRoot) {
151
153
  if (hasBaseline) await readBaseline(target, packageRoot);
152
154
  if (hasDiscovery) await readDiscoveryReport(target, packageRoot);
153
155
  if (hasLock) await readPolicyLock(target, packageRoot);
156
+ // A capability policy is policy configuration: its presence makes the
157
+ // executable-policy subsystem applicable and it must fail closed when
158
+ // malformed.
159
+ if (hasCapabilityPolicy) {
160
+ const { loadCapabilityPolicy } = await import("./capability-policy.js");
161
+ await loadCapabilityPolicy(target, packageRoot);
162
+ }
154
163
  return "AVAILABLE";
155
164
  } catch {
156
165
  return "INVALID";
@@ -190,12 +199,22 @@ export function canonicalizeBaseline(baseline) {
190
199
  };
191
200
  }
192
201
 
193
- export function computePolicyLockData(rules, baseline) {
202
+ export function computePolicyLockData(rules, baseline, capabilityPolicy = null) {
194
203
  const canonicalRules = canonicalizeRules(rules);
195
204
  const canonicalBase = canonicalizeBaseline(baseline);
196
205
  const rulesDigest = sha256(canonicalFingerprint(canonicalRules));
197
206
  const baselineDigest = sha256(canonicalFingerprint(canonicalBase));
198
- const fullDigest = sha256(`${rulesDigest}:${baselineDigest}`);
207
+ // The capability policy participates in the lock only when the artifact
208
+ // exists, keeping historical locks stable for projects that never adopt
209
+ // durable actions.
210
+ const capabilityPolicyDigest =
211
+ capabilityPolicy && typeof capabilityPolicy === "object"
212
+ ? sha256(canonicalFingerprint(capabilityPolicy))
213
+ : null;
214
+ const fullDigest =
215
+ capabilityPolicyDigest === null
216
+ ? sha256(`${rulesDigest}:${baselineDigest}`)
217
+ : sha256(`${rulesDigest}:${baselineDigest}:${capabilityPolicyDigest}`);
199
218
 
200
219
  return {
201
220
  schemaVersion: 1,
@@ -203,10 +222,78 @@ export function computePolicyLockData(rules, baseline) {
203
222
  digest: `sha256:${fullDigest}`,
204
223
  rulesDigest: `sha256:${rulesDigest}`,
205
224
  baselineDigest: `sha256:${baselineDigest}`,
225
+ ...(capabilityPolicyDigest === null
226
+ ? {}
227
+ : { capabilityPolicyDigest: `sha256:${capabilityPolicyDigest}` }),
206
228
  capturedAt: new Date().toISOString(),
207
229
  };
208
230
  }
209
231
 
232
+ export async function readCapabilityPolicyIdentityForLock(target, packageRoot) {
233
+ const { readCapabilityPolicyIdentity } = await import("./capability-policy.js");
234
+ return readCapabilityPolicyIdentity(target, packageRoot);
235
+ }
236
+
237
+ /**
238
+ * Canonical policy identity for durable-action authorization. Fails closed
239
+ * with deterministic codes whenever the capability policy, the persisted
240
+ * lock, or the task snapshot do not agree.
241
+ */
242
+ export async function loadPolicyIdentity(target, packageRoot, taskId) {
243
+ const { E_ACTION_POLICY_DRIFT, E_ACTION_POLICY_LOCK_REQUIRED } = await import("./error-codes.js");
244
+ const capability = await readCapabilityPolicyIdentityForLock(target, packageRoot);
245
+ const lock = await verifyPolicyLock(target, packageRoot);
246
+ const snapshot = taskId ? await readTaskPolicySnapshot(target, taskId, packageRoot) : null;
247
+
248
+ if (capability.policy && lock.status !== "VALID") {
249
+ return {
250
+ status: lock.status === "MISMATCH" ? "DRIFT" : "INVALID",
251
+ code: lock.status === "MISMATCH" ? E_ACTION_POLICY_DRIFT : E_ACTION_POLICY_LOCK_REQUIRED,
252
+ ...(lock.status === "MISMATCH" ? { mismatches: lock.mismatches } : {}),
253
+ };
254
+ }
255
+
256
+ // A modern task snapshot must bind the current capability policy before any
257
+ // side-effecting action may be authorized.
258
+ if (
259
+ capability.digest
260
+ && snapshot
261
+ && snapshot.capabilityPolicyDigest !== capability.digest
262
+ ) {
263
+ return {
264
+ status: "DRIFT",
265
+ code: E_ACTION_POLICY_DRIFT,
266
+ };
267
+ }
268
+
269
+ // A modern capability policy without a task snapshot has no epoch to bind:
270
+ // authorization cannot proceed for a task-scoped action.
271
+ if (capability.digest && taskId && !snapshot) {
272
+ return {
273
+ status: "INVALID",
274
+ code: E_ACTION_POLICY_LOCK_REQUIRED,
275
+ };
276
+ }
277
+
278
+ return {
279
+ status: "VALID",
280
+ lockDigest: lock.digest ?? null,
281
+ taskPolicyDigest: snapshot?.policyDigest ?? null,
282
+ capabilityPolicyFingerprint: capability.fingerprint,
283
+ capabilityPolicyDigest: capability.digest,
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Compute policy-lock data for persistence, binding the current capability
289
+ * policy whenever it exists so locks, snapshots, and authorization evidence
290
+ * share one policy identity.
291
+ */
292
+ export async function computePersistedPolicyLockData(target, packageRoot, rules, baseline) {
293
+ const identity = await readCapabilityPolicyIdentityForLock(target, packageRoot);
294
+ return computePolicyLockData(rules, baseline, identity.policy);
295
+ }
296
+
210
297
  export async function verifyPolicyLock(target, packageRoot) {
211
298
  const capability = await detectPolicyCapability(target, packageRoot);
212
299
  if (capability === "NOT_PRESENT") {
@@ -223,7 +310,18 @@ export async function verifyPolicyLock(target, packageRoot) {
223
310
 
224
311
  const rules = await loadEffectiveRules(target, packageRoot);
225
312
  const baseline = await readBaseline(target, packageRoot);
226
- const expectedLock = computePolicyLockData(rules, baseline);
313
+ let capabilityPolicy = null;
314
+ try {
315
+ const { loadCapabilityPolicy } = await import("./capability-policy.js");
316
+ const loadedCapabilityPolicy = await loadCapabilityPolicy(target, packageRoot);
317
+ capabilityPolicy = loadedCapabilityPolicy?.policy ?? null;
318
+ } catch (error) {
319
+ if (error.code === "E_POLICY_INVALID") {
320
+ return { status: "INVALID", error: "Capability policy artifact is malformed" };
321
+ }
322
+ throw error;
323
+ }
324
+ const expectedLock = computePolicyLockData(rules, baseline, capabilityPolicy);
227
325
 
228
326
  const mismatches = [];
229
327
  if (persistedLock.algorithm !== expectedLock.algorithm) {
@@ -238,6 +336,12 @@ export async function verifyPolicyLock(target, packageRoot) {
238
336
  if (persistedLock.baselineDigest !== expectedLock.baselineDigest) {
239
337
  mismatches.push("baselineDigest");
240
338
  }
339
+ if (
340
+ expectedLock.capabilityPolicyDigest !== undefined &&
341
+ persistedLock.capabilityPolicyDigest !== expectedLock.capabilityPolicyDigest
342
+ ) {
343
+ mismatches.push("capabilityPolicyDigest");
344
+ }
241
345
 
242
346
  if (mismatches.length > 0) {
243
347
  return {
@@ -254,6 +358,9 @@ export async function verifyPolicyLock(target, packageRoot) {
254
358
  digest: persistedLock.digest ?? null,
255
359
  rulesDigest: persistedLock.rulesDigest ?? null,
256
360
  baselineDigest: persistedLock.baselineDigest ?? null,
361
+ ...(expectedLock.capabilityPolicyDigest === undefined
362
+ ? {}
363
+ : { capabilityPolicyDigest: persistedLock.capabilityPolicyDigest ?? null }),
257
364
  },
258
365
  };
259
366
  }
@@ -476,7 +583,7 @@ export async function evaluateTargetPolicy({
476
583
  if (taskId) {
477
584
  const taskSnapshot = await readTaskPolicySnapshot(target, taskId, packageRoot);
478
585
  if (taskSnapshot) {
479
- const currentLock = computePolicyLockData(rules, baseline);
586
+ const currentLock = await computePersistedPolicyLockData(target, packageRoot, rules, baseline);
480
587
  if (taskSnapshot.policyDigest !== currentLock.digest) {
481
588
  const policyDiff = diffPolicies(
482
589
  { rules: taskSnapshot.rules, baseline: taskSnapshot.baseline, baselineDigest: taskSnapshot.baselineDigest },
@@ -513,7 +620,7 @@ export async function evaluateTargetPolicy({
513
620
  }
514
621
  }
515
622
 
516
- const currentLock = computePolicyLockData(rules, baseline);
623
+ const currentLock = await computePersistedPolicyLockData(target, packageRoot, rules, baseline);
517
624
 
518
625
  return {
519
626
  status: errors.length === 0 ? "VALID" : "INVALID",
@@ -165,10 +165,34 @@ function sameBlockedPreflightEvent(event, result) {
165
165
  && event.details?.routingFingerprint === result.fingerprints.routing;
166
166
  }
167
167
 
168
+ /**
169
+ * Returns the latest recorded preflight outcome event (READY or BLOCKED) for
170
+ * the task. An append-only lifecycle may legitimately contain an older READY
171
+ * event that was superseded by a later BLOCKED outcome (for example after the
172
+ * contract evolved or a gate requirement was added); the binding chronology is
173
+ * the latest outcome, not the first READY ever recorded.
174
+ */
175
+ function latestPreflightOutcomeEvent(events, taskId) {
176
+ let latest = null;
177
+ for (const event of events) {
178
+ if ((event.event === "PREFLIGHT_READY" || event.event === "PREFLIGHT_BLOCKED")
179
+ && event.taskId === taskId) {
180
+ latest = event;
181
+ }
182
+ }
183
+ return latest;
184
+ }
185
+
168
186
  export function assertExistingReadyLifecycleCompatibility(ledger, result) {
169
187
  if (result.status !== "READY") return;
170
- const existingReady = ledger?.events?.find((event) => event.event === "PREFLIGHT_READY" && event.taskId === result.taskId);
171
- if (existingReady && !sameReadyPreflightEvent(existingReady, result)) {
188
+ const events = ledger?.events ?? [];
189
+ const existingReady = events.findLast((event) => event.event === "PREFLIGHT_READY" && event.taskId === result.taskId);
190
+ if (!existingReady) return;
191
+ const latestOutcome = latestPreflightOutcomeEvent(events, result.taskId);
192
+ // A READY outcome superseded by a later BLOCKED outcome may be replaced by a
193
+ // fresh READY with different details once the blocked preflight is resolved.
194
+ if (latestOutcome?.event === "PREFLIGHT_BLOCKED") return;
195
+ if (!sameReadyPreflightEvent(existingReady, result)) {
172
196
  throw preflightError(
173
197
  "E_PHASE_CHRONOLOGY_INVALID",
174
198
  "PREFLIGHT_READY already exists with different READY preflight details; repair the contract, route, or gate lifecycle before refreshing preflight",
@@ -200,16 +224,18 @@ export async function appendActivationEvents(target, packageRoot, ledger, result
200
224
  }
201
225
  }
202
226
 
203
- const existingReady = events.find((event) => event.event === "PREFLIGHT_READY" && event.taskId === result.taskId);
227
+ const existingReady = events.findLast((event) => event.event === "PREFLIGHT_READY" && event.taskId === result.taskId);
228
+ const latestOutcome = latestPreflightOutcomeEvent(events, result.taskId);
229
+ const readySupersededByBlocked = existingReady && latestOutcome?.event === "PREFLIGHT_BLOCKED";
204
230
  if (result.status === "READY") {
205
- if (existingReady && !sameReadyPreflightEvent(existingReady, result)) {
231
+ if (existingReady && !readySupersededByBlocked && !sameReadyPreflightEvent(existingReady, result)) {
206
232
  throw preflightError(
207
233
  "E_PHASE_CHRONOLOGY_INVALID",
208
234
  "PREFLIGHT_READY already exists with different READY preflight details; repair the contract, route, or gate lifecycle before refreshing preflight",
209
235
  [ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.events, ARTIFACT_PATHS.contract, ARTIFACT_PATHS.route, ARTIFACT_PATHS.gates],
210
236
  );
211
237
  }
212
- if (!existingReady) {
238
+ if (!existingReady || readySupersededByBlocked) {
213
239
  await append({
214
240
  taskId: result.taskId,
215
241
  event: "PREFLIGHT_READY",
@@ -200,7 +200,7 @@ export async function runPreflight({
200
200
  detectPolicyCapability,
201
201
  loadEffectiveRules,
202
202
  readBaseline,
203
- computePolicyLockData,
203
+ computePersistedPolicyLockData,
204
204
  readTaskPolicySnapshot,
205
205
  writeTaskPolicySnapshot,
206
206
  } = await import("./policy-engine.js");
@@ -225,13 +225,30 @@ export async function runPreflight({
225
225
  if (!existingSnapshot) {
226
226
  const rules = await loadEffectiveRules(target, packageRoot);
227
227
  const baseline = await readBaseline(target, packageRoot);
228
- const lock = computePolicyLockData(rules, baseline);
228
+ const lock = await computePersistedPolicyLockData(target, packageRoot, rules, baseline);
229
+ // Bind the current capability policy into the snapshot so policy
230
+ // drift is detectable before any side-effecting action launch.
231
+ const { readCapabilityPolicyIdentity } = await import("./capability-policy.js");
232
+ const capabilityIdentity = await readCapabilityPolicyIdentity(target, packageRoot);
233
+ if (capabilityIdentity.policy && !lock.capabilityPolicyDigest) {
234
+ throw preflightError(
235
+ "E_POLICY_SNAPSHOT_WRITE_FAILED",
236
+ "Capability policy identity could not be bound to the task policy snapshot",
237
+ [taskArtifactPath(result.taskId, "policySnapshot")],
238
+ );
239
+ }
229
240
  const snapshot = {
230
241
  schemaVersion: 1,
231
242
  policyDigest: lock.digest,
232
243
  rules,
233
244
  baseline: baseline ?? { schemaVersion: 1, entries: [] },
234
245
  baselineDigest: lock.baselineDigest,
246
+ ...(capabilityIdentity.digest
247
+ ? {
248
+ capabilityPolicyDigest: capabilityIdentity.digest,
249
+ capabilityPolicyFingerprint: capabilityIdentity.fingerprint,
250
+ }
251
+ : {}),
235
252
  capturedAt: new Date().toISOString(),
236
253
  };
237
254
  await writeTaskPolicySnapshot(target, result.taskId, snapshot, packageRoot);
@@ -0,0 +1,227 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ resolveExecutionResolution,
4
+ validateVerificationAuthority,
5
+ E_COMMAND_RESOLUTION_AMBIGUOUS,
6
+ } from "./verification-capability.js";
7
+
8
+ export { E_COMMAND_RESOLUTION_AMBIGUOUS };
9
+
10
+ function executionError(code, message, artifacts = []) {
11
+ const error = new Error(message);
12
+ error.code = code;
13
+ error.artifacts = artifacts;
14
+ return error;
15
+ }
16
+
17
+ /**
18
+ * Deterministic pre-launch preparation. Normalizes exact argv, resolves the
19
+ * command, rejects ambiguous resolution contexts, and validates installation
20
+ * authority. Performs no process launch and writes no execution artifact
21
+ * (INV-EXEC-01): ACTION_STARTED is only meaningful after all of these checks
22
+ * succeed.
23
+ */
24
+ export async function prepareCommandExecution({
25
+ target,
26
+ taskId,
27
+ argv,
28
+ details,
29
+ authorityContext,
30
+ runtimeContext,
31
+ }) {
32
+ if (!Array.isArray(argv) || argv.length === 0 || argv.some((item) => typeof item !== "string" || item.trim() === "")) {
33
+ throw executionError("E_EXECUTION_INVALID", "Execution argv must contain at least one non-empty string");
34
+ }
35
+ const commandArgv = [...argv];
36
+ const resolution = await resolveExecutionResolution({
37
+ argv: commandArgv,
38
+ cwd: target,
39
+ });
40
+
41
+ if (
42
+ resolution.resolutionMode === "UNKNOWN"
43
+ && resolution.mayInstall === true
44
+ && (
45
+ resolution.reason === "NPM_WORKSPACE_SCRIPT_UNRESOLVED"
46
+ || resolution.reason === "NPM_SUBCOMMAND_AMBIGUOUS"
47
+ || resolution.reason === "NPM_COMMAND_UNCLASSIFIED"
48
+ || resolution.reason === "NPM_OPTION_VALUE_AMBIGUOUS"
49
+ )
50
+ ) {
51
+ const error = new Error(
52
+ resolution.reason === "NPM_WORKSPACE_SCRIPT_UNRESOLVED"
53
+ ? "npm workspace script execution cannot be proven from the current target. Run ForgeLoop against the selected workspace directory."
54
+ : "Command execution context could not be proven safe before launch."
55
+ );
56
+ error.code = E_COMMAND_RESOLUTION_AMBIGUOUS;
57
+ error.resolution = resolution;
58
+ throw error;
59
+ }
60
+
61
+ if (resolution.mayInstall) {
62
+ const check = {
63
+ kind: "command",
64
+ source: commandArgv[0],
65
+ details: {
66
+ ...(details ?? {}),
67
+ execution: { resolution },
68
+ },
69
+ };
70
+ const authority = validateVerificationAuthority(check, {
71
+ target,
72
+ taskId,
73
+ authorityContext,
74
+ runtimeContext,
75
+ });
76
+ if (!authority.valid) {
77
+ throw executionError(authority.error.code ?? "E_INSTALLATION_AUTHORITY_REQUIRED", authority.error.message);
78
+ }
79
+ }
80
+
81
+ return { argv: commandArgv, resolution };
82
+ }
83
+
84
+ const MAX_CAPTURED_OUTPUT_BYTES = 64 * 1024;
85
+
86
+ async function executePreparedProcess(argv, cwd, { timeoutMs = null } = {}) {
87
+ const { spawn } = await import("node:child_process");
88
+ return new Promise((resolve) => {
89
+ let spawnError = null;
90
+ let timedOut = false;
91
+ let settled = false;
92
+ let timeout = null;
93
+ let forceTermination = null;
94
+ const stdout = [];
95
+ const stderr = [];
96
+ let stdoutBytes = 0;
97
+ let stderrBytes = 0;
98
+ let outputTruncated = false;
99
+ const capture = (chunks, chunk, total) => {
100
+ const available = MAX_CAPTURED_OUTPUT_BYTES - total;
101
+ if (available <= 0) {
102
+ outputTruncated = true;
103
+ return total;
104
+ }
105
+ if (chunk.length > available) {
106
+ chunks.push(chunk.subarray(0, available));
107
+ outputTruncated = true;
108
+ return total + available;
109
+ }
110
+ chunks.push(chunk);
111
+ return total + chunk.length;
112
+ };
113
+ const finish = (result) => {
114
+ if (settled) return;
115
+ settled = true;
116
+ if (timeout) clearTimeout(timeout);
117
+ if (forceTermination) clearTimeout(forceTermination);
118
+ resolve({
119
+ ...result,
120
+ timedOut,
121
+ stdout: Buffer.concat(stdout),
122
+ stderr: Buffer.concat(stderr),
123
+ stdoutBytes,
124
+ stderrBytes,
125
+ outputTruncated,
126
+ });
127
+ };
128
+ try {
129
+ const child = spawn(argv[0], argv.slice(1), {
130
+ cwd,
131
+ shell: false,
132
+ stdio: ["ignore", "pipe", "pipe"],
133
+ });
134
+ child.stdout?.on("data", (chunk) => { stdoutBytes = capture(stdout, chunk, stdoutBytes); });
135
+ child.stderr?.on("data", (chunk) => { stderrBytes = capture(stderr, chunk, stderrBytes); });
136
+ child.once("error", (error) => {
137
+ spawnError = error;
138
+ });
139
+ child.once("close", (exitCode, signal) => {
140
+ finish({ exitCode, signal, spawnError });
141
+ });
142
+ if (Number.isInteger(timeoutMs) && timeoutMs > 0) {
143
+ timeout = setTimeout(() => {
144
+ timedOut = true;
145
+ child.kill("SIGTERM");
146
+ forceTermination = setTimeout(() => {
147
+ child.kill("SIGKILL");
148
+ }, TERMINATION_GRACE_MS_PREPARED);
149
+ }, timeoutMs);
150
+ }
151
+ } catch (error) {
152
+ finish({ exitCode: null, signal: null, spawnError: error });
153
+ }
154
+ });
155
+ }
156
+
157
+ export const TERMINATION_GRACE_MS_PREPARED = 1_000;
158
+
159
+ /**
160
+ * Launch a prepared execution. The caller must have already recorded
161
+ * ACTION_STARTED; this function never performs deterministic pre-launch
162
+ * validation again.
163
+ */
164
+ export async function runPreparedCommandExecution({
165
+ target,
166
+ packageRoot,
167
+ taskId,
168
+ checkId,
169
+ requirement,
170
+ verificationCycle = 1,
171
+ prepared,
172
+ timeoutMs = null,
173
+ executionPath,
174
+ }) {
175
+ const { createHash } = await import("node:crypto");
176
+ const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
177
+ const executionId = `exec-${randomUUID()}`;
178
+ const startedAt = new Date().toISOString();
179
+ const processResult = await executePreparedProcess(prepared.argv, target, { timeoutMs });
180
+ const finishedAt = new Date().toISOString();
181
+ const execution = {
182
+ schemaVersion: 1,
183
+ protocolVersion: 1,
184
+ executionId,
185
+ taskId,
186
+ checkId,
187
+ requirement,
188
+ verificationCycle,
189
+ kind: "COMMAND_EXECUTION",
190
+ argv: prepared.argv,
191
+ cwd: target,
192
+ resolution: {
193
+ resolutionMode: prepared.resolution.resolutionMode,
194
+ mayInstall: prepared.resolution.mayInstall,
195
+ installer: prepared.resolution.installer,
196
+ tool: prepared.resolution.tool,
197
+ },
198
+ ...(prepared.resolution.dispatch ? { dispatch: prepared.resolution.dispatch } : {}),
199
+ startedAt,
200
+ finishedAt,
201
+ status: processResult.exitCode === 0 && !processResult.spawnError && !processResult.timedOut ? "passed" : "failed",
202
+ exitCode: processResult.exitCode,
203
+ durationMs: Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt)),
204
+ termination: processResult.spawnError ? "spawn-error" : processResult.timedOut ? "timeout" : processResult.signal ? "signal" : "exit",
205
+ signal: processResult.signal ?? null,
206
+ stdoutSha256: digest(processResult.stdout),
207
+ stderrSha256: digest(processResult.stderr),
208
+ stdoutBytes: processResult.stdoutBytes,
209
+ stderrBytes: processResult.stderrBytes,
210
+ outputTruncated: processResult.outputTruncated,
211
+ ...(Number.isInteger(timeoutMs) && timeoutMs > 0 ? { timeoutMs, terminationGraceMs: TERMINATION_GRACE_MS_PREPARED } : {}),
212
+ };
213
+ const { writeJsonArtifact } = await import("./artifacts.js");
214
+ const written = await writeJsonArtifact(target, executionPath ?? await resolveExecutionArtifactPathFor(target, taskId, executionId), execution, "execution", packageRoot);
215
+ return {
216
+ path: written.path,
217
+ execution: written.value,
218
+ result: processResult.spawnError
219
+ ? "process failed to start"
220
+ : `process exited with code ${processResult.exitCode}`,
221
+ };
222
+ }
223
+
224
+ async function resolveExecutionArtifactPathFor(target, taskId, executionId) {
225
+ const { resolveExecutionArtifactPath } = await import("./execution.js");
226
+ return resolveExecutionArtifactPath(target, taskId, executionId);
227
+ }
@@ -1,4 +1,9 @@
1
1
  import { diagnosisEventsForTask } from "./diagnosis-model.js";
2
+ import { resolveCurrentCycleDiagnostic } from "./diagnostic-projection.js";
3
+ import {
4
+ buildInformationGainProjection,
5
+ evaluateStructuredDiagnosticStall,
6
+ } from "./information-gain-projection.js";
2
7
 
3
8
  export const PROGRESS_STATUS = Object.freeze({
4
9
  ADVANCING: "ADVANCING",
@@ -32,7 +37,25 @@ export function evaluateProgress({ state, events = [] } = {}) {
32
37
 
33
38
  const taskEvents = Array.isArray(events) ? events.filter((e) => !state.taskId || e.taskId === state.taskId) : [];
34
39
  const diagEvents = diagnosisEventsForTask(taskEvents, state.taskId);
35
- const latestDiag = diagEvents.at(-1)?.details ?? null;
40
+ const resolvedDiagnostic = resolveCurrentCycleDiagnostic(taskEvents, state.taskId, state.verificationCycle ?? null);
41
+ const latestDiag = resolvedDiagnostic?.details ?? diagEvents.at(-1)?.details ?? null;
42
+
43
+ let latestGainClassification = latestDiag?.informationGain ?? null;
44
+ let structuredStall = null;
45
+ if (resolvedDiagnostic?.sourceModel === "STRUCTURED_DIAGNOSTIC_CASE_V1") {
46
+ if (!Array.isArray(latestDiag.evidenceRefs)) {
47
+ latestDiag.evidenceRefs = [...new Set(
48
+ (latestDiag.hypotheses ?? []).flatMap((hypothesis) => hypothesis.evidenceRefs ?? []),
49
+ )];
50
+ }
51
+ // Canonical structured-stall truth; compatibility classification is presentation only.
52
+ structuredStall = evaluateStructuredDiagnosticStall(
53
+ buildInformationGainProjection(taskEvents, state.taskId),
54
+ { verificationCycle: state.verificationCycle ?? null },
55
+ );
56
+ latestDiag.effectiveInformationGain = !(structuredStall.stalled);
57
+ latestGainClassification = structuredStall.latestGain?.classification ?? null;
58
+ }
36
59
 
37
60
  // Build checksById index for resolving requirement from check IDs
38
61
  const checksById = new Map();
@@ -48,13 +71,27 @@ export function evaluateProgress({ state, events = [] } = {}) {
48
71
  }
49
72
 
50
73
  // 1. Check if latest diagnosis has NO information gain (global stall)
51
- if (latestDiag && latestDiag.informationGain === "NONE") {
74
+ const legacyStalled = resolvedDiagnostic?.sourceModel !== "STRUCTURED_DIAGNOSTIC_CASE_V1"
75
+ && Boolean(latestDiag) && latestGainClassification === "NONE";
76
+ // Single normalized diagnostic-stall truth: structured diagnostics defer to
77
+ // the canonical structured stall evaluator; legacy diagnoses keep their
78
+ // compatibility rule (informationGain NONE).
79
+ const diagnosticStalled =
80
+ resolvedDiagnostic?.sourceModel === "STRUCTURED_DIAGNOSTIC_CASE_V1"
81
+ ? Boolean(structuredStall?.stalled)
82
+ : Boolean(legacyStalled);
83
+ if (diagnosticStalled) {
52
84
  status = PROGRESS_STATUS.STALLED;
53
85
  signals.push({
54
86
  code: PROGRESS_SIGNAL.NO_DIAGNOSTIC_INFORMATION_GAIN,
55
87
  severity: "BLOCKING_FOR_RETRY",
56
- message: "Latest diagnosis repeats the prior hypothesis with the same evidence.",
88
+ message: resolvedDiagnostic?.sourceModel === "STRUCTURED_DIAGNOSTIC_CASE_V1"
89
+ ? "Latest diagnostic state introduces no effective new information relative to the prior comparable diagnostic state."
90
+ : "Latest diagnosis repeats the prior hypothesis with the same evidence.",
57
91
  verificationCycles: [latestDiag.verificationCycle],
92
+ ...(structuredStall?.latestGain
93
+ ? { classification: structuredStall.latestGain.classification, effectiveGain: false }
94
+ : {}),
58
95
  });
59
96
  }
60
97
 
@@ -88,7 +125,7 @@ export function evaluateProgress({ state, events = [] } = {}) {
88
125
  for (const [req, cyclesSet] of [...reqCycles.entries()].sort(([a], [b]) => a.localeCompare(b))) {
89
126
  if (cyclesSet.size >= 3) {
90
127
  const sortedCycles = [...cyclesSet].sort((a, b) => a - b);
91
- const isLatestStalledForThisReq = latestDiag && latestDiag.informationGain === "NONE" && latestDiagReqs.includes(req);
128
+ const isLatestStalledForThisReq = diagnosticStalled && latestDiagReqs.includes(req);
92
129
  if (isLatestStalledForThisReq) {
93
130
  if (!signals.some((s) => s.code === PROGRESS_SIGNAL.REPEATED_FAILURE_WITH_SAME_DIAGNOSIS && s.requirement === req)) {
94
131
  signals.push({
@@ -0,0 +1,21 @@
1
+ import path from "node:path";
2
+ import { realpath } from "node:fs/promises";
3
+
4
+ import { resolveTarget } from "./filesystem.js";
5
+
6
+ /**
7
+ * Canonical project-root resolution for integrations. Applies exactly the
8
+ * same semantics as the CLI target resolver — the path must exist, must be a
9
+ * real directory (not a symlink), and is returned as an absolute path.
10
+ *
11
+ * Symlinked roots are rejected so that every transport agrees on whether a
12
+ * given project path is acceptable; use the resolved real directory instead.
13
+ */
14
+ export async function resolveForgeLoopProjectRoot(projectPath, { cwd = process.cwd() } = {}) {
15
+ const target = await resolveTarget(cwd, projectPath);
16
+ return realpath(target);
17
+ }
18
+
19
+ export function defaultIntegrationProjectPath() {
20
+ return path.resolve(".");
21
+ }
@@ -33,6 +33,67 @@ export function protocolInfo({ packageVersion = null } = {}) {
33
33
  readsSchemaVersions: schemaVersions,
34
34
  writesSchemaVersions: schemaVersions,
35
35
  compatibility: SCHEMA_COMPATIBILITY_POLICY,
36
+ features: {
37
+ taskClaimRecovery: {
38
+ version: 1,
39
+ durableRecoveryState: true,
40
+ explicitResume: true,
41
+ validatedClaimProjection: true,
42
+ },
43
+ integrationApi: {
44
+ version: 1,
45
+ structuredCommandRuntime: true,
46
+ canonicalResources: true,
47
+ },
48
+ executionHistory: {
49
+ version: 1,
50
+ supported: true,
51
+ schemaVersion: 1,
52
+ },
53
+ structuredTrace: {
54
+ version: 1,
55
+ supported: true,
56
+ schemaVersion: 1,
57
+ },
58
+ taskInspection: {
59
+ version: 1,
60
+ supported: true,
61
+ schemaVersion: 1,
62
+ },
63
+ reflection: {
64
+ version: 1,
65
+ supported: true,
66
+ schemaVersion: 1,
67
+ },
68
+ diagnostics: {
69
+ legacyDiagnosis: true,
70
+ structuredDiagnosticCase: true,
71
+ multifactorContributors: true,
72
+ hypothesisDisposition: true,
73
+ interventionLedger: true,
74
+ informationGainV2: true,
75
+ strategyOscillationDetection: true,
76
+ },
77
+ durableActions: {
78
+ version: 1,
79
+ supported: true,
80
+ states: ["PROPOSED", "AUTHORIZED", "STARTED", "COMMITTED", "VERIFIED", "FAILED", "COMMIT_UNKNOWN", "CANCELLED"],
81
+ reconciliation: true,
82
+ exactlyOnce: false,
83
+ },
84
+ capabilityPolicy: { version: 1, supported: true, decisions: ["ALLOW", "DENY", "REQUIRE_AUTHORITY", "REQUIRE_APPROVAL"] },
85
+ durableApprovals: { version: 1, supported: true, fingerprintBound: true, hostAttestationMinting: false },
86
+ trajectoryMetrics: { version: 1, supported: true, usageUnknownWhenUnreported: true, overallScore: false },
87
+ trajectoryEvaluation: { version: 1, supported: true, requiresReferenceScenario: true, source: "PROJECT_LOCAL_REFERENCE" },
88
+ observabilityStability: {
89
+ executionHistory: "stable",
90
+ structuredTrace: "stable",
91
+ taskInspection: "stable",
92
+ reflection: "stable",
93
+ informationGainV2: "stable",
94
+ strategyOscillationDetection: "stable",
95
+ },
96
+ },
36
97
  lifecycle: { phases: WORK_PHASES, transitions: WORK_TRANSITIONS },
37
98
  guides: Object.values(GUIDE_REGISTRY),
38
99
  commands: Object.values(CLI_COMMAND_DEFINITIONS).map(({ name, category, mutation, description }) => ({ name, category, mutation, description })),