@cassiomc1/forgeloop 1.2.1 → 1.2.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 (65) hide show
  1. package/.cursor/rules/project-loop.mdc +1 -1
  2. package/.github/copilot-instructions.md +1 -1
  3. package/AGENTS.md +1 -1
  4. package/CLAUDE.md +1 -1
  5. package/DOCS_INDEX.md +3 -0
  6. package/ENG/design-code-eng.md +59 -0
  7. package/ENG/premium-sites-studio-eng.md +28 -0
  8. package/LOOP_ENGINEERING.md +23 -0
  9. package/LOOP_SYSTEM_DESIGN.md +9 -5
  10. package/ORCHESTRATOR_INTEGRATION.md +37 -4
  11. package/PROTOCOL_INTEGRATION.md +13 -0
  12. package/README.md +34 -2
  13. package/TERMINOLOGY.md +10 -0
  14. package/THIRD_PARTY_NOTICES.md +34 -0
  15. package/THREAT_MODEL.md +12 -1
  16. package/docs/ARTIFACT_REFERENCE.md +150 -0
  17. package/docs/CLI_REFERENCE.md +263 -30
  18. package/docs/CROSS_HARNESS_CONTINUITY.md +1 -0
  19. package/docs/DOCUMENTATION_GUIDE.md +41 -4
  20. package/docs/GETTING_STARTED.md +9 -4
  21. package/docs/RECIPES.md +31 -1
  22. package/docs/TROUBLESHOOTING.md +191 -0
  23. package/package.json +1 -1
  24. package/schemas/policy-baseline.schema.json +26 -0
  25. package/schemas/policy-discovery.schema.json +45 -0
  26. package/schemas/policy-lock.schema.json +16 -0
  27. package/schemas/policy-rules.schema.json +48 -0
  28. package/schemas/policy-snapshot.schema.json +16 -0
  29. package/src/cli.js +69 -1
  30. package/src/commands/baseline.js +120 -0
  31. package/src/commands/init.js +304 -6
  32. package/src/commands/policy-diff.js +51 -0
  33. package/src/commands/policy-discover.js +42 -0
  34. package/src/commands/policy-status.js +33 -0
  35. package/src/commands/profile-interview.js +50 -0
  36. package/src/commands/reconcile-closure.js +49 -0
  37. package/src/commands/rule-verify.js +36 -0
  38. package/src/commands/validate-receipt.js +38 -3
  39. package/src/core/artifact-registry.js +60 -0
  40. package/src/core/audit.js +24 -0
  41. package/src/core/cli-command-definitions.js +114 -7
  42. package/src/core/cli-metadata.js +1 -1
  43. package/src/core/completion-artifacts.js +29 -3
  44. package/src/core/completion.js +101 -10
  45. package/src/core/error-codes.js +227 -0
  46. package/src/core/events.js +22 -0
  47. package/src/core/execution-prerequisites.js +38 -20
  48. package/src/core/execution.js +20 -3
  49. package/src/core/native-adapters.js +14 -4
  50. package/src/core/next-action-model.js +9 -0
  51. package/src/core/next-action.js +128 -82
  52. package/src/core/policy-adapters.js +276 -0
  53. package/src/core/policy-baseline.js +144 -0
  54. package/src/core/policy-diff.js +133 -0
  55. package/src/core/policy-discovery.js +225 -0
  56. package/src/core/policy-engine.js +533 -0
  57. package/src/core/policy-mutation.js +139 -0
  58. package/src/core/preflight-consistency.js +23 -15
  59. package/src/core/preflight.js +65 -1
  60. package/src/core/reconcile-closure.js +173 -0
  61. package/src/core/schema-validation.js +6 -0
  62. package/src/core/task-context.js +11 -0
  63. package/src/core/task-discovery.js +67 -1
  64. package/src/core/task-paths.js +9 -0
  65. package/src/core/templates.js +5 -0
@@ -86,64 +86,72 @@ export async function validateReadyProtocolConsistency({
86
86
  persisted,
87
87
  current = null,
88
88
  evaluateCurrentPreflight,
89
+ taskId = null,
89
90
  } = {}) {
90
91
  if (persisted?.status !== "READY") return [];
91
- const result = current ?? await evaluateCurrentPreflight({ target, packageRoot });
92
+ const result = current ?? await evaluateCurrentPreflight({ target, packageRoot, taskId });
92
93
  const errors = [...validatePersistedPreflight(persisted, result)];
93
94
 
95
+ const stateRel = taskId ? taskArtifactPath(taskId, "state") : ARTIFACT_PATHS.state;
96
+ const eventsRel = taskId ? taskArtifactPath(taskId, "events") : ARTIFACT_PATHS.events;
97
+ const preflightRel = taskId ? taskArtifactPath(taskId, "preflight") : ARTIFACT_PATHS.preflight;
98
+ const contractRel = taskId ? taskArtifactPath(taskId, "contract") : ARTIFACT_PATHS.contract;
99
+ const routeRel = taskId ? taskArtifactPath(taskId, "route") : ARTIFACT_PATHS.route;
100
+ const gatesRel = taskId ? taskArtifactPath(taskId, "gates") : ARTIFACT_PATHS.gates;
101
+
94
102
  let state = null;
95
103
  try {
96
- state = await readWorkState(target, packageRoot);
104
+ state = await readWorkState(target, { packageRoot, taskId });
97
105
  } catch (error) {
98
- errors.push(issue("E_STATE_INVALID", error.message, [ARTIFACT_PATHS.state]));
106
+ errors.push(issue("E_STATE_INVALID", error.message, [stateRel]));
99
107
  }
100
108
  if (!state) {
101
109
  errors.push(issue(
102
110
  "E_STATE_MISSING_AFTER_PREFLIGHT_READY",
103
111
  "A persisted READY preflight must have a resumable work-state checkpoint",
104
- [ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.state],
112
+ [preflightRel, stateRel],
105
113
  ));
106
114
  } else {
107
115
  if (state.taskId !== persisted.taskId) {
108
- errors.push(issue("E_STATE_TASK_MISMATCH", "The resumable checkpoint does not belong to the READY preflight task", [ARTIFACT_PATHS.state, ARTIFACT_PATHS.preflight]));
116
+ errors.push(issue("E_STATE_TASK_MISMATCH", "The resumable checkpoint does not belong to the READY preflight task", [stateRel, preflightRel]));
109
117
  }
110
118
  if (state.contractFingerprint !== result.fingerprints.contract) {
111
- errors.push(issue("E_CONTRACT_STALE", "The resumable checkpoint does not match the READY contract fingerprint", [ARTIFACT_PATHS.state, ARTIFACT_PATHS.contract]));
119
+ errors.push(issue("E_CONTRACT_STALE", "The resumable checkpoint does not match the READY contract fingerprint", [stateRel, contractRel]));
112
120
  }
113
121
  if (state.routeFingerprint !== result.fingerprints.routing) {
114
- errors.push(issue("E_ROUTE_STALE", "The resumable checkpoint does not match the READY routing fingerprint", [ARTIFACT_PATHS.state, ARTIFACT_PATHS.route]));
122
+ errors.push(issue("E_ROUTE_STALE", "The resumable checkpoint does not match the READY routing fingerprint", [stateRel, routeRel]));
115
123
  }
116
124
  if (JSON.stringify(state.selectedGuides) !== JSON.stringify(result.routing.guides)) {
117
- errors.push(issue("E_ROUTE_GUIDE_MISMATCH", "The resumable checkpoint guides do not match the READY routing result", [ARTIFACT_PATHS.state, ARTIFACT_PATHS.route]));
125
+ errors.push(issue("E_ROUTE_GUIDE_MISMATCH", "The resumable checkpoint guides do not match the READY routing result", [stateRel, routeRel]));
118
126
  }
119
127
  if (!sameStringSet(state.requiredGates ?? [], persisted.requiredGates)
120
128
  || !sameStringSet(state.satisfiedGates ?? [], persisted.satisfiedGates)) {
121
- errors.push(issue("E_PREFLIGHT_GATES_STALE", "The resumable checkpoint gate sets do not match the READY preflight", [ARTIFACT_PATHS.state, ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.gates]));
129
+ errors.push(issue("E_PREFLIGHT_GATES_STALE", "The resumable checkpoint gate sets do not match the READY preflight", [stateRel, preflightRel, gatesRel]));
122
130
  }
123
131
  }
124
132
 
125
- const ledger = await validateEventLedger(target, packageRoot);
133
+ const ledger = await validateEventLedger(target, packageRoot, { taskId });
126
134
  if (!ledger.valid) {
127
- errors.push(...ledger.errors.map((error) => issue(error.code ?? "E_EVENT_INVALID", error.message, [ARTIFACT_PATHS.events])));
135
+ errors.push(...ledger.errors.map((error) => issue(error.code ?? "E_EVENT_INVALID", error.message, [eventsRel])));
128
136
  }
129
137
  const events = ledger.events ?? [];
130
138
  for (const requiredEvent of ["CONTRACT_VALIDATED", "ROUTE_VALIDATED"]) {
131
139
  if (!events.some((event) => event.event === requiredEvent && event.taskId === persisted.taskId)) {
132
- errors.push(issue("E_PREFLIGHT_EVENT_MISSING", `READY preflight is missing lifecycle event: ${requiredEvent}`, [ARTIFACT_PATHS.events, ARTIFACT_PATHS.preflight]));
140
+ errors.push(issue("E_PREFLIGHT_EVENT_MISSING", `READY preflight is missing lifecycle event: ${requiredEvent}`, [eventsRel, preflightRel]));
133
141
  }
134
142
  }
135
143
  for (const gate of persisted.satisfiedGates ?? []) {
136
144
  if (!events.some((event) => event.event === "GATE_SATISFIED"
137
145
  && event.taskId === persisted.taskId
138
146
  && event.details?.gate === gate)) {
139
- errors.push(issue("E_PREFLIGHT_GATE_EVENT_MISSING", `READY preflight is missing lifecycle gate event: ${gate}`, [ARTIFACT_PATHS.events, `${ARTIFACT_PATHS.gates}/${gate}.json`]));
147
+ errors.push(issue("E_PREFLIGHT_GATE_EVENT_MISSING", `READY preflight is missing lifecycle gate event: ${gate}`, [eventsRel, `${gatesRel}/${gate}.json`]));
140
148
  }
141
149
  }
142
150
  const readyEvents = events.filter((event) => event.event === "PREFLIGHT_READY" && event.taskId === persisted.taskId);
143
151
  if (readyEvents.length === 0) {
144
- errors.push(issue("E_PREFLIGHT_READY_EVENT_MISSING", "Persisted READY preflight is missing the matching PREFLIGHT_READY lifecycle event", [ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.events]));
152
+ errors.push(issue("E_PREFLIGHT_READY_EVENT_MISSING", "Persisted READY preflight is missing the matching PREFLIGHT_READY lifecycle event", [preflightRel, eventsRel]));
145
153
  } else if (!readyEvents.some((event) => sameReadyPreflightEvent(event, result))) {
146
- errors.push(issue("E_PREFLIGHT_READY_EVENT_MISMATCH", "PREFLIGHT_READY lifecycle details do not match the persisted READY preflight", [ARTIFACT_PATHS.preflight, ARTIFACT_PATHS.events]));
154
+ errors.push(issue("E_PREFLIGHT_READY_EVENT_MISMATCH", "PREFLIGHT_READY lifecycle details do not match the persisted READY preflight", [preflightRel, eventsRel]));
147
155
  }
148
156
  return sortIssues(errors);
149
157
  }
@@ -27,7 +27,7 @@ import {
27
27
  import { PROFILE_PATH } from "./target-layout.js";
28
28
  import { readPersistedRoute } from "./route-artifact.js";
29
29
  import { validateEventLedger } from "./events.js";
30
- import { taskArtifactPath } from "./task-paths.js";
30
+ import { PROJECT_ARTIFACT_PATHS, taskArtifactPath } from "./task-paths.js";
31
31
 
32
32
  const PREVIEW_DECISION_LIMIT = 10;
33
33
  const PREVIEW_DECISION_MAX_LENGTH = 240;
@@ -91,6 +91,24 @@ export async function evaluatePreflight({ target, packageRoot, strict = false, t
91
91
  errors.push(issue("E_CONTRACT_STALE", "work-state references a different contract", [contractRelPath, stateRelPath]));
92
92
  }
93
93
 
94
+ const { detectPolicyCapability } = await import("./policy-engine.js");
95
+ const policyCapability = await detectPolicyCapability(target, packageRoot);
96
+ if (policyCapability === "INVALID") {
97
+ errors.push(issue(
98
+ "E_POLICY_INVALID",
99
+ "Executable policy artifacts are present but invalid.",
100
+ [
101
+ PROJECT_ARTIFACT_PATHS.policyRules,
102
+ PROJECT_ARTIFACT_PATHS.policyBaseline,
103
+ PROJECT_ARTIFACT_PATHS.policyDiscovery,
104
+ PROJECT_ARTIFACT_PATHS.policyLock,
105
+ ],
106
+ {
107
+ next: "Repair the invalid policy artifact and rerun forgeloop preflight.",
108
+ },
109
+ ));
110
+ }
111
+
94
112
  const sortedErrors = sortIssues(errors);
95
113
  const effectiveTaskId = taskId ?? contract?.value?.taskId ?? state?.taskId ?? "unknown";
96
114
  return {
@@ -178,6 +196,52 @@ export async function runPreflight({
178
196
  }
179
197
 
180
198
  if (result.taskId !== "unknown") {
199
+ const {
200
+ detectPolicyCapability,
201
+ loadEffectiveRules,
202
+ readBaseline,
203
+ computePolicyLockData,
204
+ readTaskPolicySnapshot,
205
+ writeTaskPolicySnapshot,
206
+ } = await import("./policy-engine.js");
207
+
208
+ const policyCapability = await detectPolicyCapability(target, packageRoot);
209
+ if (policyCapability === "INVALID") {
210
+ throw preflightError(
211
+ "E_POLICY_INVALID",
212
+ "Executable policy artifacts are present but invalid.",
213
+ [
214
+ PROJECT_ARTIFACT_PATHS.policyRules,
215
+ PROJECT_ARTIFACT_PATHS.policyBaseline,
216
+ PROJECT_ARTIFACT_PATHS.policyDiscovery,
217
+ PROJECT_ARTIFACT_PATHS.policyLock,
218
+ ],
219
+ );
220
+ }
221
+
222
+ if (policyCapability === "AVAILABLE") {
223
+ try {
224
+ const existingSnapshot = await readTaskPolicySnapshot(target, result.taskId, packageRoot);
225
+ if (!existingSnapshot) {
226
+ const rules = await loadEffectiveRules(target, packageRoot);
227
+ const baseline = await readBaseline(target, packageRoot);
228
+ const lock = computePolicyLockData(rules, baseline);
229
+ const snapshot = {
230
+ schemaVersion: 1,
231
+ policyDigest: lock.digest,
232
+ rules,
233
+ baseline: baseline ?? { schemaVersion: 1, entries: [] },
234
+ baselineDigest: lock.baselineDigest,
235
+ capturedAt: new Date().toISOString(),
236
+ };
237
+ await writeTaskPolicySnapshot(target, result.taskId, snapshot, packageRoot);
238
+ }
239
+ } catch (error) {
240
+ const snapRel = taskArtifactPath(result.taskId, "policySnapshot");
241
+ throw preflightError("E_POLICY_SNAPSHOT_WRITE_FAILED", `Failed to persist task policy snapshot: ${error.message}`, [snapRel]);
242
+ }
243
+ }
244
+
181
245
  await appendActivationEvents(target, packageRoot, ledger, result, { eventsPath, taskId });
182
246
  const afterEvents = await validateEventLedger(target, packageRoot, { eventsPath, taskId });
183
247
  if (!afterEvents.valid) {
@@ -0,0 +1,173 @@
1
+ import { readContract } from "./contract.js";
2
+ import { appendProtocolEvent, validateEventLedger } from "./events.js";
3
+ import { runCommandExecution } from "./execution.js";
4
+ import { currentRepositoryFingerprint } from "./repository.js";
5
+ import { taskArtifactPath } from "./task-paths.js";
6
+ import { classifyLoadedWorkState, readWorkState, writeWorkState } from "./work-state.js";
7
+
8
+ export const RECONCILE_EVENT = "CHECKPOINT_RECONCILED";
9
+
10
+ const RECONCILABLE_DRIFT = new Set(["REPOSITORY_CHANGED"]);
11
+
12
+ function reconcileError(code, message, artifacts = []) {
13
+ const error = new Error(message);
14
+ error.code = code;
15
+ error.artifacts = artifacts;
16
+ return error;
17
+ }
18
+
19
+ /**
20
+ * Canonical recovery for an EXECUTING task whose objective is already
21
+ * satisfied in the current repository but whose work-state checkpoint is
22
+ * stale because the repository fingerprint moved.
23
+ *
24
+ * The command refreshes the checkpoint repository fingerprint only after:
25
+ * - the task is EXECUTING,
26
+ * - classification requires revalidation and the only drift is
27
+ * REPOSITORY_CHANGED,
28
+ * - the append-only event ledger is valid,
29
+ * - a contract-bound verification check (exact verification item id and
30
+ * requirement text, type VERIFICATION) executes successfully in the
31
+ * current repository as evidence that the objective is present.
32
+ *
33
+ * Closure itself proceeds through the canonical pipeline (advance to
34
+ * VERIFYING, prepare-completion, record-check, complete); claims are
35
+ * released only by canonical COMPLETE.
36
+ */
37
+ export async function runReconcileClosure({
38
+ target,
39
+ packageRoot,
40
+ taskId,
41
+ checkId,
42
+ requirement,
43
+ argv,
44
+ details,
45
+ authorityContext,
46
+ runtimeContext,
47
+ } = {}) {
48
+ if (typeof taskId !== "string" || !taskId.trim()) {
49
+ throw reconcileError("E_TASK_REQUIRED", "reconcile-closure requires --task", []);
50
+ }
51
+ if (typeof checkId !== "string" || !checkId.trim()) {
52
+ throw reconcileError("E_RECONCILE_REQUIREMENT_UNKNOWN", "reconcile-closure requires --id", []);
53
+ }
54
+ if (typeof requirement !== "string" || !requirement.trim()) {
55
+ throw reconcileError("E_RECONCILE_REQUIREMENT_UNKNOWN", "reconcile-closure requires --requirement", []);
56
+ }
57
+ if (!Array.isArray(argv) || argv.length === 0) {
58
+ throw reconcileError("E_RECONCILE_EVIDENCE_FAILED", "reconcile-closure requires -- followed by the evidence command argv", []);
59
+ }
60
+
61
+ const stateRel = taskArtifactPath(taskId, "state");
62
+ const contractRel = taskArtifactPath(taskId, "contract");
63
+ const eventsRel = taskArtifactPath(taskId, "events");
64
+
65
+ const state = await readWorkState(target, { packageRoot, taskId });
66
+ if (!state) {
67
+ throw reconcileError("E_RECONCILE_PHASE_INVALID", "Cannot reconcile without work state", [stateRel]);
68
+ }
69
+ if (state.phase !== "EXECUTING") {
70
+ throw reconcileError(
71
+ "E_RECONCILE_PHASE_INVALID",
72
+ `reconcile-closure supports EXECUTING tasks whose objective is already satisfied; found ${state.phase}`,
73
+ [stateRel],
74
+ );
75
+ }
76
+
77
+ const freshness = await classifyLoadedWorkState({ target, state, contractFile: contractRel });
78
+ if (freshness.status !== "REVALIDATION_REQUIRED" || !freshness.reasons.includes("REPOSITORY_CHANGED")) {
79
+ throw reconcileError(
80
+ "E_RECONCILE_NOT_STALE",
81
+ `work-state checkpoint is ${freshness.status === "FRESH" ? "fresh" : "not revalidation-required"}; no reconciliation required`,
82
+ [stateRel, contractRel],
83
+ );
84
+ }
85
+ const unsupported = freshness.reasons.filter((reason) => !RECONCILABLE_DRIFT.has(reason));
86
+ if (unsupported.length > 0) {
87
+ throw reconcileError(
88
+ "E_RECONCILE_UNSUPPORTED_DRIFT",
89
+ `reconcile-closure only reconciles repository fingerprint drift; unresolved drift: ${unsupported.join(", ")}`,
90
+ [stateRel, contractRel],
91
+ );
92
+ }
93
+
94
+ const ledger = await validateEventLedger(target, packageRoot, { taskId });
95
+ if (!ledger.valid) {
96
+ const first = ledger.errors[0];
97
+ throw reconcileError(
98
+ "E_RECONCILE_LEDGER_INVALID",
99
+ `append-only event ledger must be valid before reconciliation: ${first?.message ?? "invalid ledger"}`,
100
+ [eventsRel],
101
+ );
102
+ }
103
+
104
+ const contract = await readContract(target, packageRoot, { taskId });
105
+ const verificationItem = (contract.value.verification ?? []).find(
106
+ (item) => item.type === "VERIFICATION" && item.id === checkId && item.text === requirement,
107
+ );
108
+ if (!verificationItem) {
109
+ throw reconcileError(
110
+ "E_RECONCILE_REQUIREMENT_UNKNOWN",
111
+ `no contract verification item matches id "${checkId}" with the exact requirement text`,
112
+ [contractRel],
113
+ );
114
+ }
115
+
116
+ const execution = await runCommandExecution({
117
+ target,
118
+ packageRoot,
119
+ taskId,
120
+ checkId,
121
+ requirement,
122
+ verificationCycle: state.verificationCycle ?? 1,
123
+ argv,
124
+ details,
125
+ authorityContext,
126
+ runtimeContext,
127
+ });
128
+ if (execution.execution.status !== "passed") {
129
+ throw reconcileError(
130
+ "E_RECONCILE_EVIDENCE_FAILED",
131
+ `objective-satisfaction evidence failed (exit ${execution.execution.exitCode ?? "not-started"}): ${execution.result}`,
132
+ [execution.path],
133
+ );
134
+ }
135
+
136
+ const repository = await currentRepositoryFingerprint(target);
137
+ const previous = state.repositoryFingerprint;
138
+
139
+ await appendProtocolEvent(target, {
140
+ taskId,
141
+ event: RECONCILE_EVENT,
142
+ details: {
143
+ previousBranch: previous?.branch ?? null,
144
+ previousHead: previous?.head ?? null,
145
+ currentBranch: repository.branch,
146
+ currentHead: repository.head,
147
+ checkId,
148
+ requirement,
149
+ command: argv.join(" "),
150
+ exitCode: execution.execution.exitCode ?? 0,
151
+ executionId: execution.execution.executionId,
152
+ },
153
+ }, packageRoot, { taskId });
154
+
155
+ await writeWorkState(target, {
156
+ ...state,
157
+ repositoryFingerprint: repository,
158
+ lastUpdated: new Date().toISOString(),
159
+ }, { packageRoot, taskId });
160
+
161
+ return {
162
+ taskId,
163
+ phase: state.phase,
164
+ reconciled: true,
165
+ previousRepositoryFingerprint: previous,
166
+ repositoryFingerprint: repository,
167
+ checkId,
168
+ requirement,
169
+ executionId: execution.execution.executionId,
170
+ executionPath: execution.path,
171
+ event: RECONCILE_EVENT,
172
+ };
173
+ }
@@ -24,6 +24,11 @@ export const SHIPPED_SCHEMA_NAMES = Object.freeze([
24
24
  "event",
25
25
  "activation",
26
26
  "policy",
27
+ "policy-rules",
28
+ "policy-discovery",
29
+ "policy-baseline",
30
+ "policy-lock",
31
+ "policy-snapshot",
27
32
  "task-bundle",
28
33
  "execution",
29
34
  "authority",
@@ -40,6 +45,7 @@ export class SchemaValidationError extends Error {
40
45
  }
41
46
 
42
47
  function typeMatches(value, type) {
48
+ if (Array.isArray(type)) return type.some((t) => typeMatches(value, t));
43
49
  if (type === "object") return value !== null && typeof value === "object" && !Array.isArray(value);
44
50
  if (type === "array") return Array.isArray(value);
45
51
  if (type === "integer") return Number.isInteger(value);
@@ -71,6 +71,17 @@ export async function resolveTaskContext(target, {
71
71
  const tasks = await discoverTasks(target, packageRoot);
72
72
  const healthyTasks = tasks.filter((t) => t.healthy !== false);
73
73
 
74
+ // Fail closed: modern task namespaces exist but are all corrupt/unhealthy.
75
+ // Never fall back to legacy singleton state over corrupt modern state.
76
+ if (tasks.length > 0 && healthyTasks.length === 0) {
77
+ const firstInvalid = tasks.find((t) => t.healthy === false);
78
+ const error = new Error(
79
+ firstInvalid?.error?.message ?? "Task namespaces exist but none are valid",
80
+ );
81
+ error.code = firstInvalid?.error?.code ?? "E_TASK_DESCRIPTOR_INVALID";
82
+ throw error;
83
+ }
84
+
74
85
  if (healthyTasks.length === 1) {
75
86
  const single = healthyTasks[0];
76
87
  return createTaskContext({
@@ -1,12 +1,59 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import { ensureWithin, fileExists } from "./filesystem.js";
3
3
  import { getPackageRoot } from "./templates.js";
4
- import { TASK_STATE_ROOT, taskArtifactPath } from "./task-paths.js";
4
+ import { TASK_STATE_ROOT, TASK_ARTIFACT_FILES, taskArtifactPath } from "./task-paths.js";
5
5
  import { readTaskDescriptor } from "./task-descriptor.js";
6
6
  import { readJsonArtifact } from "./artifacts.js";
7
7
  import { readLockInfo } from "./task-lock.js";
8
8
  import { taskStorageKey } from "./task-identity.js";
9
9
 
10
+ /**
11
+ * Explicitly recognized legacy-incidental artifacts that may legitimately
12
+ * exist inside a 64-hex task-state directory WITHOUT a task.json descriptor.
13
+ * A legacy preflight writes a task-scoped policy snapshot for a task that has
14
+ * no modern namespace; that directory is not a task namespace. Any other
15
+ * content makes the directory a corrupt modern task namespace that must fail
16
+ * closed.
17
+ */
18
+ const LEGACY_INCIDENTAL_ARTIFACTS = new Set([
19
+ TASK_ARTIFACT_FILES.policySnapshot,
20
+ ]);
21
+
22
+ /**
23
+ * Classifies a 64-hex task-state directory whose task.json is missing.
24
+ *
25
+ * - directory contains only explicitly recognized legacy-incidental
26
+ * artifacts (policy-snapshot.json) -> LEGACY_INCIDENTAL (ignored)
27
+ * - anything else, including an empty directory -> CORRUPT_TASK_NAMESPACE
28
+ * (no positive evidence of legitimate legacy spillover)
29
+ */
30
+ async function classifyDescriptorlessTaskDirectory(target, taskKey) {
31
+ const directory = ensureWithin(target, `${TASK_STATE_ROOT}/${taskKey}`);
32
+ let entries = [];
33
+ try {
34
+ entries = await readdir(directory, { withFileTypes: true });
35
+ } catch {
36
+ return {
37
+ kind: "CORRUPT_TASK_NAMESPACE",
38
+ error: {
39
+ code: "E_TASK_DESCRIPTOR_INVALID",
40
+ message: `Task namespace ${taskKey} is missing task.json`,
41
+ },
42
+ };
43
+ }
44
+ const names = new Set(entries.map((entry) => entry.name));
45
+ if (names.size > 0 && [...names].every((name) => LEGACY_INCIDENTAL_ARTIFACTS.has(name))) {
46
+ return { kind: "LEGACY_INCIDENTAL" };
47
+ }
48
+ return {
49
+ kind: "CORRUPT_TASK_NAMESPACE",
50
+ error: {
51
+ code: "E_TASK_DESCRIPTOR_INVALID",
52
+ message: `Task namespace ${taskKey} contains task artifacts but task.json is missing`,
53
+ },
54
+ };
55
+ }
56
+
10
57
  export async function discoverTasks(target, packageRoot = getPackageRoot()) {
11
58
  const rootPath = ensureWithin(target, TASK_STATE_ROOT);
12
59
  if (!(await fileExists(rootPath))) {
@@ -92,6 +139,25 @@ export async function discoverTasks(target, packageRoot = getPackageRoot()) {
92
139
  directory: `${TASK_STATE_ROOT}/${descriptor.taskKey}`,
93
140
  });
94
141
  } catch (err) {
142
+ // A directory without a task.json descriptor is not automatically a
143
+ // task namespace: classify by contents so explicitly recognized legacy
144
+ // spillover (policy-snapshot.json) stays compatible while any modern
145
+ // task artifact without a descriptor fails closed instead of silently
146
+ // reopening the legacy singleton fallback.
147
+ if (err.code === "E_TASK_NOT_FOUND" || err.code === "ARTIFACT_MISSING") {
148
+ const classification = await classifyDescriptorlessTaskDirectory(target, entry.name);
149
+ if (classification.kind === "LEGACY_INCIDENTAL") {
150
+ continue;
151
+ }
152
+ tasks.push({
153
+ taskId: null,
154
+ taskKey: entry.name,
155
+ directory: `${TASK_STATE_ROOT}/${entry.name}`,
156
+ healthy: false,
157
+ error: classification.error,
158
+ });
159
+ continue;
160
+ }
95
161
  // P1-2: Surface corrupt task namespaces instead of silently hiding them
96
162
  tasks.push({
97
163
  taskId: null,
@@ -15,14 +15,22 @@ export const TASK_ARTIFACT_FILES = Object.freeze({
15
15
  gates: "gates",
16
16
  executions: "executions",
17
17
  lock: ".lock",
18
+ policySnapshot: "policy-snapshot.json",
18
19
  });
19
20
 
21
+ export const POLICY_ROOT = ".forgeloop/policy";
22
+
20
23
  export const PROJECT_ARTIFACT_PATHS = Object.freeze({
21
24
  config: ".forgeloop/config.json",
22
25
  sources: ".forgeloop/sources.json",
23
26
  manifest: ".forgeloop/.manifest.json",
24
27
  kit: ".forgeloop/kit",
25
28
  gitignore: ".forgeloop/.gitignore",
29
+ policyDir: ".forgeloop/policy",
30
+ policyRules: ".forgeloop/policy/rules.json",
31
+ policyBaseline: ".forgeloop/policy/baseline.json",
32
+ policyLock: ".forgeloop/policy/policy.lock",
33
+ policyDiscovery: ".forgeloop/policy/discovery.json",
26
34
  });
27
35
 
28
36
  export const LEGACY_TASK_ARTIFACT_PATHS = Object.freeze({
@@ -92,5 +100,6 @@ export function buildTaskArtifactPaths(taskId) {
92
100
  gates: `${dir}/${TASK_ARTIFACT_FILES.gates}`,
93
101
  executions: `${dir}/${TASK_ARTIFACT_FILES.executions}`,
94
102
  lock: `${dir}/${TASK_ARTIFACT_FILES.lock}`,
103
+ policySnapshot: `${dir}/${TASK_ARTIFACT_FILES.policySnapshot}`,
95
104
  });
96
105
  }
@@ -58,6 +58,11 @@ export const TEMPLATE_PATHS = [
58
58
  "schemas/event.schema.json",
59
59
  "schemas/activation.schema.json",
60
60
  "schemas/policy.schema.json",
61
+ "schemas/policy-rules.schema.json",
62
+ "schemas/policy-discovery.schema.json",
63
+ "schemas/policy-baseline.schema.json",
64
+ "schemas/policy-lock.schema.json",
65
+ "schemas/policy-snapshot.schema.json",
61
66
  "schemas/task-bundle.schema.json",
62
67
  "schemas/authority.schema.json",
63
68
  "schemas/task-descriptor.schema.json",