@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
@@ -14,7 +14,25 @@ import { taskArtifactPath } from "./task-paths.js";
14
14
  import { getActiveTaskTransaction, withTaskTransaction } from "./transaction.js";
15
15
 
16
16
  import { assertDiagnosisDetails } from "./diagnosis-model.js";
17
+ import {
18
+ assertActionEventDetails,
19
+ assertApprovalEventDetails,
20
+ isActionEventName,
21
+ isApprovalEventName,
22
+ } from "./action-model.js";
23
+ import {
24
+ assertDiagnosticCaseDetails,
25
+ assertInterventionDetails,
26
+ assertHypothesisDispositionDetails,
27
+ } from "./diagnostic-model.js";
17
28
  import { assertDecisionCriterionDetails } from "./settlement-model.js";
29
+ import {
30
+ LEGACY_RECOVERY_MIGRATION_EVENT,
31
+ assertLegacyMigrationDetails,
32
+ isLegacyRecoveryDetailsShape,
33
+ isLegacyRecoveryEventShape,
34
+ legacyRecoveryMigrationId,
35
+ } from "./task-recovery-migration.js";
18
36
 
19
37
  const EVENT_SCHEMA_VERSION = 1;
20
38
  export const LIFECYCLE_MILESTONES = Object.freeze([
@@ -39,6 +57,7 @@ const REPEATABLE_MILESTONES = new Set([
39
57
  "VERIFICATION_RECORDED",
40
58
  "REVIEW_STARTED",
41
59
  "TERMINAL_RESULT_RECORDED",
60
+ "PREFLIGHT_READY",
42
61
  ]);
43
62
 
44
63
  function eventIndexPath(eventsPath) {
@@ -91,17 +110,93 @@ export function validateKnownEventDetails(event) {
91
110
  case "DIAGNOSIS_RECORDED":
92
111
  assertDiagnosisDetails(event.details);
93
112
  return;
113
+ case "DIAGNOSTIC_CASE_RECORDED":
114
+ assertDiagnosticCaseDetails(event.details);
115
+ return;
116
+ case "INTERVENTION_RECORDED":
117
+ assertInterventionDetails(event.details);
118
+ return;
119
+ case "HYPOTHESIS_DISPOSITION_RECORDED":
120
+ assertHypothesisDispositionDetails(event.details);
121
+ return;
94
122
  case "DECISION_CRITERION_RECORDED":
95
123
  assertDecisionCriterionDetails(event.details);
96
124
  return;
97
125
  case "CHECKPOINT_RECONCILED":
98
126
  assertReconcileClosureDetails(event.details);
99
127
  return;
128
+ case "TASK_RECOVERY_RECORDED":
129
+ assertRecoveryRecordedDetails(event.details);
130
+ return;
131
+ case "OPERATOR_RECOVERY_RECORDED":
132
+ // The exact known legacy defect signature is tolerated here so the
133
+ // ledger can be parsed and classified. It only becomes valid through an
134
+ // official migration event (enforced by validateEventLedger).
135
+ if (!event.details?.recoveryId && isLegacyRecoveryDetailsShape(event.details)) return;
136
+ assertRecoveryRecordedDetails(event.details);
137
+ return;
138
+ case "LEGACY_RECOVERY_MIGRATION_RECORDED":
139
+ assertLegacyMigrationDetails(event.details);
140
+ return;
141
+ case "TASK_RECOVERY_RESUMED":
142
+ assertRecoveryResumedDetails(event.details);
143
+ return;
144
+ case "TRAJECTORY_EVALUATED":
145
+ if (!event.details || !/^eval-[A-Za-z0-9_-]+$/.test(event.details.evaluationId ?? "")
146
+ || typeof event.details.scenarioId !== "string" || !/^[a-f0-9]{64}$/.test(event.details.evaluationFingerprint ?? "")
147
+ || event.fingerprint !== event.details.evaluationFingerprint) {
148
+ throw protocolError("E_EVENT_INVALID", "TRAJECTORY_EVALUATED requires a bound evaluationId, scenarioId, and fingerprint");
149
+ }
150
+ return;
100
151
  default:
152
+ if (isActionEventName(event.event)) {
153
+ assertActionEventDetails(event);
154
+ return;
155
+ }
156
+ if (isApprovalEventName(event.event)) {
157
+ assertApprovalEventDetails(event);
158
+ return;
159
+ }
101
160
  return;
102
161
  }
103
162
  }
104
163
 
164
+ function assertStringList(value, label) {
165
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item)) {
166
+ throw protocolError("E_EVENT_INVALID", `${label} must be an array of non-empty strings`);
167
+ }
168
+ }
169
+
170
+ function assertRecoveryRecordedDetails(details) {
171
+ if (!details || typeof details !== "object" || Array.isArray(details)) {
172
+ throw protocolError("E_EVENT_INVALID", "recovery event requires structured details");
173
+ }
174
+ for (const key of ["recoveryId", "classification", "previousPhase", "authorityKind"]) {
175
+ if (typeof details[key] !== "string" || !details[key]) {
176
+ throw protocolError("E_EVENT_INVALID", `recovery event details.${key} must be a non-empty string`);
177
+ }
178
+ }
179
+ if (!Number.isInteger(details.previousRevision) || details.previousRevision < 0) {
180
+ throw protocolError("E_EVENT_INVALID", "recovery event details.previousRevision must be a non-negative integer");
181
+ }
182
+ if (!["STALE", "ABANDONED"].includes(details.classification)) {
183
+ throw protocolError("E_EVENT_INVALID", "recovery event details.classification must be STALE or ABANDONED");
184
+ }
185
+ if (!["CALLER_ACKNOWLEDGED", "HOST_ATTESTED"].includes(details.authorityKind)) {
186
+ throw protocolError("E_EVENT_INVALID", "recovery event details.authorityKind is invalid");
187
+ }
188
+ assertStringList(details.reasonCodes, "recovery event details.reasonCodes");
189
+ assertStringList(details.releasedClaims, "recovery event details.releasedClaims");
190
+ }
191
+
192
+ function assertRecoveryResumedDetails(details) {
193
+ if (!details || typeof details !== "object" || Array.isArray(details)
194
+ || typeof details.recoveryId !== "string" || !details.recoveryId) {
195
+ throw protocolError("E_EVENT_INVALID", "TASK_RECOVERY_RESUMED requires details.recoveryId");
196
+ }
197
+ assertStringList(details.reacquiredClaims, "TASK_RECOVERY_RESUMED details.reacquiredClaims");
198
+ }
199
+
105
200
  function assertReconcileClosureDetails(details) {
106
201
  if (!details || typeof details !== "object" || Array.isArray(details)) {
107
202
  throw protocolError("E_EVENT_INVALID", "CHECKPOINT_RECONCILED requires structured details");
@@ -121,7 +216,7 @@ function assertReconcileClosureDetails(details) {
121
216
  }
122
217
  }
123
218
 
124
- function eventHash(event) {
219
+ export function eventHash(event) {
125
220
  const { hash, ...body } = event;
126
221
  return canonicalFingerprint(body);
127
222
  }
@@ -242,6 +337,73 @@ export async function appendProtocolEvent(target, input, packageRoot, options =
242
337
  return event;
243
338
  }
244
339
 
340
+ /**
341
+ * Validates the append-only pairing between unmigrated legacy recovery events
342
+ * and their official migration events. Strict by default; the official repair
343
+ * command validates intermediate state with `allowUnmigratedLegacyRecoveryEvents`
344
+ * before appending the migration events.
345
+ */
346
+ function validateLegacyRecoveryMigrations(events, errors, { allowUnmigratedLegacyRecoveryEvents = false } = {}) {
347
+ const migrationBySeq = new Map();
348
+ for (const event of events) {
349
+ if (event.event !== LEGACY_RECOVERY_MIGRATION_EVENT) continue;
350
+ try {
351
+ assertLegacyMigrationDetails(event.details);
352
+ } catch (err) {
353
+ errors.push({ code: err.code ?? "E_EVENT_INVALID", message: `event ${event.seq} (${event.event}): ${err.message}` });
354
+ continue;
355
+ }
356
+ if (migrationBySeq.has(event.details.legacyEventSeq)) {
357
+ errors.push({
358
+ code: "E_EVENT_INVALID",
359
+ message: `event ${event.seq} (${event.event}): duplicate migration for legacy recovery event seq ${event.details.legacyEventSeq}`,
360
+ });
361
+ continue;
362
+ }
363
+ migrationBySeq.set(event.details.legacyEventSeq, event);
364
+ }
365
+ for (const event of events) {
366
+ if (!isLegacyRecoveryEventShape(event)) continue;
367
+ const migration = migrationBySeq.get(event.seq);
368
+ if (!migration) {
369
+ if (!allowUnmigratedLegacyRecoveryEvents) {
370
+ errors.push({
371
+ code: "E_EVENT_INVALID",
372
+ message: `legacy recovery event ${event.seq} is not officially migrated (run forgeloop task-repair-legacy-recovery)`,
373
+ });
374
+ }
375
+ continue;
376
+ }
377
+ migrationBySeq.delete(event.seq);
378
+ const expectedRecoveryId = legacyRecoveryMigrationId({ taskId: event.taskId, seq: event.seq, hash: event.hash });
379
+ // Tail-binding: the migration event is appended at the ledger tail and may
380
+ // sit anywhere after its historical source. It binds by reference only.
381
+ if (migration.seq <= event.seq) {
382
+ errors.push({
383
+ code: "E_EVENT_INVALID",
384
+ message: `migration event ${migration.seq} must follow legacy recovery event ${event.seq}`,
385
+ });
386
+ }
387
+ if (migration.taskId !== event.taskId
388
+ || migration.details.legacyTaskId !== event.taskId
389
+ || migration.details.recoveryId !== expectedRecoveryId
390
+ || migration.details.legacyEventHash !== event.hash
391
+ || migration.details.legacyEventAt !== event.at
392
+ || migration.details.legacyEventType !== event.event) {
393
+ errors.push({
394
+ code: "E_LEDGER_HASH_INVALID",
395
+ message: `migration event ${migration.seq} does not bind legacy recovery event ${event.seq}`,
396
+ });
397
+ }
398
+ }
399
+ for (const [legacySeq, migration] of migrationBySeq) {
400
+ errors.push({
401
+ code: "E_EVENT_INVALID",
402
+ message: `migration event ${migration.seq} references unknown legacy recovery event seq ${legacySeq}`,
403
+ });
404
+ }
405
+ }
406
+
245
407
  export async function validateEventLedger(target, packageRoot, options = {}) {
246
408
  const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
247
409
  let events;
@@ -284,7 +446,11 @@ export async function validateEventLedger(target, packageRoot, options = {}) {
284
446
  code: "E_PHASE_CHRONOLOGY_INVALID",
285
447
  message: `${event.event} is missing prerequisite milestone: ${LIFECYCLE_MILESTONES[lastMilestone + 1]}`,
286
448
  });
287
- } else if (milestoneIndex < lastMilestone && event.event !== "VERIFICATION_STARTED") {
449
+ } else if (milestoneIndex < lastMilestone && event.event !== "VERIFICATION_STARTED"
450
+ && event.event !== "PREFLIGHT_READY") {
451
+ // VERIFICATION_STARTED re-enters per verification cycle; PREFLIGHT_READY
452
+ // may be refreshed mid-lifecycle (policy/contract evolution) after its
453
+ // prerequisites were already satisfied by the earlier occurrence.
288
454
  errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: `${event.event} is out of lifecycle order` });
289
455
  } else if (milestoneIndex === lastMilestone && !REPEATABLE_MILESTONES.has(event.event)) {
290
456
  errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: `lifecycle milestone must not repeat: ${event.event}` });
@@ -323,6 +489,9 @@ export async function validateEventLedger(target, packageRoot, options = {}) {
323
489
  errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: "completion rejected before verification started" });
324
490
  }
325
491
  }
492
+ validateLegacyRecoveryMigrations(events, errors, {
493
+ allowUnmigratedLegacyRecoveryEvents: options?.allowUnmigratedLegacyRecoveryEvents === true,
494
+ });
326
495
  return { valid: errors.length === 0, events, errors };
327
496
  }
328
497
 
@@ -102,7 +102,10 @@ function prerequisiteLedgerErrors(ledger, taskId, preflight, route) {
102
102
  ));
103
103
  }
104
104
  }
105
- const preflightEvent = currentEvents.find((event) => event.event === "PREFLIGHT_READY");
105
+ // Bind to the latest PREFLIGHT_READY: an append-only lifecycle may contain an
106
+ // older READY event superseded by a BLOCKED outcome and a fresh READY with
107
+ // current details (see preflight-reactivation recovery).
108
+ const preflightEvent = currentEvents.findLast((event) => event.event === "PREFLIGHT_READY");
106
109
  if (preflightEvent && (!sameStringSet(preflightEvent.details?.requiredGates, preflight.requiredGates)
107
110
  || !sameStringSet(preflightEvent.details?.satisfiedGates, preflight.satisfiedGates))) {
108
111
  errors.push(issue(
@@ -1,5 +1,3 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { spawn } from "node:child_process";
3
1
  import { readdir } from "node:fs/promises";
4
2
  import path from "node:path";
5
3
  import { ensureWithin, fileExists } from "./filesystem.js";
@@ -7,17 +5,19 @@ import {
7
5
  ARTIFACT_PATHS,
8
6
  executionArtifactPath,
9
7
  readJsonArtifact,
10
- writeJsonArtifact,
11
8
  } from "./artifacts.js";
12
9
  import { taskArtifactPath, taskExecutionPath } from "./task-paths.js";
13
- import {
14
- resolveExecutionResolution,
15
- validateVerificationAuthority,
16
- E_COMMAND_RESOLUTION_AMBIGUOUS,
17
- } from "./verification-capability.js";
18
10
 
19
- export { E_COMMAND_RESOLUTION_AMBIGUOUS };
20
- export const EXECUTION_KIND = "COMMAND_EXECUTION";
11
+ export { E_COMMAND_RESOLUTION_AMBIGUOUS } from "./verification-capability.js";
12
+ import {
13
+ prepareCommandExecution,
14
+ runPreparedCommandExecution,
15
+ } from "./prepared-execution.js";
16
+ export {
17
+ prepareCommandExecution,
18
+ runPreparedCommandExecution,
19
+ TERMINATION_GRACE_MS_PREPARED as TERMINATION_GRACE_MS,
20
+ } from "./prepared-execution.js";
21
21
 
22
22
  function executionError(code, message, artifacts = []) {
23
23
  const error = new Error(message);
@@ -26,111 +26,6 @@ function executionError(code, message, artifacts = []) {
26
26
  return error;
27
27
  }
28
28
 
29
- function normalizeArgv(argv) {
30
- if (!Array.isArray(argv) || argv.length === 0 || argv.some((item) => typeof item !== "string" || item.trim() === "")) {
31
- throw executionError("E_EXECUTION_INVALID", "Execution argv must contain at least one non-empty string");
32
- }
33
- return [...argv];
34
- }
35
-
36
- function validateAuthorityBeforeLaunch({ target, taskId, argv, resolution, details, authorityContext, runtimeContext }) {
37
- if (!resolution.mayInstall) return;
38
- const check = {
39
- kind: "command",
40
- source: argv[0],
41
- details: {
42
- ...(details ?? {}),
43
- execution: { resolution },
44
- },
45
- };
46
- const authority = validateVerificationAuthority(check, {
47
- target,
48
- taskId,
49
- authorityContext,
50
- runtimeContext,
51
- });
52
- if (!authority.valid) {
53
- throw executionError(authority.error.code ?? "E_INSTALLATION_AUTHORITY_REQUIRED", authority.error.message);
54
- }
55
- }
56
-
57
- const MAX_CAPTURED_OUTPUT_BYTES = 64 * 1024;
58
- export const TERMINATION_GRACE_MS = 1_000;
59
-
60
- function digest(bytes) {
61
- return createHash("sha256").update(bytes).digest("hex");
62
- }
63
-
64
- function executeProcess(argv, cwd, { timeoutMs = null } = {}) {
65
- return new Promise((resolve) => {
66
- let spawnError = null;
67
- let timedOut = false;
68
- let settled = false;
69
- let timeout = null;
70
- let forceTermination = null;
71
- const stdout = [];
72
- const stderr = [];
73
- let stdoutBytes = 0;
74
- let stderrBytes = 0;
75
- let outputTruncated = false;
76
- const capture = (chunks, chunk, total) => {
77
- const available = MAX_CAPTURED_OUTPUT_BYTES - total;
78
- if (available <= 0) {
79
- outputTruncated = true;
80
- return total;
81
- }
82
- if (chunk.length > available) {
83
- chunks.push(chunk.subarray(0, available));
84
- outputTruncated = true;
85
- return total + available;
86
- }
87
- chunks.push(chunk);
88
- return total + chunk.length;
89
- };
90
- const finish = (result) => {
91
- if (settled) return;
92
- settled = true;
93
- if (timeout) clearTimeout(timeout);
94
- if (forceTermination) clearTimeout(forceTermination);
95
- resolve({
96
- ...result,
97
- timedOut,
98
- stdout: Buffer.concat(stdout),
99
- stderr: Buffer.concat(stderr),
100
- stdoutBytes,
101
- stderrBytes,
102
- outputTruncated,
103
- });
104
- };
105
- try {
106
- const child = spawn(argv[0], argv.slice(1), {
107
- cwd,
108
- shell: false,
109
- stdio: ["ignore", "pipe", "pipe"],
110
- });
111
- child.stdout?.on("data", (chunk) => { stdoutBytes = capture(stdout, chunk, stdoutBytes); });
112
- child.stderr?.on("data", (chunk) => { stderrBytes = capture(stderr, chunk, stderrBytes); });
113
- child.once("error", (error) => {
114
- spawnError = error;
115
- });
116
- child.once("close", (exitCode, signal) => {
117
- finish({ exitCode, signal, spawnError });
118
- });
119
- if (Number.isInteger(timeoutMs) && timeoutMs > 0) {
120
- timeout = setTimeout(() => {
121
- timedOut = true;
122
- child.kill("SIGTERM");
123
- forceTermination = setTimeout(() => {
124
- child.kill("SIGKILL");
125
- }, TERMINATION_GRACE_MS);
126
- }, timeoutMs);
127
- }
128
- } catch (error) {
129
- finish({ exitCode: null, signal: null, spawnError: error });
130
- }
131
- });
132
- }
133
-
134
29
  /**
135
30
  * Resolves where a new execution artifact should be written. Task-scoped
136
31
  * execution artifacts require a real modern task namespace (a task.json
@@ -148,6 +43,12 @@ export async function resolveExecutionArtifactPath(target, taskId, executionId)
148
43
  return executionArtifactPath(executionId);
149
44
  }
150
45
 
46
+ /**
47
+ * Deterministic pre-launch preparation followed by an exact-argv launch.
48
+ * Kept as the canonical single-command entrypoint for non-durable callers
49
+ * (run-check); durable actions use the two phases separately so that
50
+ * ACTION_STARTED lands exactly on the launch boundary (INV-EXEC-01).
51
+ */
151
52
  export async function runCommandExecution({
152
53
  target,
153
54
  packageRoot,
@@ -162,87 +63,24 @@ export async function runCommandExecution({
162
63
  executionPath,
163
64
  timeoutMs = null,
164
65
  } = {}) {
165
- const commandArgv = normalizeArgv(argv);
166
- const resolution = await resolveExecutionResolution({
167
- argv: commandArgv,
168
- cwd: target,
169
- });
170
-
171
- if (
172
- resolution.resolutionMode === "UNKNOWN"
173
- && resolution.mayInstall === true
174
- && (
175
- resolution.reason === "NPM_WORKSPACE_SCRIPT_UNRESOLVED"
176
- || resolution.reason === "NPM_SUBCOMMAND_AMBIGUOUS"
177
- || resolution.reason === "NPM_COMMAND_UNCLASSIFIED"
178
- || resolution.reason === "NPM_OPTION_VALUE_AMBIGUOUS"
179
- )
180
- ) {
181
- const error = new Error(
182
- resolution.reason === "NPM_WORKSPACE_SCRIPT_UNRESOLVED"
183
- ? "npm workspace script execution cannot be proven from the current target. Run ForgeLoop against the selected workspace directory."
184
- : "Command execution context could not be proven safe before launch."
185
- );
186
- error.code = E_COMMAND_RESOLUTION_AMBIGUOUS;
187
- error.resolution = resolution;
188
- throw error;
189
- }
190
-
191
- validateAuthorityBeforeLaunch({
66
+ const prepared = await prepareCommandExecution({
192
67
  target,
193
- taskId,
194
- argv: commandArgv,
195
- resolution,
68
+ argv,
196
69
  details,
197
70
  authorityContext,
198
71
  runtimeContext,
199
72
  });
200
-
201
- const executionId = `exec-${randomUUID()}`;
202
- const startedAt = new Date().toISOString();
203
- const processResult = await executeProcess(commandArgv, target, { timeoutMs });
204
- const finishedAt = new Date().toISOString();
205
- const execution = {
206
- schemaVersion: 1,
207
- protocolVersion: 1,
208
- executionId,
73
+ return runPreparedCommandExecution({
74
+ target,
75
+ packageRoot,
209
76
  taskId,
210
77
  checkId,
211
78
  requirement,
212
79
  verificationCycle,
213
- kind: EXECUTION_KIND,
214
- argv: commandArgv,
215
- cwd: target,
216
- resolution: {
217
- resolutionMode: resolution.resolutionMode,
218
- mayInstall: resolution.mayInstall,
219
- installer: resolution.installer,
220
- tool: resolution.tool,
221
- },
222
- ...(resolution.dispatch ? { dispatch: resolution.dispatch } : {}),
223
- startedAt,
224
- finishedAt,
225
- status: processResult.exitCode === 0 && !processResult.spawnError && !processResult.timedOut ? "passed" : "failed",
226
- exitCode: processResult.exitCode,
227
- durationMs: Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt)),
228
- termination: processResult.spawnError ? "spawn-error" : processResult.timedOut ? "timeout" : processResult.signal ? "signal" : "exit",
229
- signal: processResult.signal ?? null,
230
- stdoutSha256: digest(processResult.stdout),
231
- stderrSha256: digest(processResult.stderr),
232
- stdoutBytes: processResult.stdoutBytes,
233
- stderrBytes: processResult.stderrBytes,
234
- outputTruncated: processResult.outputTruncated,
235
- ...(Number.isInteger(timeoutMs) && timeoutMs > 0 ? { timeoutMs, terminationGraceMs: TERMINATION_GRACE_MS } : {}),
236
- };
237
- const execPath = executionPath ?? await resolveExecutionArtifactPath(target, taskId, executionId);
238
- const written = await writeJsonArtifact(target, execPath, execution, "execution", packageRoot);
239
- return {
240
- path: written.path,
241
- execution: written.value,
242
- result: processResult.spawnError
243
- ? "process failed to start"
244
- : `process exited with code ${processResult.exitCode}`,
245
- };
80
+ prepared,
81
+ timeoutMs,
82
+ executionPath,
83
+ });
246
84
  }
247
85
 
248
86
  export async function readExecutionArtifact({ target, executionRef, packageRoot, taskId } = {}) {
@@ -287,7 +125,7 @@ export async function readExecutionArtifact({ target, executionRef, packageRoot,
287
125
  }
288
126
 
289
127
  export function validateExecutionBinding({ execution, taskId, checkId, requirement, verificationCycle = 1 } = {}) {
290
- if (!execution || execution.kind !== EXECUTION_KIND
128
+ if (!execution || execution.kind !== "COMMAND_EXECUTION"
291
129
  || execution.taskId !== taskId
292
130
  || execution.checkId !== checkId
293
131
  || execution.requirement !== requirement
@@ -0,0 +1,70 @@
1
+ import { canonicalFingerprint } from "./artifacts.js";
2
+
3
+ export function computeFailureSignature({ requirement, checkKind = null, status, exitCode = null, failureToken = null }) {
4
+ if (!requirement || typeof requirement !== "string") {
5
+ const error = new Error("failure signature requires a requirement");
6
+ error.code = "E_FAILURE_SIGNATURE_INVALID";
7
+ throw error;
8
+ }
9
+ if (!status || typeof status !== "string") {
10
+ const error = new Error("failure signature requires a status");
11
+ error.code = "E_FAILURE_SIGNATURE_INVALID";
12
+ throw error;
13
+ }
14
+ return canonicalFingerprint({
15
+ requirement,
16
+ ...(checkKind ? { checkKind } : {}),
17
+ status,
18
+ ...(Number.isInteger(exitCode) ? { exitCode } : {}),
19
+ ...(failureToken ? { failureToken: String(failureToken) } : {}),
20
+ });
21
+ }
22
+
23
+ function failureTokenOf(details) {
24
+ if (typeof details?.failureToken === "string" && details.failureToken) return details.failureToken;
25
+ if (typeof details?.details?.failureToken === "string" && details.details.failureToken) return details.details.failureToken;
26
+ return null;
27
+ }
28
+
29
+ export function projectFailureSignatures({ state = null, events = [] } = {}) {
30
+ const byCycle = new Map();
31
+ const record = (cycle, details) => {
32
+ if (details.status !== "failed" && details.status !== "blocked") return;
33
+ const requirement = details.requirement ?? details.id ?? details.checkId;
34
+ if (!requirement) return;
35
+ const signature = computeFailureSignature({
36
+ requirement,
37
+ status: details.status,
38
+ exitCode: Number.isInteger(details.exitCode) ? details.exitCode : null,
39
+ failureToken: failureTokenOf(details),
40
+ });
41
+ if (!byCycle.has(cycle)) byCycle.set(cycle, new Map());
42
+ const cycleMap = byCycle.get(cycle);
43
+ if (!cycleMap.has(signature)) cycleMap.set(signature, { signature, requirements: new Set(), cycles: new Set() });
44
+ cycleMap.get(signature).requirements.add(requirement);
45
+ cycleMap.get(signature).cycles.add(cycle);
46
+ };
47
+
48
+ for (const event of events) {
49
+ if (event.event !== "VERIFICATION_RECORDED") continue;
50
+ record(event.details?.verificationCycle ?? 1, event.details ?? {});
51
+ }
52
+ for (const check of state?.checks ?? []) {
53
+ if (check.status !== "failed" && check.status !== "blocked") continue;
54
+ record(check.details?.verificationCycle ?? state?.verificationCycle ?? 1, check);
55
+ }
56
+
57
+ const signaturesByIdentity = new Map();
58
+ for (const [, cycleMap] of [...byCycle.entries()].sort(([a], [b]) => a - b)) {
59
+ for (const [signature, entry] of cycleMap) {
60
+ if (!signaturesByIdentity.has(signature)) signaturesByIdentity.set(signature, { signature, cycles: [], requirements: new Set() });
61
+ const aggregated = signaturesByIdentity.get(signature);
62
+ aggregated.cycles.push(...entry.cycles);
63
+ for (const requirement of entry.requirements) aggregated.requirements.add(requirement);
64
+ }
65
+ }
66
+
67
+ return [...signaturesByIdentity.values()]
68
+ .map((entry) => ({ ...entry, cycles: [...new Set(entry.cycles)].sort((a, b) => a - b), requirements: [...entry.requirements].sort() }))
69
+ .sort((a, b) => a.signature.localeCompare(b.signature));
70
+ }
@@ -0,0 +1,57 @@
1
+ export function projectFailureSurfaces({ state = null, events = [] } = {}) {
2
+ const failedRequirementsByCycle = new Map();
3
+ // Track every canonically verified cycle so a successful verification
4
+ // appears explicitly as surface: [] instead of silently disappearing.
5
+ const knownCycles = new Set();
6
+ const record = (cycle, requirement) => {
7
+ if (!Number.isInteger(cycle)) cycle = Number(cycle) || 1;
8
+ knownCycles.add(cycle);
9
+ if (!failedRequirementsByCycle.has(cycle)) failedRequirementsByCycle.set(cycle, new Set());
10
+ if (!requirement) return;
11
+ failedRequirementsByCycle.get(cycle).add(requirement);
12
+ };
13
+ const knowCycle = (cycle) => {
14
+ if (Number.isInteger(cycle)) knownCycles.add(cycle);
15
+ };
16
+
17
+ for (const event of events) {
18
+ if (event.event === "VERIFICATION_STARTED") {
19
+ knowCycle(event.details?.verificationCycle);
20
+ }
21
+ if (event.event !== "VERIFICATION_RECORDED") continue;
22
+ const d = event.details ?? {};
23
+ if (d.status === "failed" || d.status === "blocked") {
24
+ record(d.verificationCycle ?? 1, d.requirement ?? d.id ?? d.checkId);
25
+ } else {
26
+ knowCycle(d.verificationCycle);
27
+ }
28
+ }
29
+
30
+ for (const check of state?.checks ?? []) {
31
+ knowCycle(check.details?.verificationCycle ?? state?.verificationCycle ?? 1);
32
+ if (check.status !== "failed" && check.status !== "blocked") continue;
33
+ record(check.details?.verificationCycle ?? state?.verificationCycle ?? 1, check.requirement ?? check.id ?? check.checkId);
34
+ }
35
+ for (const cycle of knownCycles) {
36
+ if (!failedRequirementsByCycle.has(cycle)) failedRequirementsByCycle.set(cycle, new Set());
37
+ }
38
+
39
+ return [...failedRequirementsByCycle.entries()]
40
+ .sort(([a], [b]) => a - b)
41
+ .map(([verificationCycle, requirements]) => ({
42
+ verificationCycle,
43
+ surface: [...requirements].sort(),
44
+ size: requirements.size,
45
+ }));
46
+ }
47
+
48
+ export function compareFailureSurface(previous, current) {
49
+ if (!previous || !current) return { changed: false, direction: "UNCHANGED" };
50
+ const previousSet = new Set(previous);
51
+ const currentSet = new Set(current);
52
+ const removed = [...previousSet].filter((item) => !currentSet.has(item));
53
+ const added = [...currentSet].filter((item) => !previousSet.has(item));
54
+ if (added.length > 0) return { changed: true, direction: "EXPANDED", added, removed };
55
+ if (removed.length > 0) return { changed: true, direction: "REDUCED", added, removed };
56
+ return { changed: false, direction: "UNCHANGED", added, removed };
57
+ }