@cassiomc1/forgeloop 1.6.2 → 1.6.4

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 (127) hide show
  1. package/DOCS_INDEX.md +26 -4
  2. package/EXECUTION_STATE.md +7 -0
  3. package/LOOP_ENGINEERING.md +58 -0
  4. package/LOOP_SYSTEM_DESIGN.md +54 -0
  5. package/ORCHESTRATOR_INTEGRATION.md +23 -0
  6. package/PROTOCOL_INTEGRATION.md +36 -0
  7. package/QUALITY_SCORECARD.md +21 -0
  8. package/README.md +57 -2
  9. package/TERMINOLOGY.md +11 -0
  10. package/THREAT_MODEL.md +7 -0
  11. package/completions/_forgeloop +85 -0
  12. package/completions/forgeloop.bash +173 -0
  13. package/completions/forgeloop.fish +544 -0
  14. package/docs/AGENT_PROTOCOL_SUMMARY.md +281 -0
  15. package/docs/ARTIFACT_REFERENCE.md +226 -0
  16. package/docs/CLI_REFERENCE.md +308 -2
  17. package/docs/CODE_ATTESTATION.md +141 -0
  18. package/docs/CROSS_HARNESS_CONTINUITY.md +22 -2
  19. package/docs/DOCUMENTATION_GUIDE.md +13 -11
  20. package/docs/GETTING_STARTED.md +98 -1
  21. package/docs/MCP.md +8 -0
  22. package/docs/PLATFORM_ADAPTERS.md +69 -0
  23. package/docs/RECIPES.md +138 -0
  24. package/docs/RELEASE_CHECKLIST.md +54 -0
  25. package/docs/REVISION_PROVIDERS.md +136 -0
  26. package/docs/SIGNING_PROVIDERS.md +83 -0
  27. package/docs/TROUBLESHOOTING.md +184 -0
  28. package/docs/UNIVERSAL_INTEGRATION.md +11 -0
  29. package/docs/assets/diagrams/forgeloop-code-attestation-flow.html +13841 -0
  30. package/docs/assets/diagrams/forgeloop-code-attestation-flow.receipt.json +37 -0
  31. package/docs/assets/diagrams/forgeloop-code-attestation-flow.svg +5046 -0
  32. package/docs/assets/diagrams/forgeloop-engineering-flow.html +46 -43
  33. package/docs/assets/diagrams/forgeloop-engineering-flow.receipt.json +6 -6
  34. package/docs/assets/diagrams/forgeloop-engineering-flow.svg +41 -41
  35. package/docs/assets/diagrams/forgeloop-verification-trust-flow.html +13783 -0
  36. package/docs/assets/diagrams/forgeloop-verification-trust-flow.receipt.json +37 -0
  37. package/docs/assets/diagrams/forgeloop-verification-trust-flow.svg +4988 -0
  38. package/docs/diagrams/README.md +30 -22
  39. package/docs/diagrams/forgeloop-code-attestation-flow.workflow.json +125 -0
  40. package/docs/diagrams/forgeloop-engineering-flow.workflow.json +11 -8
  41. package/docs/diagrams/forgeloop-verification-trust-flow.workflow.json +116 -0
  42. package/docs/diagrams/manifest.json +42 -0
  43. package/docs/diagrams/reviews/forgeloop-code-attestation-flow.review.json +20 -0
  44. package/docs/diagrams/reviews/forgeloop-engineering-flow.review.json +3 -3
  45. package/docs/diagrams/reviews/forgeloop-verification-trust-flow.review.json +20 -0
  46. package/integrations/generic-ci/verify.sh +14 -0
  47. package/package.json +31 -9
  48. package/schemas/attestation-verification-result.schema.json +34 -0
  49. package/schemas/code-attestation.schema.json +61 -0
  50. package/schemas/code-manifest.schema.json +57 -0
  51. package/schemas/config.schema.json +57 -1
  52. package/schemas/handoff-envelope.schema.json +57 -0
  53. package/schemas/in-toto-statement.schema.json +32 -0
  54. package/schemas/responsibility.schema.json +37 -0
  55. package/schemas/verification-scope.schema.json +26 -0
  56. package/schemas/workspace-binding.schema.json +20 -0
  57. package/scripts/CI_VALIDATORS.md +3 -0
  58. package/scripts/benchmark-cli-startup.mjs +60 -0
  59. package/scripts/check-changelog-freshness.mjs +88 -0
  60. package/scripts/check-critical-coverage.mjs +58 -0
  61. package/scripts/generate-agent-protocol-summary.mjs +141 -0
  62. package/scripts/generate-shell-completions.mjs +132 -0
  63. package/scripts/write-forgeloop-attestation-summary.mjs +93 -0
  64. package/src/cli.js +114 -6
  65. package/src/commands/attestation-create.js +30 -0
  66. package/src/commands/attestation-status.js +12 -0
  67. package/src/commands/attestation-verify-range.js +32 -0
  68. package/src/commands/attestation-verify.js +23 -0
  69. package/src/commands/handoff-create.js +9 -0
  70. package/src/commands/handoff-list.js +12 -0
  71. package/src/commands/handoff-show.js +10 -0
  72. package/src/commands/responsibility-set.js +20 -0
  73. package/src/commands/responsibility-status.js +12 -0
  74. package/src/commands/run-check.js +43 -3
  75. package/src/commands/task-repair-legacy-recovery.js +1 -2
  76. package/src/commands/verify-scope.js +9 -0
  77. package/src/commands/workspace-bind.js +17 -0
  78. package/src/commands/workspace-status.js +19 -0
  79. package/src/core/artifact-registry.js +84 -0
  80. package/src/core/attestation-coverage.js +134 -0
  81. package/src/core/attestation-verifier.js +227 -0
  82. package/src/core/attestation.js +263 -0
  83. package/src/core/audit.js +67 -1
  84. package/src/core/bundles.js +210 -9
  85. package/src/core/cli-command-definitions.js +196 -0
  86. package/src/core/code-manifest.js +293 -0
  87. package/src/core/command-executors.js +79 -0
  88. package/src/core/command-input.js +69 -18
  89. package/src/core/command-runtime.js +2 -1
  90. package/src/core/completion.js +170 -8
  91. package/src/core/config.js +60 -2
  92. package/src/core/continuity-cli-options.js +9 -3
  93. package/src/core/continuity-reconciliation.js +20 -0
  94. package/src/core/diagnostic-projection.js +3 -1
  95. package/src/core/error-codes.js +146 -0
  96. package/src/core/events.js +109 -13
  97. package/src/core/exit-codes.js +22 -0
  98. package/src/core/handoff.js +208 -0
  99. package/src/core/information-gain-projection.js +3 -3
  100. package/src/core/integration-invocation-policy.js +40 -0
  101. package/src/core/integration-resources.js +54 -0
  102. package/src/core/next-action-phases.js +968 -0
  103. package/src/core/next-action.js +32 -951
  104. package/src/core/phase.js +29 -1
  105. package/src/core/protocol-info.js +41 -1
  106. package/src/core/repository.js +256 -11
  107. package/src/core/responsibility.js +265 -0
  108. package/src/core/revision/git.js +207 -0
  109. package/src/core/revision/provider.js +90 -0
  110. package/src/core/revision/registry.js +5 -0
  111. package/src/core/route-artifact.js +1 -1
  112. package/src/core/schema-validation.js +8 -0
  113. package/src/core/signing/none.js +23 -0
  114. package/src/core/signing/provider.js +21 -0
  115. package/src/core/signing/registry.js +7 -0
  116. package/src/core/signing/sigstore.js +158 -0
  117. package/src/core/task-command.js +9 -1
  118. package/src/core/task-paths.js +72 -0
  119. package/src/core/templates.js +8 -0
  120. package/src/core/verification-scope-capability.js +179 -0
  121. package/src/core/verification-scope.js +271 -0
  122. package/src/core/workspace-binding.js +211 -0
  123. package/src/integration.d.ts +91 -0
  124. package/AGENT_COMPATIBILITY.md +0 -11
  125. package/docs/RELEASE_CHECKLIST_1_4.md +0 -38
  126. package/docs/RELEASE_CHECKLIST_1_5_MCP.md +0 -78
  127. package/docs/RELEASE_CHECKLIST_1_6_1.md +0 -121
package/src/core/phase.js CHANGED
@@ -10,7 +10,7 @@ import { readPersistedRoute } from "./route-artifact.js";
10
10
  import { assertWorkPhase, isValidTransition } from "./protocol.js";
11
11
  import { readWorkState, mutateWorkState } from "./work-state.js";
12
12
  import { authorizeCompletionRecoveryOrRebind } from "./completion-recovery-rebind.js";
13
- import { evaluateCompletion } from "./completion.js";
13
+ import { evaluateCompletion, runComplete as persistCompletion } from "./completion.js";
14
14
  import { evaluatePreflight } from "./preflight.js";
15
15
  import { requiredEvidenceForTarget } from "./completion-artifacts.js";
16
16
  import { assertCompletionRelationships, assertStateIdentity } from "./completion-relationships.js";
@@ -174,6 +174,34 @@ export async function advanceWorkState(target, toPhase, options = {}) {
174
174
  const eventsRel = eventsPath ?? (taskId ? taskArtifactPath(taskId, "events") : ARTIFACT_PATHS.events);
175
175
  const receiptRel = receiptPath ?? (taskId ? taskArtifactPath(taskId, "receipt") : ARTIFACT_PATHS.receipt);
176
176
 
177
+ // COMPLETE must use the same transactional completion path regardless of
178
+ // whether the caller selected the dedicated command or a phase transition.
179
+ // This keeps required code-manifest capture and its ledger event atomic with
180
+ // the final state and receipt.
181
+ if (toPhase === "COMPLETE" && state.phase === "REVIEWING") {
182
+ const completion = await persistCompletion({
183
+ target,
184
+ packageRoot,
185
+ taskId,
186
+ authorityContext,
187
+ runtimeContext,
188
+ contractPath,
189
+ routePath,
190
+ statePath,
191
+ receiptPath,
192
+ eventsPath,
193
+ preflightPath: null,
194
+ });
195
+ if (completion.status !== "VALID") {
196
+ throw phaseError(
197
+ "E_COMPLETION_REJECTED",
198
+ "COMPLETE requires a valid completion audit",
199
+ completion.errors.flatMap((error) => error.artifacts ?? []),
200
+ );
201
+ }
202
+ return readWorkState(target, { packageRoot, taskId, statePath });
203
+ }
204
+
177
205
  await assertPhasePrerequisites(target, state, toPhase, packageRoot, authorityContext, runtimeContext, { taskId, statePath, contractPath, routePath, receiptPath, eventsPath });
178
206
  await assertPersistedStateIdentity(target, state, toPhase, packageRoot, { taskId, contractPath, routePath });
179
207
 
@@ -17,7 +17,8 @@ function publicSchemaVersions() {
17
17
  return Object.fromEntries(
18
18
  [...new Set(Object.values(ARTIFACT_REGISTRY)
19
19
  .filter((artifact) => artifact.isPublic && artifact.isPersisted)
20
- .map((artifact) => artifact.schema))]
20
+ .map((artifact) => artifact.schema)
21
+ .filter(Boolean))]
21
22
  .sort()
22
23
  .map((schema) => [schema, [SCHEMA_COMPATIBILITY_POLICY.schemaVersion]]),
23
24
  );
@@ -93,6 +94,45 @@ export function protocolInfo({ packageVersion = null } = {}) {
93
94
  durableApprovals: { version: 1, supported: true, fingerprintBound: true, hostAttestationMinting: false },
94
95
  trajectoryMetrics: { version: 1, supported: true, usageUnknownWhenUnreported: true, overallScore: false },
95
96
  trajectoryEvaluation: { version: 1, supported: true, requiresReferenceScenario: true, source: "PROJECT_LOCAL_REFERENCE" },
97
+ workspaceBinding: {
98
+ version: 1,
99
+ supported: true,
100
+ optional: true,
101
+ modes: ["GIT_WORKTREE"],
102
+ statuses: ["UNBOUND", "MATCH", "MISMATCH", "INVALID", "UNAVAILABLE"],
103
+ rebinding: "EXPLICIT_ONLY",
104
+ },
105
+ canonicalHandoffs: {
106
+ version: 1,
107
+ supported: true,
108
+ immutable: true,
109
+ actorControlledIntent: true,
110
+ lifecycleAuthority: false,
111
+ },
112
+ responsibilityConstraints: {
113
+ version: 1,
114
+ supported: true,
115
+ immutableDuringPass: true,
116
+ frozenInputs: ["contract", "route", "claims"],
117
+ completionEnforced: true,
118
+ },
119
+ differentialVerificationScope: {
120
+ version: 1,
121
+ supported: true,
122
+ modes: ["AUTO", "CHANGED", "CLAIMED", "FULL"],
123
+ heuristicImpactedMode: false,
124
+ staleBinding: true,
125
+ },
126
+ codeAttestation: {
127
+ version: 1,
128
+ supported: true,
129
+ modes: ["off", "optional", "required"],
130
+ revisionProviders: ["git"],
131
+ signingProviders: ["none", "sigstore"],
132
+ statementType: "https://in-toto.io/Statement/v1",
133
+ completionLedgerBound: true,
134
+ excludesProtocolMetadata: true,
135
+ },
96
136
  observabilityStability: {
97
137
  executionHistory: "stable",
98
138
  structuredTrace: "stable",
@@ -1,8 +1,131 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import path from "node:path";
3
4
 
4
5
  const execFileAsync = promisify(execFile);
5
6
 
7
+ const GIT_EXECUTION_OPTIONS = Object.freeze({
8
+ windowsHide: true,
9
+ maxBuffer: 64 * 1024 * 1024,
10
+ });
11
+
12
+ function repositoryError(code, message, cause = null) {
13
+ const error = new Error(message);
14
+ error.code = code;
15
+ if (cause) error.cause = cause;
16
+ return error;
17
+ }
18
+
19
+ async function runGit(target, args, { binary = false } = {}) {
20
+ try {
21
+ const result = await execFileAsync("git", ["-C", target, ...args], {
22
+ ...GIT_EXECUTION_OPTIONS,
23
+ ...(binary ? { encoding: "buffer" } : {}),
24
+ });
25
+ return result.stdout;
26
+ } catch (error) {
27
+ throw repositoryError(
28
+ "E_REPOSITORY_GIT_COMMAND_FAILED",
29
+ `Git command failed (${args.join(" ")}): ${String(error.stderr ?? error.message).trim()}`,
30
+ error,
31
+ );
32
+ }
33
+ }
34
+
35
+ function text(value) {
36
+ return Buffer.isBuffer(value) ? value.toString("utf8") : String(value ?? "");
37
+ }
38
+
39
+ function assertRepositoryPath(relativePath) {
40
+ if (typeof relativePath !== "string" || relativePath.length === 0
41
+ || relativePath.startsWith("/") || /^[A-Za-z]:[\\/]/u.test(relativePath)
42
+ || /[\u0000-\u001f\u007f]/u.test(relativePath) || relativePath.includes("\\")) {
43
+ throw repositoryError("E_REVISION_CONTENT_UNAVAILABLE", "Repository path must be a non-empty relative path");
44
+ }
45
+ const normalized = path.posix.normalize(relativePath);
46
+ if (normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized !== relativePath) {
47
+ throw repositoryError("E_REVISION_CONTENT_UNAVAILABLE", `Repository path escapes the target: ${relativePath}`);
48
+ }
49
+ return normalized;
50
+ }
51
+
52
+ function isGitNotFound(error) {
53
+ return error?.code === 128
54
+ || error?.cause?.code === 128
55
+ || error?.cause?.cause?.code === 128;
56
+ }
57
+
58
+ function assertRevision(value, label = "revision") {
59
+ if (typeof value !== "string" || !value.trim() || value.startsWith("-") || value.includes("\0") || /\s/u.test(value)) {
60
+ throw repositoryError("E_REVISION_NOT_FOUND", `${label} must be a non-empty opaque revision identifier`);
61
+ }
62
+ return value;
63
+ }
64
+
65
+ function parseNameStatusZ(output) {
66
+ const tokens = text(output).split("\0").filter(Boolean);
67
+ const entries = [];
68
+ for (let index = 0; index < tokens.length; index += 1) {
69
+ const statusToken = tokens[index];
70
+ const status = statusToken.slice(0, 1);
71
+ const score = statusToken.slice(1);
72
+ if (["R", "C"].includes(status)) {
73
+ const sourcePath = assertRepositoryPath(tokens[index + 1] ?? "");
74
+ const filePath = assertRepositoryPath(tokens[index + 2] ?? "");
75
+ entries.push({
76
+ path: filePath,
77
+ sourcePath,
78
+ status,
79
+ score: score ? Number(score) : null,
80
+ });
81
+ index += 2;
82
+ } else {
83
+ const embeddedPath = statusToken.length > 1 ? statusToken.slice(1).replace(/^\t/u, "") : null;
84
+ const pathToken = embeddedPath || tokens[++index] || "";
85
+ entries.push({
86
+ path: assertRepositoryPath(pathToken),
87
+ sourcePath: null,
88
+ status,
89
+ score: score ? Number(score) : null,
90
+ });
91
+ }
92
+ }
93
+ return entries.sort((left, right) => left.path.localeCompare(right.path));
94
+ }
95
+
96
+ function parseStatusZ(output) {
97
+ const tokens = text(output).split("\0").filter(Boolean);
98
+ const entries = [];
99
+ for (let index = 0; index < tokens.length; index += 1) {
100
+ const item = tokens[index];
101
+ const status = item.slice(0, 2);
102
+ const firstPath = item.slice(3).replace(/^\t/u, "");
103
+ if (status.includes("R") || status.includes("C")) {
104
+ const sourcePath = assertRepositoryPath(tokens[index + 1] ?? "");
105
+ const filePath = assertRepositoryPath(firstPath);
106
+ entries.push({ path: filePath, sourcePath, status, score: null });
107
+ index += 1;
108
+ } else {
109
+ entries.push({ path: assertRepositoryPath(firstPath), sourcePath: null, status, score: null });
110
+ }
111
+ }
112
+ return entries.sort((left, right) => left.path.localeCompare(right.path));
113
+ }
114
+
115
+ function parseIndexEntriesZ(output) {
116
+ const tokens = text(output).split("\0").filter(Boolean);
117
+ return tokens.map((token) => {
118
+ const match = token.match(/^(\d+) ([0-9a-f]+) (\d+)\t([\s\S]*)$/u);
119
+ if (!match) throw repositoryError("E_REVISION_PROVIDER_INVALID", "Cannot parse Git index entry");
120
+ return {
121
+ mode: match[1],
122
+ objectId: match[2],
123
+ stage: Number(match[3]),
124
+ path: assertRepositoryPath(match[4]),
125
+ };
126
+ });
127
+ }
128
+
6
129
  export async function currentRepositoryFingerprint(target) {
7
130
  try {
8
131
  const [{ stdout: branchOutput }, { stdout: headOutput }] = await Promise.all([
@@ -18,6 +141,135 @@ export async function currentRepositoryFingerprint(target) {
18
141
  }
19
142
  }
20
143
 
144
+ export async function repositoryObjectFormat(target) {
145
+ const output = await runGit(target, ["rev-parse", "--show-object-format"]);
146
+ return text(output).trim() || "sha1";
147
+ }
148
+
149
+ export async function repositoryWorktreeMetadata(target) {
150
+ const [commonDirectory, gitDirectory, topLevel] = await Promise.all([
151
+ runGit(target, ["rev-parse", "--git-common-dir"]),
152
+ runGit(target, ["rev-parse", "--git-dir"]),
153
+ runGit(target, ["rev-parse", "--show-toplevel"]),
154
+ ]);
155
+ return {
156
+ commonDirectory: text(commonDirectory).trim(),
157
+ gitDirectory: text(gitDirectory).trim(),
158
+ topLevel: text(topLevel).trim(),
159
+ objectFormat: await repositoryObjectFormat(target),
160
+ };
161
+ }
162
+
163
+ export async function repositoryHead(target, { revision = "HEAD" } = {}) {
164
+ const output = await runGit(target, ["rev-parse", "--verify", assertRevision(revision)]);
165
+ return text(output).trim();
166
+ }
167
+
168
+ export async function repositoryTree(target, { revision = "HEAD" } = {}) {
169
+ const output = await runGit(target, ["rev-parse", "--verify", `${assertRevision(revision)}^{tree}`]);
170
+ return text(output).trim();
171
+ }
172
+
173
+ export async function repositoryRemoteUrl(target, { remote = "origin" } = {}) {
174
+ try {
175
+ const output = await runGit(target, ["remote", "get-url", remote]);
176
+ return text(output).trim() || null;
177
+ } catch (error) {
178
+ if (error.cause?.code === 2 || /No such remote|no such remote/iu.test(error.message)) return null;
179
+ throw error;
180
+ }
181
+ }
182
+
183
+ export async function repositoryDiffEntries(target, {
184
+ baseRevision,
185
+ headRevision = "HEAD",
186
+ paths = null,
187
+ } = {}) {
188
+ if (typeof baseRevision !== "string" || !baseRevision.trim()) {
189
+ throw repositoryError("E_REVISION_NOT_FOUND", "baseRevision is required for a repository diff");
190
+ }
191
+ const args = ["diff", "--name-status", "-z", "--find-renames", "--find-copies", assertRevision(baseRevision, "baseRevision"), assertRevision(headRevision, "headRevision")];
192
+ if (Array.isArray(paths) && paths.length > 0) {
193
+ args.push("--", ...paths.map(assertRepositoryPath));
194
+ }
195
+ return parseNameStatusZ(await runGit(target, args));
196
+ }
197
+
198
+ export async function repositoryIndexEntries(target, { paths = null } = {}) {
199
+ const args = ["ls-files", "--stage", "-z"];
200
+ if (Array.isArray(paths) && paths.length > 0) args.push("--", ...paths.map(assertRepositoryPath));
201
+ return parseIndexEntriesZ(await runGit(target, args));
202
+ }
203
+
204
+ export async function repositoryTreeEntry(target, { revision = "HEAD", path: repositoryPath } = {}) {
205
+ if (typeof revision !== "string" || !revision.trim()) throw repositoryError("E_REVISION_NOT_FOUND", "revision is required");
206
+ const safeRevision = assertRevision(revision);
207
+ const safePath = assertRepositoryPath(repositoryPath);
208
+ const output = await runGit(target, ["ls-tree", "-z", safeRevision, "--", safePath]);
209
+ const treeEntry = text(output).split("\0").find(Boolean);
210
+ if (!treeEntry) {
211
+ const error = repositoryError("E_REVISION_CONTENT_UNAVAILABLE", `Path is absent at revision ${safeRevision}: ${safePath}`);
212
+ error.notFound = true;
213
+ throw error;
214
+ }
215
+ const match = treeEntry.match(/^(\d+)\s+(\w+)\s+([a-f0-9]+)\t(.*)$/u);
216
+ if (!match) throw repositoryError("E_REVISION_PROVIDER_INVALID", `Cannot parse Git tree entry for ${safePath}`);
217
+ return { mode: match[1], type: match[2], objectId: match[3], path: match[4] };
218
+ }
219
+
220
+ export async function repositoryStatusEntries(target, { paths = null } = {}) {
221
+ const args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
222
+ if (Array.isArray(paths) && paths.length > 0) args.push("--", ...paths.map(assertRepositoryPath));
223
+ return parseStatusZ(await runGit(target, args));
224
+ }
225
+
226
+ export async function repositoryBlobAtRef(target, { ref, path: repositoryPath } = {}) {
227
+ const safeRef = assertRevision(ref, "ref");
228
+ const safePath = assertRepositoryPath(repositoryPath);
229
+ try {
230
+ return Buffer.from(await runGit(target, ["cat-file", "blob", `${safeRef}:${safePath}`], { binary: true }));
231
+ } catch (error) {
232
+ const wrapped = repositoryError("E_REVISION_CONTENT_UNAVAILABLE", `Cannot read ${safePath} at revision ${safeRef}`, error);
233
+ wrapped.notFound = isGitNotFound(error);
234
+ throw wrapped;
235
+ }
236
+ }
237
+
238
+ export async function repositoryBlobOidAtRef(target, { ref, path: repositoryPath } = {}) {
239
+ const safeRef = assertRevision(ref, "ref");
240
+ const safePath = assertRepositoryPath(repositoryPath);
241
+ try {
242
+ const output = await runGit(target, ["rev-parse", "--verify", `${safeRef}:${safePath}`]);
243
+ return text(output).trim();
244
+ } catch (error) {
245
+ const wrapped = repositoryError("E_REVISION_CONTENT_UNAVAILABLE", `Cannot resolve ${safePath} at revision ${safeRef}`, error);
246
+ wrapped.notFound = isGitNotFound(error);
247
+ throw wrapped;
248
+ }
249
+ }
250
+
251
+ export async function expectedGitBlobOid(target, { path: repositoryPath } = {}) {
252
+ const safePath = assertRepositoryPath(repositoryPath);
253
+ try {
254
+ const output = await runGit(target, ["hash-object", `--path=${safePath}`, "--", safePath]);
255
+ return text(output).trim();
256
+ } catch (error) {
257
+ throw repositoryError("E_REVISION_CONTENT_UNAVAILABLE", `Cannot hash worktree path ${safePath}`, error);
258
+ }
259
+ }
260
+
261
+ export async function repositoryHasUnstagedChanges(target, { paths = null } = {}) {
262
+ const args = ["diff", "--quiet"];
263
+ if (Array.isArray(paths) && paths.length > 0) args.push("--", ...paths.map(assertRepositoryPath));
264
+ try {
265
+ await runGit(target, args);
266
+ return false;
267
+ } catch (error) {
268
+ if (error.cause?.code === 1) return true;
269
+ throw error;
270
+ }
271
+ }
272
+
21
273
  export function parsePorcelainV1Z(stdout) {
22
274
  if (!stdout) return [];
23
275
  const entries = [];
@@ -54,17 +306,10 @@ export function parsePorcelainV1Z(stdout) {
54
306
 
55
307
  export async function currentChangedPaths(target, { paths = null } = {}) {
56
308
  try {
57
- const args = ["-C", target];
58
- if (paths && paths.length > 0) {
59
- args.unshift("--literal-pathspecs");
60
- }
61
- args.push("status", "--porcelain=v1", "-z", "--untracked-files=all");
62
- if (paths && paths.length > 0) {
63
- args.push("--", ...paths);
64
- }
65
-
66
- const { stdout } = await execFileAsync("git", args, { windowsHide: true });
67
- return parsePorcelainV1Z(stdout);
309
+ const entries = await repositoryStatusEntries(target, { paths });
310
+ return [...new Set(entries
311
+ .flatMap((entry) => [entry.path, entry.sourcePath].filter(Boolean))
312
+ .filter((relativePath) => !relativePath.startsWith(".forgeloop/")))].sort((a, b) => a.localeCompare(b));
68
313
  } catch {
69
314
  return null;
70
315
  }
@@ -0,0 +1,265 @@
1
+ import { canonicalFingerprint, readJsonArtifact, writeJsonArtifact } from "./artifacts.js";
2
+ import { assertSchema, readSchema } from "./schema-validation.js";
3
+ import { assertSecretFree } from "./receipt.js";
4
+ import { getPackageRoot } from "./templates.js";
5
+ import { currentChangedPaths } from "./repository.js";
6
+ import { readContract } from "./contract.js";
7
+ import { readPersistedRoute } from "./route-artifact.js";
8
+ import { readWorkState } from "./work-state.js";
9
+ import { resolveTaskClaimState } from "./task-claim-state.js";
10
+ import { normalizeWriteClaims } from "./task-scope.js";
11
+ import { taskResponsibilityPath } from "./task-paths.js";
12
+ import { ensureWithin, fileExists } from "./filesystem.js";
13
+ import { appendProtocolEvent } from "./events.js";
14
+ import { withTaskTransaction } from "./transaction.js";
15
+ import { assertTaskMutationAllowed } from "./task-claim-state.js";
16
+ import { assertWorkspaceBinding } from "./workspace-binding.js";
17
+
18
+ function responsibilityError(code, message, artifacts = []) {
19
+ const error = new Error(message);
20
+ error.name = "ResponsibilityError";
21
+ error.code = code;
22
+ error.artifacts = artifacts;
23
+ return error;
24
+ }
25
+
26
+ function normalizePaths(paths, label) {
27
+ try {
28
+ return normalizeWriteClaims(paths ?? []);
29
+ } catch (error) {
30
+ throw responsibilityError("E_RESPONSIBILITY_INVALID", `${label}: ${error.message}`);
31
+ }
32
+ }
33
+
34
+ function coversPath(prefix, candidate) {
35
+ if (prefix === ".") return true;
36
+ return candidate === prefix || candidate.startsWith(`${prefix}/`);
37
+ }
38
+
39
+ function pathScopeErrors(responsibility, changedPaths) {
40
+ if (changedPaths === null) {
41
+ return [responsibilityError("E_RESPONSIBILITY_SCOPE_VIOLATION", "Changed paths cannot be resolved for responsibility validation")];
42
+ }
43
+ if (!Array.isArray(changedPaths)) {
44
+ return [responsibilityError("E_RESPONSIBILITY_SCOPE_VIOLATION", "Changed paths must be a list of relative paths")];
45
+ }
46
+ const unsafePaths = [];
47
+ const normalizedChangedPaths = [];
48
+ for (const value of changedPaths) {
49
+ try {
50
+ normalizedChangedPaths.push(normalizeWriteClaims([value])[0]);
51
+ } catch {
52
+ unsafePaths.push(String(value));
53
+ }
54
+ }
55
+ const allowed = responsibility.allowedPaths;
56
+ const readOnly = responsibility.readOnlyPaths;
57
+ const outOfScope = normalizedChangedPaths.filter((item) => !allowed.some((prefix) => coversPath(prefix, item)));
58
+ const readOnlyChanges = normalizedChangedPaths.filter((item) => readOnly.some((prefix) => coversPath(prefix, item)));
59
+ const errors = [];
60
+ if (unsafePaths.length > 0) {
61
+ errors.push(responsibilityError("E_RESPONSIBILITY_SCOPE_VIOLATION", `Changed paths are not safe relative paths: ${unsafePaths.join(", ")}`));
62
+ }
63
+ if (outOfScope.length > 0) {
64
+ errors.push(responsibilityError("E_RESPONSIBILITY_SCOPE_VIOLATION", `Changed paths are outside allowed responsibility paths: ${outOfScope.join(", ")}`));
65
+ }
66
+ if (readOnlyChanges.length > 0) {
67
+ errors.push(responsibilityError("E_RESPONSIBILITY_SCOPE_VIOLATION", `Changed paths violate read-only responsibility paths: ${readOnlyChanges.join(", ")}`));
68
+ }
69
+ return errors;
70
+ }
71
+
72
+ /** Validate the active responsibility boundary against canonical changed paths. */
73
+ export function validateResponsibilityScope(responsibility, changedPaths) {
74
+ return pathScopeErrors(responsibility, changedPaths);
75
+ }
76
+
77
+ export async function validateResponsibilityContract(value, packageRoot = getPackageRoot()) {
78
+ try {
79
+ assertSchema(value, await readSchema("responsibility", packageRoot), "responsibility contract");
80
+ } catch (error) {
81
+ throw responsibilityError("E_RESPONSIBILITY_INVALID", error.message);
82
+ }
83
+ try {
84
+ const allowedPaths = normalizePaths(value.allowedPaths, "allowedPaths");
85
+ const readOnlyPaths = normalizePaths(value.readOnlyPaths, "readOnlyPaths");
86
+ const requiredCheckIds = [...new Set(value.requiredCheckIds)].sort();
87
+ if (requiredCheckIds.length !== value.requiredCheckIds.length) {
88
+ throw responsibilityError("E_RESPONSIBILITY_INVALID", "requiredCheckIds must not contain duplicates");
89
+ }
90
+ if (allowedPaths.some((item) => item === ".forgeloop" || item.startsWith(".forgeloop/"))) {
91
+ throw responsibilityError("E_RESPONSIBILITY_INVALID", "allowedPaths cannot include ForgeLoop protocol metadata");
92
+ }
93
+ if (readOnlyPaths.some((item) => item === ".forgeloop" || item.startsWith(".forgeloop/"))) {
94
+ throw responsibilityError("E_RESPONSIBILITY_INVALID", "readOnlyPaths cannot include ForgeLoop protocol metadata");
95
+ }
96
+ const normalized = {
97
+ ...value,
98
+ allowedPaths,
99
+ readOnlyPaths,
100
+ requiredCheckIds,
101
+ frozenInputs: {
102
+ contract: value.frozenInputs?.contract === true,
103
+ route: value.frozenInputs?.route === true,
104
+ claims: value.frozenInputs?.claims === true,
105
+ },
106
+ baseline: {
107
+ contractFingerprint: value.baseline.contractFingerprint,
108
+ routeFingerprint: value.baseline.routeFingerprint ?? null,
109
+ claimsFingerprint: value.baseline.claimsFingerprint,
110
+ },
111
+ };
112
+ assertSecretFree(normalized);
113
+ return normalized;
114
+ } catch (error) {
115
+ if (error.code === "E_RESPONSIBILITY_INVALID") throw error;
116
+ throw responsibilityError("E_RESPONSIBILITY_INVALID", error.message);
117
+ }
118
+ }
119
+
120
+ export async function readResponsibility(target, { taskId, packageRoot = getPackageRoot() } = {}) {
121
+ const relativePath = taskResponsibilityPath(taskId);
122
+ if (!(await fileExists(ensureWithin(target, relativePath)))) return null;
123
+ try {
124
+ const artifact = await readJsonArtifact(target, relativePath, "responsibility", packageRoot);
125
+ const value = await validateResponsibilityContract(artifact.value, packageRoot);
126
+ if (value.taskId !== taskId) throw responsibilityError("E_RESPONSIBILITY_INVALID", "Responsibility taskId does not match its task namespace", [relativePath]);
127
+ return { ...artifact, value };
128
+ } catch (error) {
129
+ if (error.code === "E_RESPONSIBILITY_INVALID") throw error;
130
+ throw responsibilityError("E_RESPONSIBILITY_INVALID", `Responsibility contract is invalid: ${error.message}`, [relativePath]);
131
+ }
132
+ }
133
+
134
+ async function currentResponsibilityInputs(target, { taskId, packageRoot }) {
135
+ const [contract, route, state, claims] = await Promise.all([
136
+ readContract(target, packageRoot, { taskId }),
137
+ readPersistedRoute(target, packageRoot, { taskId }),
138
+ readWorkState(target, { packageRoot, taskId }),
139
+ resolveTaskClaimState(target, { packageRoot, taskId }),
140
+ ]);
141
+ return {
142
+ contract,
143
+ route,
144
+ state,
145
+ claims,
146
+ claimsFingerprint: canonicalFingerprint(claims.effectiveWriteClaims ?? []),
147
+ };
148
+ }
149
+
150
+ export function validateResponsibilityFrozenInputs(responsibility, inputs) {
151
+ const errors = [];
152
+ const baseline = responsibility.baseline;
153
+ if (responsibility.frozenInputs.contract && baseline.contractFingerprint !== inputs.contract.fingerprint) {
154
+ errors.push(responsibilityError("E_RESPONSIBILITY_FROZEN_INPUT_DRIFT", "The current contract differs from the responsibility baseline"));
155
+ }
156
+ if (responsibility.frozenInputs.route && baseline.routeFingerprint !== (inputs.route?.fingerprint ?? null)) {
157
+ errors.push(responsibilityError("E_RESPONSIBILITY_FROZEN_INPUT_DRIFT", "The current route differs from the responsibility baseline"));
158
+ }
159
+ if (responsibility.frozenInputs.claims && baseline.claimsFingerprint !== inputs.claimsFingerprint) {
160
+ errors.push(responsibilityError("E_RESPONSIBILITY_FROZEN_INPUT_DRIFT", "The current effective write claims differ from the responsibility baseline"));
161
+ }
162
+ return errors;
163
+ }
164
+
165
+ export function validateResponsibilityChecks(responsibility, state) {
166
+ const checks = new Map((state?.checks ?? [])
167
+ .filter((check) => (check.details?.verificationCycle ?? state?.verificationCycle ?? 1) === (state?.verificationCycle ?? 1))
168
+ .map((check) => [check.id, check]));
169
+ const missing = responsibility.requiredCheckIds.filter((id) => checks.get(id)?.status !== "passed");
170
+ return missing.length === 0
171
+ ? []
172
+ : [responsibilityError("E_RESPONSIBILITY_REQUIRED_CHECK_MISSING", `Required responsibility checks are missing or not passed: ${missing.join(", ")}`)];
173
+ }
174
+
175
+ export async function resolveResponsibilityStatus(target, { taskId, packageRoot = getPackageRoot() } = {}) {
176
+ const artifact = await readResponsibility(target, { taskId, packageRoot });
177
+ if (!artifact) return { status: "NOT_APPLICABLE", taskId, path: taskResponsibilityPath(taskId), responsibility: null, errors: [] };
178
+ let inputs;
179
+ try {
180
+ inputs = await currentResponsibilityInputs(target, { taskId, packageRoot });
181
+ } catch (error) {
182
+ return { status: "INVALID", taskId, path: artifact.path, responsibility: artifact.value, errors: [{ code: error.code ?? "E_RESPONSIBILITY_INVALID", message: error.message }] };
183
+ }
184
+ const changedPaths = await currentChangedPaths(target);
185
+ const errors = [
186
+ ...validateResponsibilityScope(artifact.value, changedPaths),
187
+ ...validateResponsibilityFrozenInputs(artifact.value, inputs),
188
+ ...validateResponsibilityChecks(artifact.value, inputs.state),
189
+ ];
190
+ return {
191
+ status: errors.length === 0 ? "VALID" : "INVALID",
192
+ taskId,
193
+ path: artifact.path,
194
+ fingerprint: artifact.fingerprint,
195
+ responsibility: artifact.value,
196
+ changedPaths,
197
+ errors: errors.map((error) => ({ code: error.code, message: error.message })),
198
+ };
199
+ }
200
+
201
+ export async function assertResponsibilityValid(target, options = {}) {
202
+ const result = await resolveResponsibilityStatus(target, options);
203
+ if (result.status === "NOT_APPLICABLE" || result.status === "VALID") return result;
204
+ const first = result.errors[0] ?? { code: "E_RESPONSIBILITY_INVALID", message: "Responsibility constraints are invalid" };
205
+ throw responsibilityError(first.code, first.message, [result.path]);
206
+ }
207
+
208
+ export async function buildResponsibilityContract(target, {
209
+ taskId,
210
+ label,
211
+ allowedPaths,
212
+ readOnlyPaths = [],
213
+ requiredCheckIds = [],
214
+ frozenInputs = {},
215
+ packageRoot = getPackageRoot(),
216
+ createdAt = new Date().toISOString(),
217
+ } = {}) {
218
+ const inputs = await currentResponsibilityInputs(target, { taskId, packageRoot });
219
+ const value = {
220
+ schemaVersion: 1,
221
+ protocolVersion: 1,
222
+ taskId,
223
+ label,
224
+ createdAt,
225
+ allowedPaths: normalizePaths(allowedPaths ?? inputs.claims.effectiveWriteClaims, "allowedPaths"),
226
+ readOnlyPaths: normalizePaths(readOnlyPaths, "readOnlyPaths"),
227
+ requiredCheckIds: [...new Set(requiredCheckIds)].sort(),
228
+ frozenInputs: {
229
+ contract: frozenInputs.contract === true,
230
+ route: frozenInputs.route === true,
231
+ claims: frozenInputs.claims === true,
232
+ },
233
+ baseline: {
234
+ contractFingerprint: inputs.contract.fingerprint,
235
+ routeFingerprint: inputs.route?.fingerprint ?? null,
236
+ claimsFingerprint: inputs.claimsFingerprint,
237
+ },
238
+ };
239
+ return validateResponsibilityContract(value, packageRoot);
240
+ }
241
+
242
+ export async function setResponsibilityContract(target, options = {}) {
243
+ const { taskId, packageRoot = getPackageRoot() } = options;
244
+ const relativePath = taskResponsibilityPath(taskId);
245
+ return withTaskTransaction({ target, taskId, packageRoot, operation: "responsibility-set", recordCommitEvent: true }, async () => {
246
+ await assertTaskMutationAllowed(target, { taskId, packageRoot });
247
+ await assertWorkspaceBinding(target, { taskId, packageRoot, operation: "responsibility-set" });
248
+ if (await fileExists(ensureWithin(target, relativePath))) {
249
+ throw responsibilityError("E_RESPONSIBILITY_INVALID", "Responsibility contract is immutable during a pass", [relativePath]);
250
+ }
251
+ const state = await readWorkState(target, { packageRoot, taskId });
252
+ if (state && ["EXECUTING", "VERIFYING", "DIAGNOSING", "CORRECTING", "REVIEWING", "COMPLETE"].includes(state.phase)) {
253
+ throw responsibilityError("E_RESPONSIBILITY_INVALID", `Responsibility must be established before execution begins (current phase: ${state.phase})`, [relativePath]);
254
+ }
255
+ const value = await buildResponsibilityContract(target, options);
256
+ const artifact = await writeJsonArtifact(target, relativePath, value, "responsibility", packageRoot, { taskId, operation: "responsibility-set" });
257
+ await appendProtocolEvent(target, {
258
+ taskId,
259
+ event: "RESPONSIBILITY_SET",
260
+ fingerprint: artifact.fingerprint,
261
+ details: { responsibilityFingerprint: artifact.fingerprint, label: value.label },
262
+ }, packageRoot, { taskId });
263
+ return { taskId, path: relativePath, fingerprint: artifact.fingerprint, responsibility: value };
264
+ });
265
+ }