@cassiomc1/forgeloop 1.3.0 → 1.5.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 (76) 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 +7 -0
  5. package/EXECUTION_STATE.md +40 -0
  6. package/LOOP_ENGINEERING.md +54 -5
  7. package/LOOP_SYSTEM_DESIGN.md +22 -1
  8. package/PROTOCOL_INTEGRATION.md +41 -0
  9. package/README.md +38 -0
  10. package/TERMINOLOGY.md +15 -0
  11. package/THIRD_PARTY_NOTICES.md +15 -0
  12. package/THREAT_MODEL.md +20 -1
  13. package/docs/ARTIFACT_REFERENCE.md +43 -0
  14. package/docs/CLI_REFERENCE.md +97 -3
  15. package/docs/CROSS_HARNESS_CONTINUITY.md +23 -0
  16. package/docs/DOCUMENTATION_GUIDE.md +14 -0
  17. package/docs/GETTING_STARTED.md +1 -0
  18. package/docs/MCP.md +126 -0
  19. package/docs/RECIPES.md +82 -0
  20. package/docs/RELEASE_CHECKLIST_1_4.md +38 -0
  21. package/docs/RELEASE_CHECKLIST_1_5_MCP.md +78 -0
  22. package/docs/TROUBLESHOOTING.md +111 -1
  23. package/docs/UNIVERSAL_INTEGRATION.md +48 -0
  24. package/package.json +14 -3
  25. package/schemas/task-recovery.schema.json +61 -0
  26. package/src/cli.js +173 -347
  27. package/src/commands/audit.js +5 -0
  28. package/src/commands/inspect.js +6 -0
  29. package/src/commands/progress.js +6 -2
  30. package/src/commands/status.js +17 -0
  31. package/src/commands/task-create.js +39 -1
  32. package/src/commands/task-list.js +14 -1
  33. package/src/commands/task-lock-status.js +2 -2
  34. package/src/commands/task-recover.js +202 -0
  35. package/src/commands/task-repair-legacy-recovery.js +417 -0
  36. package/src/commands/task-resume.js +172 -0
  37. package/src/commands/task-scope.js +23 -4
  38. package/src/commands/task-show.js +18 -4
  39. package/src/commands/validate-protocol.js +19 -2
  40. package/src/core/artifact-registry.js +12 -0
  41. package/src/core/audit.js +20 -4
  42. package/src/core/bundles.js +15 -0
  43. package/src/core/cli-command-definitions.js +50 -4
  44. package/src/core/command-executors.js +387 -0
  45. package/src/core/command-input.js +107 -0
  46. package/src/core/command-runtime.js +106 -0
  47. package/src/core/completion-artifacts.js +2 -3
  48. package/src/core/completion-ownership.js +88 -0
  49. package/src/core/error-codes.js +118 -1
  50. package/src/core/events.js +130 -1
  51. package/src/core/filesystem.js +55 -6
  52. package/src/core/inspect.js +27 -0
  53. package/src/core/integration-invocation-policy.js +170 -0
  54. package/src/core/integration-limits.js +20 -0
  55. package/src/core/integration-resources.js +127 -0
  56. package/src/core/next-action-model.js +60 -0
  57. package/src/core/next-action.js +31 -0
  58. package/src/core/phase.js +2 -1
  59. package/src/core/project-root.js +21 -0
  60. package/src/core/protocol-info.js +13 -0
  61. package/src/core/reconcile-closure.js +32 -10
  62. package/src/core/recovery-history.js +116 -0
  63. package/src/core/schema-validation.js +1 -0
  64. package/src/core/task-claim-state.js +272 -0
  65. package/src/core/task-command.js +5 -1
  66. package/src/core/task-conflict-inspection.js +321 -0
  67. package/src/core/task-context.js +32 -29
  68. package/src/core/task-discovery.js +14 -1
  69. package/src/core/task-lock.js +216 -22
  70. package/src/core/task-paths.js +3 -2
  71. package/src/core/task-recovery-migration.js +192 -0
  72. package/src/core/task-recovery.js +205 -0
  73. package/src/core/task-scope.js +33 -1
  74. package/src/core/templates.js +1 -0
  75. package/src/core/transaction.js +28 -2
  76. package/src/integration.js +47 -0
@@ -0,0 +1,205 @@
1
+ import { PROTOCOL_VERSION } from "./protocol.js";
2
+ import { LEGACY_RECOVERY_MIGRATION_EVENT } from "./task-recovery-migration.js";
3
+ import { readJsonArtifact, writeJsonArtifact } from "./artifacts.js";
4
+ import {
5
+ E_TASK_RECOVERY_AUTHORITY_INVALID,
6
+ } from "./error-codes.js";
7
+ import { taskArtifactPath } from "./task-paths.js";
8
+ import { getActiveTaskTransaction } from "./transaction.js";
9
+ import { classifyRecoveryHistory } from "./recovery-history.js";
10
+
11
+ export const TASK_RECOVERY_SCHEMA_VERSION = 1;
12
+ export const TASK_RECOVERY_EVENT_TYPES = Object.freeze(new Set([
13
+ "TASK_RECOVERY_RECORDED",
14
+ "OPERATOR_RECOVERY_RECORDED",
15
+ LEGACY_RECOVERY_MIGRATION_EVENT,
16
+ ]));
17
+
18
+ export function createTaskRecovery({
19
+ taskId,
20
+ recoveredAt,
21
+ recoveryId,
22
+ recoveryEventSeq,
23
+ classificationAtRecovery,
24
+ reasonCodes,
25
+ releasedClaims,
26
+ previousPhase,
27
+ previousRevision,
28
+ repositoryFingerprint,
29
+ authority,
30
+ }) {
31
+ const recovery = {
32
+ schemaVersion: TASK_RECOVERY_SCHEMA_VERSION,
33
+ protocolVersion: PROTOCOL_VERSION,
34
+ taskId,
35
+ status: "RECOVERED",
36
+ recoveredAt,
37
+ recoveryId,
38
+ recoveryEventSeq,
39
+ classificationAtRecovery,
40
+ reasonCodes: [...reasonCodes],
41
+ releasedClaims: [...releasedClaims],
42
+ previousPhase,
43
+ previousRevision,
44
+ repositoryFingerprint: { ...repositoryFingerprint },
45
+ authority: { ...authority },
46
+ };
47
+ assertTaskRecoveryAuthority(recovery);
48
+ return recovery;
49
+ }
50
+
51
+ export function assertTaskRecoveryAuthority(recovery) {
52
+ const authority = recovery?.authority;
53
+ const validCaller = authority?.kind === "CALLER_ACKNOWLEDGED" && authority.grantRef === undefined;
54
+ const validHost = authority?.kind === "HOST_ATTESTED"
55
+ && typeof authority.grantRef === "string"
56
+ && authority.grantRef.trim() !== "";
57
+ if (validCaller || validHost) return recovery;
58
+ const error = new Error(
59
+ "Recovery authority must be caller acknowledgement or host attestation with a trusted grant reference",
60
+ );
61
+ error.code = E_TASK_RECOVERY_AUTHORITY_INVALID;
62
+ throw error;
63
+ }
64
+
65
+ export async function readTaskRecovery(target, { taskId, packageRoot } = {}) {
66
+ try {
67
+ const artifact = await readJsonArtifact(target, taskArtifactPath(taskId, "recovery"), "task-recovery", packageRoot);
68
+ assertTaskRecoveryAuthority(artifact.value);
69
+ return artifact;
70
+ } catch (error) {
71
+ if (error.code === "ARTIFACT_MISSING") return null;
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ export function writeTaskRecovery(target, recovery, packageRoot) {
77
+ assertTaskRecoveryAuthority(recovery);
78
+ return writeJsonArtifact(
79
+ target,
80
+ taskArtifactPath(recovery.taskId, "recovery"),
81
+ recovery,
82
+ "task-recovery",
83
+ packageRoot,
84
+ { taskId: recovery.taskId, operation: "write-task-recovery" },
85
+ );
86
+ }
87
+
88
+ export async function clearTaskRecovery(target, { taskId } = {}) {
89
+ const transaction = getActiveTaskTransaction();
90
+ if (!transaction) {
91
+ throw new Error("clearTaskRecovery requires an active task transaction");
92
+ }
93
+ await transaction.stageDelete(taskArtifactPath(taskId, "recovery"));
94
+ }
95
+
96
+ export function isTaskRecovered(recovery) {
97
+ return recovery?.status === "RECOVERED";
98
+ }
99
+
100
+ export async function assertTaskNotRecovered(target, { taskId, packageRoot } = {}) {
101
+ const { assertTaskMutationAllowed } = await import("./task-claim-state.js");
102
+ return assertTaskMutationAllowed(target, { taskId, packageRoot });
103
+ }
104
+
105
+ export function effectiveTaskClaims({
106
+ validatedClaimState = null,
107
+ historicalWriteClaims = null,
108
+ writeClaims = [],
109
+ } = {}) {
110
+ const historical = historicalWriteClaims ?? writeClaims;
111
+ // Only VALIDATED canonical ownership may release claims. A bare
112
+ // `phase === "COMPLETE"` never proves completion ownership.
113
+ const validatedRelease = validatedClaimState?.valid === true
114
+ && ["RELEASED_BY_COMPLETION", "RELEASED_BY_RECOVERY"].includes(validatedClaimState.claimState);
115
+ return validatedRelease ? [] : [...historical];
116
+ }
117
+
118
+ export function taskClaimProjection({
119
+ validatedClaimState = null,
120
+ historicalWriteClaims: suppliedHistoricalClaims = null,
121
+ writeClaims = [],
122
+ } = {}) {
123
+ const historicalWriteClaims = [...(suppliedHistoricalClaims ?? writeClaims)];
124
+ const effectiveWriteClaims = effectiveTaskClaims({
125
+ validatedClaimState,
126
+ historicalWriteClaims,
127
+ });
128
+ const claimState = validatedClaimState?.valid === true
129
+ ? validatedClaimState.claimState
130
+ : "ACTIVE";
131
+ return {
132
+ writeClaims: effectiveWriteClaims,
133
+ historicalWriteClaims,
134
+ effectiveWriteClaims,
135
+ claimState,
136
+ mutationAllowed: claimState === "ACTIVE",
137
+ };
138
+ }
139
+
140
+ function sameList(left, right) {
141
+ return Array.isArray(left)
142
+ && Array.isArray(right)
143
+ && left.length === right.length
144
+ && left.every((value, index) => value === right[index]);
145
+ }
146
+
147
+ function recoveryConsistencyError(message) {
148
+ return { code: "E_TASK_RECOVERY_INCONSISTENT", message };
149
+ }
150
+
151
+ export function validateTaskRecoveryConsistency({
152
+ taskId,
153
+ recovery = null,
154
+ events = [],
155
+ historicalWriteClaims = null,
156
+ recoveryHistory = null,
157
+ } = {}) {
158
+ const history = recoveryHistory ?? classifyRecoveryHistory(events);
159
+ const errors = [...history.errors];
160
+
161
+ if (!recovery) {
162
+ if (history.valid && history.activeRecovery) {
163
+ errors.push(recoveryConsistencyError(
164
+ `Recovery event ${history.activeRecovery.recoveryId} has neither an active recovery artifact nor a resume event`,
165
+ ));
166
+ }
167
+ return errors;
168
+ }
169
+ if (!history.activeRecovery || history.activeRecovery.recoveryId !== recovery.recoveryId) {
170
+ errors.push(recoveryConsistencyError(
171
+ `Recovery artifact ${recovery.recoveryId ?? "unknown"} does not match the active recovery history`,
172
+ ));
173
+ }
174
+ if (recovery.taskId !== taskId) {
175
+ errors.push(recoveryConsistencyError(`Recovery artifact belongs to ${recovery.taskId}, not ${taskId}`));
176
+ }
177
+ const event = events.find((candidate) => candidate.seq === recovery.recoveryEventSeq);
178
+ if (!event || !TASK_RECOVERY_EVENT_TYPES.has(event.event)) {
179
+ errors.push(recoveryConsistencyError(
180
+ `Recovery artifact references missing recovery event seq ${recovery.recoveryEventSeq}`,
181
+ ));
182
+ return errors;
183
+ }
184
+
185
+ const comparisons = [
186
+ [event.taskId === recovery.taskId, "taskId"],
187
+ [event.at === recovery.recoveredAt, "recoveredAt"],
188
+ [event.details?.recoveryId === recovery.recoveryId, "recoveryId"],
189
+ [event.details?.classification === recovery.classificationAtRecovery, "classificationAtRecovery"],
190
+ [sameList(event.details?.reasonCodes, recovery.reasonCodes), "reasonCodes"],
191
+ [sameList(event.details?.releasedClaims, recovery.releasedClaims), "releasedClaims"],
192
+ [event.details?.previousPhase === recovery.previousPhase, "previousPhase"],
193
+ [event.details?.previousRevision === recovery.previousRevision, "previousRevision"],
194
+ [event.details?.currentBranch === recovery.repositoryFingerprint?.branch, "repositoryFingerprint.branch"],
195
+ [event.details?.currentHead === recovery.repositoryFingerprint?.head, "repositoryFingerprint.head"],
196
+ [event.details?.authorityKind === recovery.authority?.kind, "authority.kind"],
197
+ ];
198
+ for (const [matches, field] of comparisons) {
199
+ if (!matches) errors.push(recoveryConsistencyError(`Recovery artifact and ledger disagree on ${field}`));
200
+ }
201
+ if (historicalWriteClaims && !sameList(historicalWriteClaims, recovery.releasedClaims)) {
202
+ errors.push(recoveryConsistencyError("Recovery releasedClaims do not match the task descriptor's historical claims"));
203
+ }
204
+ return errors;
205
+ }
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import {
3
3
  E_TASK_CHANGE_OUTSIDE_SCOPE,
4
+ E_TASK_CLAIM_OWNERSHIP_INCONSISTENT,
4
5
  E_TASK_DESCRIPTOR_INVALID,
5
6
  E_TASK_SCOPE_CONFLICT,
6
7
  E_TASK_SCOPE_DIRTY,
@@ -93,7 +94,9 @@ export function checkScopeConflicts(newClaims, existingTasks = [], currentTaskId
93
94
  // Only non-COMPLETE tasks hold active write claims
94
95
  if (task.phase === "COMPLETE") continue;
95
96
 
96
- const taskClaims = normalizeWriteClaims(task.writeClaims ?? task.descriptor?.writeClaims ?? []);
97
+ const taskClaims = normalizeWriteClaims(task.claimState === undefined
98
+ ? (task.writeClaims ?? task.descriptor?.writeClaims ?? [])
99
+ : (task.effectiveWriteClaims ?? []));
97
100
  for (const newClaim of normalizedNew) {
98
101
  for (const existingClaim of taskClaims) {
99
102
  if (claimsOverlap(newClaim, existingClaim)) {
@@ -112,6 +115,35 @@ export function checkScopeConflicts(newClaims, existingTasks = [], currentTaskId
112
115
  }
113
116
 
114
117
  export function assertNoScopeConflicts(newClaims, existingTasks = [], currentTaskId = null) {
118
+ const normalizedNew = normalizeWriteClaims(newClaims);
119
+ if (normalizedNew.length > 0) {
120
+ const ownershipBlockers = existingTasks.filter((task) => {
121
+ if (task.taskId === currentTaskId) return false;
122
+ if (task.healthy === false) return true;
123
+ if (task.claimState !== "INCONSISTENT" && task.ownershipValid !== false) return false;
124
+ const retainedClaims = normalizeWriteClaims(
125
+ task.effectiveWriteClaims ?? task.writeClaims ?? task.descriptor?.writeClaims ?? [],
126
+ );
127
+ return retainedClaims.length === 0
128
+ || normalizedNew.some((claim) => retainedClaims.some((retained) => claimsOverlap(claim, retained)));
129
+ });
130
+ if (ownershipBlockers.length > 0) {
131
+ const error = new Error(
132
+ `Cannot acquire write claims while task ownership is inconsistent: ${ownershipBlockers
133
+ .map((task) => task.taskId ?? task.taskKey)
134
+ .join(", ")}`,
135
+ );
136
+ error.code = E_TASK_CLAIM_OWNERSHIP_INCONSISTENT;
137
+ error.tasks = ownershipBlockers.map((task) => ({
138
+ taskId: task.taskId ?? null,
139
+ taskKey: task.taskKey ?? null,
140
+ claimState: task.claimState ?? "INCONSISTENT",
141
+ reasonCodes: task.reasonCodes ?? [task.error?.code].filter(Boolean),
142
+ }));
143
+ throw error;
144
+ }
145
+ }
146
+
115
147
  const conflicts = checkScopeConflicts(newClaims, existingTasks, currentTaskId);
116
148
  if (conflicts.length > 0) {
117
149
  const first = conflicts[0];
@@ -66,6 +66,7 @@ export const TEMPLATE_PATHS = [
66
66
  "schemas/task-bundle.schema.json",
67
67
  "schemas/authority.schema.json",
68
68
  "schemas/task-descriptor.schema.json",
69
+ "schemas/task-recovery.schema.json",
69
70
  ];
70
71
 
71
72
  export function getPackageRoot() {
@@ -118,6 +118,9 @@ export async function withTaskTransaction({
118
118
  lock,
119
119
  async readText(relativePath) {
120
120
  await assertSafePath(target, relativePath);
121
+ if (manifest.writes.some((entry) => writePath(entry) === relativePath && entry.kind === "DELETE")) {
122
+ return null;
123
+ }
121
124
  const staged = ensureWithin(target, `${stageRoot}/${relativePath}`);
122
125
  if (await fileExists(staged)) return readFile(staged, "utf8");
123
126
  const appendStaged = ensureWithin(target, `${stageRoot}/${relativePath}.append`);
@@ -128,8 +131,9 @@ export async function withTaskTransaction({
128
131
  },
129
132
  async stageText(relativePath, text) {
130
133
  await assertSafePath(target, relativePath);
131
- if (manifest.writes.some((entry) => writePath(entry) === relativePath && entry.kind === "APPEND")) {
132
- throw new Error(`cannot replace append-staged path: ${relativePath}`);
134
+ const conflicting = manifest.writes.find((entry) => writePath(entry) === relativePath);
135
+ if (conflicting?.kind === "APPEND" || conflicting?.kind === "DELETE") {
136
+ throw new Error(`cannot replace ${conflicting.kind.toLowerCase()}-staged path: ${relativePath}`);
133
137
  }
134
138
  const staged = `${stageRoot}/${relativePath}`;
135
139
  await assertSafePath(target, staged);
@@ -162,6 +166,23 @@ export async function withTaskTransaction({
162
166
  }
163
167
  await writeManifest(target, manifestPath, manifest);
164
168
  },
169
+ async stageDelete(relativePath) {
170
+ await assertSafePath(target, relativePath);
171
+ if (manifest.writes.some((entry) => writePath(entry) === relativePath)) {
172
+ throw new Error(`cannot delete write-staged path: ${relativePath}`);
173
+ }
174
+ const destination = ensureWithin(target, relativePath);
175
+ if (!(await fileExists(destination))) {
176
+ throw new Error(`transaction delete target is missing: ${relativePath}`);
177
+ }
178
+ manifest.writes.push({
179
+ path: relativePath,
180
+ kind: "DELETE",
181
+ hadPrevious: false,
182
+ published: false,
183
+ });
184
+ await writeManifest(target, manifestPath, manifest);
185
+ },
165
186
  };
166
187
  try {
167
188
  const result = await transactionContext.run(tx, async () => {
@@ -221,6 +242,11 @@ export async function withTaskTransaction({
221
242
  entry.backupCreated = true;
222
243
  }
223
244
  await writeManifest(target, manifestPath, manifest);
245
+ if (entry.kind === "DELETE") {
246
+ entry.published = true;
247
+ await writeManifest(target, manifestPath, manifest);
248
+ continue;
249
+ }
224
250
  await rename(staged, destination);
225
251
  entry.published = true;
226
252
  await writeManifest(target, manifestPath, manifest);
@@ -0,0 +1,47 @@
1
+ import {
2
+ FORGELOOP_INTEGRATION_RUNTIME_VERSION,
3
+ executeForgeLoopCommand,
4
+ } from "./core/command-runtime.js";
5
+ import { validateForgeLoopCommandInput, defaultCommandInputValues } from "./core/command-input.js";
6
+ import { CLI_COMMAND_DEFINITIONS } from "./core/cli-command-definitions.js";
7
+ import {
8
+ INTEGRATION_RISK_CLASSES,
9
+ classifyForgeLoopInvocation,
10
+ getForgeLoopCapabilities,
11
+ } from "./core/integration-invocation-policy.js";
12
+ import { readForgeLoopIntegrationResource, INTEGRATION_RESOURCE_DEFINITIONS } from "./core/integration-resources.js";
13
+ import { resolveForgeLoopProjectRoot } from "./core/project-root.js";
14
+ import { INTEGRATION_LIMITS } from "./core/integration-limits.js";
15
+ import { readFileSync } from "node:fs";
16
+ import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ /**
20
+ * Version of the installed @cassiomc1/forgeloop package providing this
21
+ * integration API (closing plan §14-16): lets external adapters report the
22
+ * real core version without hardcoding or deep-importing package internals.
23
+ */
24
+ export function getForgeLoopPackageVersion() {
25
+ const packageJsonPath = path.join(
26
+ path.dirname(fileURLToPath(import.meta.url)),
27
+ "..",
28
+ "package.json",
29
+ );
30
+ return JSON.parse(readFileSync(packageJsonPath, "utf8")).version;
31
+ }
32
+
33
+ export {
34
+ executeForgeLoopCommand,
35
+ validateForgeLoopCommandInput,
36
+ defaultCommandInputValues,
37
+ getForgeLoopCapabilities,
38
+ classifyForgeLoopInvocation,
39
+ readForgeLoopIntegrationResource,
40
+ resolveForgeLoopProjectRoot,
41
+ INTEGRATION_LIMITS,
42
+ INTEGRATION_RISK_CLASSES,
43
+ INTEGRATION_RESOURCE_DEFINITIONS,
44
+ CLI_COMMAND_DEFINITIONS,
45
+ };
46
+
47
+ export const FORGELOOP_INTEGRATION_API_VERSION = FORGELOOP_INTEGRATION_RUNTIME_VERSION;