@cassiomc1/forgeloop 1.10.0 → 1.10.2

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 (67) hide show
  1. package/DOCS_INDEX.md +12 -1
  2. package/LOOP_ENGINEERING.md +4 -4
  3. package/LOOP_SYSTEM_DESIGN.md +23 -0
  4. package/QUALITY_SCORECARD.md +2 -2
  5. package/README.md +22 -8
  6. package/docs/ADVISORY_CONTEXT.md +24 -0
  7. package/docs/AGENT_PROTOCOL_SUMMARY.md +1 -1
  8. package/docs/ARTIFACT_REFERENCE.md +15 -0
  9. package/docs/CODE_ATTESTATION.md +9 -0
  10. package/docs/DOCUMENTATION_GUIDE.md +4 -4
  11. package/docs/EXECUTION_PROFILE_BENCHMARKS.md +10 -0
  12. package/docs/MCP.md +13 -1
  13. package/docs/PACKAGE_CONTENTS.md +88 -0
  14. package/docs/RELEASE_CHECKLIST.md +10 -2
  15. package/docs/REVISION_PROVIDERS.md +9 -0
  16. package/docs/RIPWIRE_ADAPTER.md +189 -0
  17. package/docs/TROUBLESHOOTING.md +12 -0
  18. package/docs/assets/diagrams/forgeloop-code-attestation-flow.html +13 -2
  19. package/docs/assets/diagrams/forgeloop-code-attestation-flow.receipt.json +6 -6
  20. package/docs/assets/diagrams/forgeloop-code-attestation-flow.svg +10 -1
  21. package/docs/assets/diagrams/forgeloop-engineering-flow.html +22 -11
  22. package/docs/assets/diagrams/forgeloop-engineering-flow.receipt.json +6 -6
  23. package/docs/assets/diagrams/forgeloop-engineering-flow.svg +17 -8
  24. package/docs/assets/diagrams/forgeloop-verification-trust-flow.html +14 -3
  25. package/docs/assets/diagrams/forgeloop-verification-trust-flow.receipt.json +6 -6
  26. package/docs/assets/diagrams/forgeloop-verification-trust-flow.svg +10 -1
  27. package/docs/diagrams/README.md +26 -0
  28. package/docs/diagrams/forgeloop-code-attestation-flow.workflow.json +383 -57
  29. package/docs/diagrams/forgeloop-engineering-flow.workflow.json +374 -55
  30. package/docs/diagrams/forgeloop-verification-trust-flow.workflow.json +328 -47
  31. package/docs/diagrams/reviews/forgeloop-code-attestation-flow.review.json +4 -4
  32. package/docs/diagrams/reviews/forgeloop-engineering-flow.review.json +4 -4
  33. package/docs/diagrams/reviews/forgeloop-verification-trust-flow.review.json +4 -4
  34. package/package.json +14 -4
  35. package/scripts/CI_VALIDATORS.md +24 -0
  36. package/scripts/check-critical-coverage.mjs +9 -0
  37. package/src/adapters/ripwire/normalize.js +352 -0
  38. package/src/adapters/ripwire/process.js +248 -0
  39. package/src/adapters/ripwire/provider.js +245 -0
  40. package/src/commands/doctor.js +11 -10
  41. package/src/core/actions.js +2 -2
  42. package/src/core/advisory-context/service.js +36 -14
  43. package/src/core/approvals.js +2 -2
  44. package/src/core/artifacts.js +3 -3
  45. package/src/core/checks.js +0 -33
  46. package/src/core/completion.js +2 -2
  47. package/src/core/events.js +5 -5
  48. package/src/core/execution-profile.js +18 -5
  49. package/src/core/handoff-acceptance.js +10 -1
  50. package/src/core/next-action-pending-actions.js +255 -0
  51. package/src/core/next-action-phases.js +26 -764
  52. package/src/core/next-action-planned-phase.js +51 -0
  53. package/src/core/next-action-quality-guidance.js +19 -0
  54. package/src/core/next-action-recovery-phases.js +97 -0
  55. package/src/core/next-action-refresh.js +20 -0
  56. package/src/core/next-action-review-phase.js +189 -0
  57. package/src/core/next-action-verification-phase.js +192 -0
  58. package/src/core/task-recovery.js +2 -2
  59. package/src/core/transaction-maintenance.js +70 -0
  60. package/src/core/transaction.js +31 -10
  61. package/src/core/work-state.js +20 -11
  62. package/src/integration.d.ts +30 -3
  63. package/src/integration.js +2 -0
  64. package/src/core/cli-metadata.js +0 -23
  65. package/src/core/decision-classification.js +0 -55
  66. package/src/core/gates.js +0 -57
  67. package/src/core/workflow-compatibility.js +0 -151
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
- import { mkdir, open, readdir, readFile, rename, stat, truncate, unlink } from "node:fs/promises";
3
+ import { mkdir, open, readdir, readFile, realpath, rename, stat, truncate, unlink } from "node:fs/promises";
4
4
  import { setTimeout as delay } from "node:timers/promises";
5
5
  import path from "node:path";
6
6
 
@@ -16,6 +16,18 @@ export function getActiveTaskTransaction() {
16
16
  return transactionContext.getStore() ?? null;
17
17
  }
18
18
 
19
+ export async function getTaskTransaction(target) {
20
+ const transaction = getActiveTaskTransaction();
21
+ if (transaction && transaction.target !== await realpath(target)) {
22
+ const error = new Error("Cannot reuse a task transaction for a different project");
23
+ error.code = "E_TASK_CONTEXT_MISMATCH";
24
+ throw error;
25
+ }
26
+ return transaction;
27
+ }
28
+
29
+ const TERMINAL_STATUSES = new Set(["COMMITTED", "ROLLED_BACK", "ABORTED"]);
30
+
19
31
  function transactionPath(transactionId) {
20
32
  return `${TRANSACTION_ROOT}/${transactionId}`;
21
33
  }
@@ -33,7 +45,7 @@ export async function findIncompleteTransactions(target) {
33
45
  if (!entry.isDirectory()) continue;
34
46
  try {
35
47
  const manifest = JSON.parse(await readFile(path.join(root, entry.name, "manifest.json"), "utf8"));
36
- if (manifest.status !== "COMMITTED") found.push(manifest);
48
+ if (!TERMINAL_STATUSES.has(manifest.status)) found.push(manifest);
37
49
  } catch {
38
50
  found.push({ transactionId: entry.name, status: "ABANDONED", malformed: true });
39
51
  }
@@ -50,6 +62,7 @@ async function rollbackPublishedWrites(target, root, manifest) {
50
62
  for (const entry of writes) {
51
63
  if (!entry || typeof entry !== "object") continue;
52
64
  const relativePath = writePath(entry);
65
+ await assertSafePath(target, relativePath);
53
66
  if (entry.kind === "APPEND") {
54
67
  if (!entry.appendStarted) continue;
55
68
  const destination = ensureWithin(target, relativePath);
@@ -82,11 +95,15 @@ export async function recoverIncompleteTransactions(target) {
82
95
  try {
83
96
  const manifest = JSON.parse(await readFile(ensureWithin(target, manifestPath), "utf8"));
84
97
  if (manifest.status !== "COMMITTING") continue;
85
- await rollbackPublishedWrites(target, root, manifest);
86
- manifest.status = "ROLLED_BACK";
87
- manifest.recoveredAt = new Date().toISOString();
88
- await writeManifest(target, manifestPath, manifest);
89
- recovered.push({ transactionId: manifest.transactionId, status: manifest.status });
98
+ await withTaskLock(target, manifest.lockTaskId ?? manifest.taskId ?? "transaction-recovery", "recover-transaction", async () => {
99
+ const current = JSON.parse(await readFile(ensureWithin(target, manifestPath), "utf8"));
100
+ if (current.status !== "COMMITTING") return;
101
+ await rollbackPublishedWrites(target, root, current);
102
+ current.status = "ROLLED_BACK";
103
+ current.recoveredAt = new Date().toISOString();
104
+ await writeManifest(target, manifestPath, current);
105
+ recovered.push({ transactionId: current.transactionId, status: current.status });
106
+ });
90
107
  } catch (error) {
91
108
  recovered.push({ transactionId: entry.name, status: "RECOVERY_FAILED", error: error.message });
92
109
  }
@@ -103,7 +120,8 @@ export async function withTaskTransaction({
103
120
  recordCommitEvent = false,
104
121
  } = {}, callback) {
105
122
  if (!target || !taskId) throw new Error("target and taskId are required for a task transaction");
106
- const activeTransaction = getActiveTaskTransaction();
123
+ target = await realpath(target);
124
+ const activeTransaction = await getTaskTransaction(target);
107
125
  if (activeTransaction) {
108
126
  if (activeTransaction.taskId !== taskId) {
109
127
  throw new Error(`cannot nest task transaction for ${taskId} inside ${activeTransaction.taskId}`);
@@ -118,9 +136,10 @@ export async function withTaskTransaction({
118
136
  const manifestPath = `${root}/manifest.json`;
119
137
  await assertSafePath(target, manifestPath);
120
138
  await mkdir(ensureWithin(target, stageRoot), { recursive: true });
121
- const manifest = { schemaVersion: 1, transactionId, taskId, operation, lockId: lock.lockId, startedAt: new Date().toISOString(), status: "STAGING", writes: [] };
139
+ const manifest = { schemaVersion: 1, transactionId, taskId, lockTaskId, operation, lockId: lock.lockId, startedAt: new Date().toISOString(), status: "STAGING", writes: [] };
122
140
  await writeManifest(target, manifestPath, manifest);
123
141
  const tx = {
142
+ target,
124
143
  transactionId,
125
144
  taskId,
126
145
  lock,
@@ -274,7 +293,9 @@ export async function withTaskTransaction({
274
293
  manifest.rollbackError = { message: rollbackError.message };
275
294
  }
276
295
  } else {
277
- manifest.status = "ABANDONED";
296
+ // Staging has not published any writes; retain the diagnostic record
297
+ // without requiring recovery of a project that was never modified.
298
+ manifest.status = "ABORTED";
278
299
  }
279
300
  manifest.failedAt = new Date().toISOString();
280
301
  manifest.error = { message: error.message };
@@ -19,7 +19,7 @@ import { assertJsonBytes } from "./json-safety.js";
19
19
  import { assertCoverageList } from "./coverage.js";
20
20
  import { canonicalFingerprint } from "./artifacts.js";
21
21
  import { taskArtifactPath } from "./task-paths.js";
22
- import { getActiveTaskTransaction, withTaskTransaction } from "./transaction.js";
22
+ import { getTaskTransaction, withTaskTransaction } from "./transaction.js";
23
23
 
24
24
  export const WORK_STATE_PATH = ".forgeloop/work-state.json";
25
25
 
@@ -245,7 +245,7 @@ export async function readWorkState(target, options = {}) {
245
245
  const statePath = ensureWithin(target, relPath);
246
246
  let state;
247
247
  try {
248
- const transaction = getActiveTaskTransaction();
248
+ const transaction = (await getTaskTransaction(target));
249
249
  const staged = transaction ? await transaction.readText(relPath) : null;
250
250
  if (staged === null) {
251
251
  if (!(await fileExists(statePath))) return null;
@@ -290,7 +290,7 @@ export async function writeWorkState(target, state, options = {}) {
290
290
  await validateStoredState(state, packageRoot);
291
291
  await assertSafePath(target, relPath);
292
292
  const serialized = `${JSON.stringify(state, null, 2)}\n`;
293
- const transaction = getActiveTaskTransaction();
293
+ const transaction = (await getTaskTransaction(target));
294
294
  if (!dryRun && transaction) {
295
295
  await transaction.stageText(relPath, serialized);
296
296
  } else {
@@ -306,13 +306,22 @@ export async function mutateWorkState(target, { expectedRevision, packageRoot =
306
306
  error.code = "E_STATE_REVISION_CONFLICT";
307
307
  throw error;
308
308
  }
309
- if (!getActiveTaskTransaction()) {
310
- return withTaskTransaction({
311
- target,
312
- taskId: taskId ?? "legacy-work-state",
313
- lockTaskId: taskId ?? "legacy-work-state",
314
- operation: "mutate-work-state",
315
- }, async () => mutateWorkState(target, { expectedRevision, packageRoot, taskId, statePath }, updater));
309
+ if (!(await getTaskTransaction(target))) {
310
+ try {
311
+ return await withTaskTransaction({
312
+ target,
313
+ taskId: taskId ?? "legacy-work-state",
314
+ lockTaskId: taskId ?? "legacy-work-state",
315
+ operation: "mutate-work-state",
316
+ }, async () => mutateWorkState(target, { expectedRevision, packageRoot, taskId, statePath }, updater));
317
+ } catch (error) {
318
+ if (error?.code === "E_TASK_LOCKED") {
319
+ const revisionError = new WorkStateError("Work state revision is being mutated concurrently");
320
+ revisionError.code = "E_STATE_REVISION_CONFLICT";
321
+ throw revisionError;
322
+ }
323
+ throw error;
324
+ }
316
325
  }
317
326
  const current = await readWorkState(target, { packageRoot, taskId, statePath });
318
327
  if (!current || (current.revision ?? 0) !== expectedRevision) {
@@ -331,7 +340,7 @@ export async function mutateWorkState(target, { expectedRevision, packageRoot =
331
340
  }
332
341
 
333
342
  export async function initializeWorkState(target, state, { packageRoot = getPackageRoot(), taskId, statePath } = {}) {
334
- if (getActiveTaskTransaction()) {
343
+ if ((await getTaskTransaction(target))) {
335
344
  const current = await readWorkState(target, { packageRoot, taskId, statePath });
336
345
  if (current) return current;
337
346
  await writeWorkState(target, state, { packageRoot, taskId, statePath });
@@ -108,6 +108,14 @@ export interface ForgeLoopAdvisoryContextProvider {
108
108
 
109
109
  export type ForgeLoopAdvisoryContextProviderFactory = () => ForgeLoopAdvisoryContextProvider | Promise<ForgeLoopAdvisoryContextProvider>;
110
110
 
111
+ /** Options for the optional, host-injected Ripwire advisory adapter. */
112
+ export interface ForgeLoopRipwireProviderOptions {
113
+ /** Absolute path selected by the host; ForgeLoop does not discover it. */
114
+ executablePath: string;
115
+ /** Exact version string qualified by the host and checked lazily at recall time. */
116
+ expectedVersion: string;
117
+ }
118
+
111
119
  export interface ForgeLoopNormalizedAdvisoryContextResult {
112
120
  provider: {
113
121
  id: string;
@@ -129,11 +137,12 @@ export interface ForgeLoopStructuralQualityProviderInput {
129
137
  maxOutputBytes: number;
130
138
  }
131
139
 
132
- export interface ForgeLoopStructuralQualityProvider {
133
- id: string;
140
+ export type ForgeLoopStructuralQualityProvider = { id: string } & ({
141
+ observe(input: ForgeLoopStructuralQualityProviderInput): Promise<Record<string, unknown>> | Record<string, unknown>;
142
+ } | {
134
143
  detect(input: ForgeLoopStructuralQualityProviderInput): Promise<Record<string, unknown>> | Record<string, unknown>;
135
144
  scan(input: ForgeLoopStructuralQualityProviderInput): Promise<Record<string, unknown>> | Record<string, unknown>;
136
- }
145
+ });
137
146
 
138
147
  export type ForgeLoopStructuralQualityProviderFactory = (input: ForgeLoopStructuralQualityProviderInput) => ForgeLoopStructuralQualityProvider | Promise<ForgeLoopStructuralQualityProvider>;
139
148
 
@@ -268,6 +277,9 @@ export declare function recallAdvisoryContext(input: {
268
277
  timeoutMs?: number;
269
278
  runtimeContext?: ForgeLoopContext | Record<string, unknown>;
270
279
  }): Promise<ForgeLoopNormalizedAdvisoryContextResult>;
280
+ export declare function createRipwireAdvisoryContextProvider(
281
+ options: ForgeLoopRipwireProviderOptions,
282
+ ): ForgeLoopAdvisoryContextProvider;
271
283
  export declare const ADVISORY_CONTEXT_LIMITS: Readonly<Record<string, number>>;
272
284
  export declare const ADVISORY_CONTEXT_TRUST: Readonly<Record<string, unknown>>;
273
285
  export declare function normalizeAdvisoryRecallOptions(input?: Partial<ForgeLoopAdvisoryRecallOptions>): ForgeLoopAdvisoryRecallOptions;
@@ -314,3 +326,18 @@ export declare function resolveHandoffAcceptance(input: {
314
326
  acceptedAt?: string;
315
327
  reasonCodes?: readonly string[];
316
328
  };
329
+
330
+ export declare const E_VERIFICATION_EXECUTION_INVALID: "E_VERIFICATION_EXECUTION_INVALID";
331
+ export declare const E_VERIFICATION_ISOLATION_UNAVAILABLE: "E_VERIFICATION_ISOLATION_UNAVAILABLE";
332
+ export declare function createStructuralQualityProviderRegistry(options?: {
333
+ providers?: Readonly<Record<string, ForgeLoopStructuralQualityProvider | ForgeLoopStructuralQualityProviderFactory>>;
334
+ builtIns?: Readonly<Record<string, ForgeLoopStructuralQualityProvider | ForgeLoopStructuralQualityProviderFactory>>;
335
+ }): { resolve(name: string, input: ForgeLoopStructuralQualityProviderInput): Promise<ForgeLoopStructuralQualityProvider> };
336
+ export declare function resolveStructuralQualityProvider(options: {
337
+ providerName?: string;
338
+ target: string;
339
+ taskId: string;
340
+ timeoutMs?: number;
341
+ maxOutputBytes?: number;
342
+ runtimeContext?: ForgeLoopContext;
343
+ }): Promise<ForgeLoopStructuralQualityProvider>;
@@ -31,6 +31,7 @@ export function getForgeLoopPackageVersion() {
31
31
  }
32
32
 
33
33
  export {
34
+ FORGELOOP_INTEGRATION_RUNTIME_VERSION,
34
35
  executeForgeLoopCommand,
35
36
  validateForgeLoopCommandInput,
36
37
  defaultCommandInputValues,
@@ -80,6 +81,7 @@ export {
80
81
  VERIFICATION_ISOLATION_MODES,
81
82
  } from "./core/verification-execution.js";
82
83
  export { recallAdvisoryContext } from "./core/advisory-context/service.js";
84
+ export { createRipwireAdvisoryContextProvider } from "./adapters/ripwire/provider.js";
83
85
  export {
84
86
  ADVISORY_CONTEXT_LIMITS,
85
87
  ADVISORY_CONTEXT_TRUST,
@@ -1,23 +0,0 @@
1
- import { CLI_COMMAND_DEFINITIONS } from "./cli-command-definitions.js";
2
-
3
- /**
4
- * Canonical metadata for all 43 ForgeLoop CLI commands derived directly
5
- * from CLI_COMMAND_DEFINITIONS to guarantee zero divergence.
6
- */
7
- export const CLI_COMMAND_METADATA = Object.freeze(
8
- Object.fromEntries(
9
- Object.entries(CLI_COMMAND_DEFINITIONS).map(([name, def]) => [
10
- name,
11
- Object.freeze({
12
- name: def.name,
13
- category: def.category,
14
- mutation: def.mutation,
15
- options: Object.keys(def.options),
16
- writes: def.writes,
17
- removes: def.removes,
18
- mayExecuteExternalProcess: def.mayExecuteExternalProcess,
19
- description: def.description,
20
- }),
21
- ]),
22
- ),
23
- );
@@ -1,55 +0,0 @@
1
- export const BLOCKING_REASON_CODES = Object.freeze([
2
- "REAL_BUSINESS_FACT_REQUIRED",
3
- "SENSITIVE_VALUE_REQUIRED",
4
- "EXTERNAL_AUTHORITY_REQUIRED",
5
- "IRREVERSIBLE_DECISION_REQUIRED",
6
- "REGULATED_CLAIM_REQUIRED",
7
- "DESTRUCTIVE_ACTION_REQUIRED",
8
- ]);
9
-
10
- const BLOCKING_RULES = Object.freeze([
11
- ["realBusinessFact", "REAL_BUSINESS_FACT_REQUIRED"],
12
- ["sensitive", "SENSITIVE_VALUE_REQUIRED"],
13
- ["authoritative", "EXTERNAL_AUTHORITY_REQUIRED"],
14
- ["external", "EXTERNAL_AUTHORITY_REQUIRED"],
15
- ["irreversible", "IRREVERSIBLE_DECISION_REQUIRED"],
16
- ["regulatedClaim", "REGULATED_CLAIM_REQUIRED"],
17
- ["destructive", "DESTRUCTIVE_ACTION_REQUIRED"],
18
- ]);
19
-
20
- function blockingDecision(reasonCode) {
21
- return {
22
- classification: "BLOCKING",
23
- reasonCode,
24
- blockingReason: reasonCode,
25
- };
26
- }
27
-
28
- export function classifyDecision(flags = {}) {
29
- if (!flags || typeof flags !== "object" || Array.isArray(flags)) {
30
- return { classification: "NON_BLOCKING", reasonCode: "SAFE_REVERSIBLE_LOCAL_DEFAULT", blockingReason: null };
31
- }
32
-
33
- for (const [flag, reasonCode] of BLOCKING_RULES) {
34
- if (flags[flag] === true) return blockingDecision(reasonCode);
35
- }
36
- if (flags.reversible === false || flags.local === false) {
37
- return blockingDecision(
38
- flags.reversible === false
39
- ? "IRREVERSIBLE_DECISION_REQUIRED"
40
- : "REAL_BUSINESS_FACT_REQUIRED",
41
- );
42
- }
43
-
44
- return {
45
- classification: "NON_BLOCKING",
46
- reasonCode: "SAFE_REVERSIBLE_LOCAL_DEFAULT",
47
- blockingReason: null,
48
- };
49
- }
50
-
51
- export function canAskUser(decision) {
52
- return decision?.classification === "BLOCKING"
53
- && BLOCKING_REASON_CODES.includes(decision.reasonCode)
54
- && decision.blockingReason === decision.reasonCode;
55
- }
package/src/core/gates.js DELETED
@@ -1,57 +0,0 @@
1
- import { PROTOCOL_VERSION } from "./protocol.js";
2
- import path from "node:path";
3
-
4
- export const GATE_SCHEMA_VERSION = 1;
5
- export const GATE_STATUSES = Object.freeze(["satisfied", "unverified", "blocked"]);
6
-
7
- function stringArray(value, label) {
8
- if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim() === "")) {
9
- throw new Error(`${label} must be an array of non-empty strings`);
10
- }
11
- return [...value];
12
- }
13
-
14
- function artifactList(value) {
15
- if (!Array.isArray(value)) throw new Error("Gate artifacts must be an array");
16
- return value.map((artifact) => {
17
- if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) {
18
- throw new Error("Gate artifact entries must be objects");
19
- }
20
- if (typeof artifact.path !== "string" || !artifact.path) throw new Error("Gate artifact path is required");
21
- const portable = artifact.path.replaceAll("\\", "/");
22
- const normalized = path.posix.normalize(portable);
23
- if (portable.startsWith("/") || /^[A-Za-z]:\//.test(portable) || normalized === ".." || normalized.startsWith("../")) {
24
- throw new Error(`Gate artifact path escapes the target: ${artifact.path}`);
25
- }
26
- if (typeof artifact.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(artifact.sha256)) {
27
- throw new Error(`Gate artifact hash is invalid: ${artifact.path}`);
28
- }
29
- return { path: normalized.replace(/^\.\//, ""), sha256: artifact.sha256 };
30
- });
31
- }
32
-
33
- export function createGate(input = {}) {
34
- if (typeof input.taskId !== "string" || !input.taskId) throw new Error("Gate taskId is required");
35
- if (typeof input.gate !== "string" || !input.gate) throw new Error("Gate name is required");
36
- if (!GATE_STATUSES.includes(input.status)) throw new Error(`Unknown gate status: ${input.status}`);
37
- const artifacts = artifactList(input.artifacts ?? []);
38
- return {
39
- schemaVersion: GATE_SCHEMA_VERSION,
40
- protocolVersion: PROTOCOL_VERSION,
41
- taskId: input.taskId,
42
- gate: input.gate,
43
- status: input.status,
44
- requiredBy: stringArray(input.requiredBy ?? [], "Gate requiredBy"),
45
- artifacts,
46
- decisions: stringArray(input.decisions ?? [], "Gate decisions"),
47
- unknowns: stringArray(input.unknowns ?? [], "Gate unknowns"),
48
- approvedAssumptions: stringArray(input.approvedAssumptions ?? [], "Gate approvedAssumptions"),
49
- evidence: Array.isArray(input.evidence) ? input.evidence.map((item) => ({ ...item })) : [],
50
- };
51
- }
52
-
53
- import { taskGatePath } from "./task-paths.js";
54
-
55
- export function requiredGatePaths(gates, { taskId } = {}) {
56
- return [...new Set(gates)].sort().map((gate) => (taskId ? taskGatePath(taskId, gate) : `.forgeloop/gates/${gate}.json`));
57
- }
@@ -1,151 +0,0 @@
1
- import { canAskUser } from "./decision-classification.js";
2
-
3
- export const QUESTION_SOURCES = Object.freeze([
4
- "USER_REQUIREMENT",
5
- "FORGELOOP_BLOCKING_DECISION",
6
- "EXTERNAL_WORKFLOW_POLICY",
7
- "MODEL_PREFERENCE",
8
- ]);
9
-
10
- export const EXTERNAL_WORKFLOW_REASON_CODES = Object.freeze({
11
- APPROVAL_CONFLICT: "E_EXTERNAL_WORKFLOW_APPROVAL_CONFLICT",
12
- BLOCKS_NON_BLOCKING: "E_EXTERNAL_WORKFLOW_BLOCKS_NON_BLOCKING",
13
- REQUIRES_USER_GATE: "E_EXTERNAL_WORKFLOW_REQUIRES_USER_GATE",
14
- });
15
-
16
- const DECISION_CLASSIFICATIONS = new Set(["NON_BLOCKING", "BLOCKING"]);
17
-
18
- function normalizeDecision(value) {
19
- if (typeof value === "string") {
20
- if (!DECISION_CLASSIFICATIONS.has(value)) {
21
- throw new TypeError(`forgeLoopDecision classification is invalid: ${value}`);
22
- }
23
- return {
24
- classification: value,
25
- reasonCode: value === "NON_BLOCKING" ? "SAFE_REVERSIBLE_LOCAL_DEFAULT" : null,
26
- blockingReason: null,
27
- };
28
- }
29
- if (!value || typeof value !== "object" || Array.isArray(value)) {
30
- throw new TypeError("forgeLoopDecision must be a decision object or classification");
31
- }
32
- if (!DECISION_CLASSIFICATIONS.has(value.classification)) {
33
- throw new TypeError(`forgeLoopDecision classification is invalid: ${value.classification}`);
34
- }
35
- return {
36
- classification: value.classification,
37
- reasonCode: value.reasonCode ?? null,
38
- blockingReason: value.blockingReason ?? null,
39
- };
40
- }
41
-
42
- function assertBoolean(value, label) {
43
- if (typeof value !== "boolean") throw new TypeError(`${label} must be a boolean`);
44
- }
45
-
46
- function workflowName(value) {
47
- if (value === undefined) return "external-workflow";
48
- if (typeof value !== "string" || value.trim() === "") {
49
- throw new TypeError("workflowName must be a non-empty string");
50
- }
51
- return value;
52
- }
53
-
54
- function compatibleResult({ decision, name, autonomousMode, requiresUserApproval }) {
55
- return {
56
- status: "COMPATIBLE",
57
- compatible: true,
58
- autonomousMode,
59
- workflowName: name,
60
- forgeLoopClassification: decision.classification,
61
- requiresUserApproval,
62
- questionSource: null,
63
- canAskUser: false,
64
- requiresUserDecision: false,
65
- recordAs: null,
66
- addsUnresolvedDecision: false,
67
- reasonCodes: [],
68
- };
69
- }
70
-
71
- /**
72
- * Evaluates an external workflow policy after ForgeLoop classifies the decision.
73
- * The helper records a policy conflict instead of turning a reversible local
74
- * default into a fake user blocker or mutating the decision classifier.
75
- */
76
- export function classifyWorkflowCompatibility({
77
- forgeLoopDecision,
78
- workflowName: requestedWorkflowName,
79
- requiresUserApproval = false,
80
- autonomousMode,
81
- } = {}) {
82
- assertBoolean(autonomousMode, "autonomousMode");
83
- assertBoolean(requiresUserApproval, "requiresUserApproval");
84
-
85
- const decision = normalizeDecision(forgeLoopDecision);
86
- const name = workflowName(requestedWorkflowName);
87
-
88
- if (!requiresUserApproval) {
89
- return compatibleResult({
90
- decision,
91
- name,
92
- autonomousMode,
93
- requiresUserApproval,
94
- });
95
- }
96
-
97
- if (decision.classification === "NON_BLOCKING") {
98
- if (!autonomousMode) {
99
- return {
100
- status: "INTERACTIVE_APPROVAL",
101
- compatible: true,
102
- autonomousMode,
103
- workflowName: name,
104
- forgeLoopClassification: decision.classification,
105
- requiresUserApproval,
106
- questionSource: "EXTERNAL_WORKFLOW_POLICY",
107
- canAskUser: true,
108
- requiresUserDecision: true,
109
- recordAs: null,
110
- addsUnresolvedDecision: true,
111
- reasonCodes: [EXTERNAL_WORKFLOW_REASON_CODES.REQUIRES_USER_GATE],
112
- };
113
- }
114
-
115
- return {
116
- status: "WORKFLOW_CONFLICT",
117
- compatible: false,
118
- autonomousMode,
119
- workflowName: name,
120
- forgeLoopClassification: decision.classification,
121
- requiresUserApproval,
122
- questionSource: "EXTERNAL_WORKFLOW_POLICY",
123
- canAskUser: false,
124
- requiresUserDecision: false,
125
- recordAs: "WORKFLOW_CONFLICT",
126
- addsUnresolvedDecision: false,
127
- reasonCodes: [
128
- EXTERNAL_WORKFLOW_REASON_CODES.APPROVAL_CONFLICT,
129
- EXTERNAL_WORKFLOW_REASON_CODES.BLOCKS_NON_BLOCKING,
130
- EXTERNAL_WORKFLOW_REASON_CODES.REQUIRES_USER_GATE,
131
- ],
132
- };
133
- }
134
-
135
- const legitimateBlockingQuestion = canAskUser(decision);
136
- return {
137
- ...compatibleResult({
138
- decision,
139
- name,
140
- autonomousMode,
141
- requiresUserApproval,
142
- }),
143
- questionSource: legitimateBlockingQuestion ? "FORGELOOP_BLOCKING_DECISION" : null,
144
- canAskUser: legitimateBlockingQuestion,
145
- requiresUserDecision: legitimateBlockingQuestion,
146
- addsUnresolvedDecision: legitimateBlockingQuestion,
147
- reasonCodes: legitimateBlockingQuestion
148
- ? [EXTERNAL_WORKFLOW_REASON_CODES.REQUIRES_USER_GATE]
149
- : [],
150
- };
151
- }