@cassiomc1/forgeloop 0.1.15 → 1.0.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 (52) hide show
  1. package/DOCS_INDEX.md +60 -0
  2. package/EXECUTION_STATE.md +9 -0
  3. package/LOOP_ENGINEERING.md +15 -0
  4. package/LOOP_SYSTEM_DESIGN.md +8 -0
  5. package/PROTOCOL_INTEGRATION.md +8 -0
  6. package/QUALITY_SCORECARD.md +2 -0
  7. package/README.md +183 -654
  8. package/TERMINOLOGY.md +4 -0
  9. package/THREAT_MODEL.md +9 -0
  10. package/docs/assets/forgeloop-flow.svg +1 -0
  11. package/docs/forgeloop-flow.mmd +51 -0
  12. package/package.json +16 -3
  13. package/schemas/continuity.schema.json +57 -0
  14. package/scripts/CI_VALIDATORS.md +32 -0
  15. package/src/cli.js +307 -204
  16. package/src/commands/clear-continuity.js +9 -0
  17. package/src/commands/continuity.js +25 -0
  18. package/src/commands/doctor.js +17 -2
  19. package/src/commands/reconcile-continuity.js +23 -0
  20. package/src/commands/record-continuity.js +63 -0
  21. package/src/commands/status.js +14 -1
  22. package/src/commands/update.js +12 -13
  23. package/src/commands/validate-protocol.js +19 -0
  24. package/src/core/artifacts.js +1 -0
  25. package/src/core/bundles.js +6 -0
  26. package/src/core/command-resolution.js +295 -0
  27. package/src/core/command-tokenizer.js +122 -0
  28. package/src/core/conformance.js +8 -2
  29. package/src/core/continuity-cli-options.js +58 -0
  30. package/src/core/continuity-conformance.js +46 -0
  31. package/src/core/continuity-observability.js +20 -0
  32. package/src/core/continuity-reconciliation.js +224 -0
  33. package/src/core/continuity.js +245 -0
  34. package/src/core/inspect.js +9 -1
  35. package/src/core/installation-authority.js +178 -0
  36. package/src/core/json-safety.js +17 -13
  37. package/src/core/next-action-artifacts.js +118 -0
  38. package/src/core/next-action-continuity.js +65 -0
  39. package/src/core/next-action-model.js +127 -0
  40. package/src/core/next-action-phases.js +12 -0
  41. package/src/core/next-action.js +22 -249
  42. package/src/core/npm-classifier.js +343 -0
  43. package/src/core/package-manager-classifiers.js +37 -0
  44. package/src/core/preflight-consistency.js +221 -0
  45. package/src/core/preflight-loaders.js +112 -0
  46. package/src/core/preflight-model.js +83 -0
  47. package/src/core/preflight.js +34 -444
  48. package/src/core/protocol.js +7 -0
  49. package/src/core/schema-validation.js +15 -1
  50. package/src/core/templates.js +2 -0
  51. package/src/core/verification-capability.js +31 -1106
  52. package/src/core/verification-constants.js +61 -0
@@ -0,0 +1,245 @@
1
+ import { unlink } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ ARTIFACT_PATHS,
6
+ canonicalFingerprint,
7
+ readJsonArtifact,
8
+ writeJsonArtifact,
9
+ } from "./artifacts.js";
10
+ import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
11
+ import { PROTOCOL_VERSION, WORK_PHASES } from "./protocol.js";
12
+ import { assertSecretFree } from "./receipt.js";
13
+ import { getPackageRoot } from "./templates.js";
14
+
15
+ export const CONTINUITY_PATH = ARTIFACT_PATHS.continuity;
16
+ export const CONTINUITY_SCHEMA_VERSION = 1;
17
+
18
+ const LIMITS = Object.freeze({
19
+ taskId: 128,
20
+ id: 128,
21
+ summary: 1000,
22
+ path: 1024,
23
+ resumeNote: 2000,
24
+ remainingWork: 40,
25
+ knownIssues: 40,
26
+ changedAreas: 80,
27
+ inspectFirst: 40,
28
+ });
29
+
30
+ function continuityError(code, message, artifacts = [CONTINUITY_PATH]) {
31
+ const error = new Error(message);
32
+ error.name = "ContinuityError";
33
+ error.code = code;
34
+ error.artifacts = artifacts;
35
+ return error;
36
+ }
37
+
38
+ function nonEmptyString(value, label, maxLength) {
39
+ if (typeof value !== "string" || value.trim() === "") {
40
+ throw continuityError("E_CONTINUITY_INVALID", `${label} must be a non-empty string`);
41
+ }
42
+ if (value.length > maxLength) {
43
+ throw continuityError("E_CONTINUITY_INVALID", `${label} exceeds the ${maxLength}-character limit`);
44
+ }
45
+ if (/\p{Cc}/u.test(value)) {
46
+ throw continuityError("E_CONTINUITY_INVALID", `${label} contains control characters`);
47
+ }
48
+ return value;
49
+ }
50
+
51
+ function fingerprint(value, label) {
52
+ if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
53
+ throw continuityError("E_CONTINUITY_INVALID", `${label} must be a lowercase SHA-256 fingerprint`);
54
+ }
55
+ return value;
56
+ }
57
+
58
+ function normalizeProjectPath(value, label) {
59
+ nonEmptyString(value, label, LIMITS.path);
60
+ const portable = value.replaceAll("\\", "/");
61
+ if (portable.startsWith("/") || /^[A-Za-z]:\//.test(portable)) {
62
+ throw continuityError("E_CONTINUITY_INVALID", `${label} must remain a relative project path`);
63
+ }
64
+ const normalized = path.posix.normalize(portable);
65
+ if (normalized === "." || normalized === ".." || normalized.startsWith("../")) {
66
+ throw continuityError("E_CONTINUITY_INVALID", `${label} must not escape the task target`);
67
+ }
68
+ return normalized.replace(/^\.\//, "");
69
+ }
70
+
71
+ function normalizeWorkItem(value, label) {
72
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
73
+ throw continuityError("E_CONTINUITY_INVALID", `${label} must be an object`);
74
+ }
75
+ const keys = Object.keys(value).sort();
76
+ if (keys.some((key) => !["id", "summary"].includes(key))) {
77
+ throw continuityError("E_CONTINUITY_INVALID", `${label} contains an unsupported field`);
78
+ }
79
+ return {
80
+ id: nonEmptyString(value.id, `${label}.id`, LIMITS.id),
81
+ summary: nonEmptyString(value.summary, `${label}.summary`, LIMITS.summary),
82
+ };
83
+ }
84
+
85
+ function normalizeWorkItems(value, label, maxItems) {
86
+ if (!Array.isArray(value)) throw continuityError("E_CONTINUITY_INVALID", `${label} must be an array`);
87
+ if (value.length > maxItems) throw continuityError("E_CONTINUITY_INVALID", `${label} exceeds the ${maxItems}-item limit`);
88
+ const items = value.map((item, index) => normalizeWorkItem(item, `${label}[${index}]`));
89
+ if (new Set(items.map((item) => item.id)).size !== items.length) {
90
+ throw continuityError("E_CONTINUITY_INVALID", `${label} must not contain duplicate ids`);
91
+ }
92
+ return items;
93
+ }
94
+
95
+ function normalizePaths(value, label, maxItems) {
96
+ if (!Array.isArray(value)) throw continuityError("E_CONTINUITY_INVALID", `${label} must be an array`);
97
+ if (value.length > maxItems) throw continuityError("E_CONTINUITY_INVALID", `${label} exceeds the ${maxItems}-item limit`);
98
+ const paths = value.map((item, index) => normalizeProjectPath(item, `${label}[${index}]`));
99
+ if (new Set(paths).size !== paths.length) throw continuityError("E_CONTINUITY_INVALID", `${label} must not contain duplicate paths`);
100
+ return paths;
101
+ }
102
+
103
+ function normalizeRepositoryFingerprint(value) {
104
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
105
+ throw continuityError("E_CONTINUITY_INVALID", "repositoryFingerprint must be an object");
106
+ }
107
+ if (Object.keys(value).some((key) => !["branch", "head"].includes(key))) {
108
+ throw continuityError("E_CONTINUITY_INVALID", "repositoryFingerprint contains an unsupported field");
109
+ }
110
+ for (const key of ["branch", "head"]) {
111
+ if (value[key] !== null && typeof value[key] !== "string") {
112
+ throw continuityError("E_CONTINUITY_INVALID", `repositoryFingerprint.${key} must be a string or null`);
113
+ }
114
+ }
115
+ return { branch: value.branch ?? null, head: value.head ?? null };
116
+ }
117
+
118
+ const CONTINUITY_FIELDS = new Set([
119
+ "schemaVersion",
120
+ "protocolVersion",
121
+ "taskId",
122
+ "workStateFingerprint",
123
+ "contractFingerprint",
124
+ "phase",
125
+ "verificationCycle",
126
+ "repositoryFingerprint",
127
+ "updatedAt",
128
+ "currentFocus",
129
+ "remainingWork",
130
+ "knownIssues",
131
+ "changedAreas",
132
+ "inspectFirst",
133
+ "resumeNote",
134
+ ]);
135
+
136
+ export function assertContinuitySemantics(input) {
137
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
138
+ throw continuityError("E_CONTINUITY_INVALID", "Continuity must be a JSON object");
139
+ }
140
+ const unknownField = Object.keys(input).find((key) => !CONTINUITY_FIELDS.has(key));
141
+ if (unknownField) {
142
+ throw continuityError("E_CONTINUITY_INVALID", `Continuity contains unsupported field: ${unknownField}`);
143
+ }
144
+ if (input.schemaVersion !== CONTINUITY_SCHEMA_VERSION || input.protocolVersion !== PROTOCOL_VERSION) {
145
+ throw continuityError("E_CONTINUITY_SCHEMA_UNSUPPORTED", "Unsupported continuity schema or protocol version");
146
+ }
147
+ if (!WORK_PHASES.includes(input.phase)) {
148
+ throw continuityError("E_CONTINUITY_INVALID", `Unknown continuity phase: ${input.phase}`);
149
+ }
150
+ if (input.verificationCycle !== undefined
151
+ && (!Number.isInteger(input.verificationCycle) || input.verificationCycle < 1)) {
152
+ throw continuityError("E_CONTINUITY_INVALID", "verificationCycle must be a positive integer");
153
+ }
154
+ const normalized = {
155
+ schemaVersion: CONTINUITY_SCHEMA_VERSION,
156
+ protocolVersion: PROTOCOL_VERSION,
157
+ taskId: nonEmptyString(input.taskId, "taskId", LIMITS.taskId),
158
+ workStateFingerprint: fingerprint(input.workStateFingerprint, "workStateFingerprint"),
159
+ contractFingerprint: fingerprint(input.contractFingerprint, "contractFingerprint"),
160
+ phase: input.phase,
161
+ ...(input.verificationCycle !== undefined ? { verificationCycle: input.verificationCycle } : {}),
162
+ repositoryFingerprint: normalizeRepositoryFingerprint(input.repositoryFingerprint),
163
+ updatedAt: nonEmptyString(input.updatedAt, "updatedAt", 128),
164
+ ...(input.currentFocus !== undefined ? { currentFocus: normalizeWorkItem(input.currentFocus, "currentFocus") } : {}),
165
+ remainingWork: normalizeWorkItems(input.remainingWork, "remainingWork", LIMITS.remainingWork),
166
+ knownIssues: normalizeWorkItems(input.knownIssues, "knownIssues", LIMITS.knownIssues),
167
+ changedAreas: normalizePaths(input.changedAreas, "changedAreas", LIMITS.changedAreas),
168
+ inspectFirst: normalizePaths(input.inspectFirst, "inspectFirst", LIMITS.inspectFirst),
169
+ ...(input.resumeNote !== undefined
170
+ ? { resumeNote: nonEmptyString(input.resumeNote, "resumeNote", LIMITS.resumeNote) }
171
+ : {}),
172
+ };
173
+ assertSecretFree(normalized);
174
+ return normalized;
175
+ }
176
+
177
+ export function createContinuity(input) {
178
+ return assertContinuitySemantics({
179
+ schemaVersion: input.schemaVersion ?? CONTINUITY_SCHEMA_VERSION,
180
+ protocolVersion: input.protocolVersion ?? PROTOCOL_VERSION,
181
+ ...structuredClone(input),
182
+ });
183
+ }
184
+
185
+ export async function readContinuity(target, packageRoot = getPackageRoot()) {
186
+ const artifact = await readJsonArtifact(target, CONTINUITY_PATH, "continuity", packageRoot);
187
+ return { ...artifact, value: assertContinuitySemantics(artifact.value) };
188
+ }
189
+
190
+ export async function writeContinuity(target, operationalInput = {}, options = {}) {
191
+ const packageRoot = options.packageRoot ?? getPackageRoot();
192
+ let state = options.state;
193
+ if (!state) {
194
+ const { readWorkState } = await import("./work-state.js");
195
+ state = await readWorkState(target, packageRoot);
196
+ }
197
+ if (!state) throw continuityError("E_CONTINUITY_STATE_MISSING", "Cannot record continuity without work state", [ARTIFACT_PATHS.state]);
198
+
199
+ let contract = options.contract;
200
+ if (!contract) {
201
+ const { readContract } = await import("./contract.js");
202
+ contract = await readContract(target, packageRoot);
203
+ }
204
+ const currentContractFingerprint = contract?.fingerprint
205
+ ?? (contract ? canonicalFingerprint(contract.value ?? contract) : null);
206
+ if (!currentContractFingerprint) {
207
+ throw continuityError("E_CONTINUITY_CONTRACT_MISMATCH", "Cannot bind continuity to the current contract", [ARTIFACT_PATHS.contract]);
208
+ }
209
+ if (state.contractFingerprint !== currentContractFingerprint) {
210
+ throw continuityError(
211
+ "E_CONTINUITY_CONTRACT_MISMATCH",
212
+ "Work state contract fingerprint does not match the current contract",
213
+ [ARTIFACT_PATHS.state, ARTIFACT_PATHS.contract],
214
+ );
215
+ }
216
+ const value = createContinuity({
217
+ taskId: state.taskId,
218
+ workStateFingerprint: canonicalFingerprint(state),
219
+ contractFingerprint: state.contractFingerprint,
220
+ phase: state.phase,
221
+ ...(state.verificationCycle !== undefined ? { verificationCycle: state.verificationCycle } : {}),
222
+ repositoryFingerprint: options.repositoryFingerprint ?? await (async () => {
223
+ const { currentRepositoryFingerprint } = await import("./repository.js");
224
+ return currentRepositoryFingerprint(target);
225
+ })(),
226
+ updatedAt: options.now ?? new Date().toISOString(),
227
+ ...(operationalInput.currentFocus !== undefined ? { currentFocus: operationalInput.currentFocus } : {}),
228
+ remainingWork: operationalInput.remainingWork ?? [],
229
+ knownIssues: operationalInput.knownIssues ?? [],
230
+ changedAreas: operationalInput.changedAreas ?? [],
231
+ inspectFirst: operationalInput.inspectFirst ?? [],
232
+ ...(operationalInput.resumeNote !== undefined ? { resumeNote: operationalInput.resumeNote } : {}),
233
+ });
234
+ return writeJsonArtifact(target, CONTINUITY_PATH, value, "continuity", packageRoot, {
235
+ dryRun: options.dryRun ?? false,
236
+ });
237
+ }
238
+
239
+ export async function clearContinuity(target) {
240
+ await assertSafePath(target, CONTINUITY_PATH);
241
+ const artifactPath = ensureWithin(target, CONTINUITY_PATH);
242
+ if (!(await fileExists(artifactPath))) return { removed: false, path: CONTINUITY_PATH };
243
+ await unlink(artifactPath);
244
+ return { removed: true, path: CONTINUITY_PATH };
245
+ }
@@ -9,6 +9,8 @@ import { runDoctor } from "../commands/doctor.js";
9
9
  import { findProfilePath } from "./profile.js";
10
10
  import { FORGELOOP_KIT_DIR } from "./target-layout.js";
11
11
  import { trustedAuthorityConfiguration } from "./trusted-authority.js";
12
+ import { reconcileContinuity } from "./continuity-reconciliation.js";
13
+ import { continuityFinding, continuityIsHealthy } from "./continuity-observability.js";
12
14
 
13
15
  function profileMetadata(bytes) {
14
16
  const text = bytes.toString("utf8");
@@ -35,6 +37,7 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
35
37
  const statePath = ensureWithin(target, WORK_STATE_PATH);
36
38
  const statePresent = await fileExists(statePath);
37
39
  const state = await readAndClassifyWorkState({ target, packageRoot, contractFile });
40
+ const continuity = await reconcileContinuity({ target, packageRoot });
38
41
  const schemaRoot = manifest?.layoutVersion >= 2
39
42
  ? ensureWithin(target, FORGELOOP_KIT_DIR)
40
43
  : target;
@@ -69,6 +72,9 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
69
72
  });
70
73
  }
71
74
  }
75
+ const continuityIssue = continuityFinding(continuity);
76
+ if (continuityIssue) findings.push(continuityIssue);
77
+
72
78
  if (state.status === "INVALID") {
73
79
  findings.push({
74
80
  code: "state-invalid",
@@ -123,6 +129,7 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
123
129
  evidence: protocolEvidence,
124
130
  },
125
131
  state: { ...state, path: WORK_STATE_PATH, present: statePresent },
132
+ continuity,
126
133
  compatibility: {
127
134
  deprecated: true,
128
135
  agents: [],
@@ -132,6 +139,7 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
132
139
  ok: doctor.ok
133
140
  && !manifestError
134
141
  && schemaHealth.status === "valid"
135
- && !["INVALID", "REVALIDATION_REQUIRED"].includes(state.status),
142
+ && !["INVALID", "REVALIDATION_REQUIRED"].includes(state.status)
143
+ && continuityIsHealthy(continuity),
136
144
  };
137
145
  }
@@ -0,0 +1,178 @@
1
+ import {
2
+ E_AUTHORITY_INVALID,
3
+ E_AUTHORITY_SCOPE_MISMATCH,
4
+ E_INSTALLATION_AUTHORITY_REQUIRED,
5
+ } from "./verification-constants.js";
6
+ import { classifyCommandResolution } from "./command-resolution.js";
7
+ import {
8
+ E_AUTHORITY_UNTRUSTED_SOURCE,
9
+ resolveTrustedAuthority,
10
+ } from "./trusted-authority.js";
11
+
12
+ export { E_AUTHORITY_UNTRUSTED_SOURCE };
13
+
14
+ export function getInstallationAuthorityRef(check) {
15
+ return (
16
+ check?.details?.installationAuthorityRef
17
+ ?? check?.details?.authorityRef
18
+ ?? check?.installationAuthorityRef
19
+ ?? check?.authorityRef
20
+ ?? null
21
+ );
22
+ }
23
+
24
+ function normalizeToolName(toolName) {
25
+ if (typeof toolName !== "string") return "";
26
+ if (toolName.startsWith("@")) {
27
+ const parts = toolName.slice(1).split("@");
28
+ return `@${parts[0]}`;
29
+ }
30
+ return toolName.split("@")[0];
31
+ }
32
+
33
+ export function validateAuthorityGrant({ authority, taskId, type = "SOFTWARE_INSTALLATION", tool } = {}) {
34
+ if (!authority || typeof authority !== "object") {
35
+ return {
36
+ valid: false,
37
+ error: {
38
+ code: E_AUTHORITY_INVALID,
39
+ message: "Authority grant artifact is missing or invalid",
40
+ },
41
+ };
42
+ }
43
+
44
+ if (authority.schemaVersion !== 1 || authority.protocolVersion !== 1) {
45
+ return {
46
+ valid: false,
47
+ error: {
48
+ code: E_AUTHORITY_INVALID,
49
+ message: `Authority grant schema version (${authority.schemaVersion}) or protocol version (${authority.protocolVersion}) is invalid`,
50
+ },
51
+ };
52
+ }
53
+
54
+ if (authority.type !== type) {
55
+ return {
56
+ valid: false,
57
+ error: {
58
+ code: E_AUTHORITY_INVALID,
59
+ message: `Authority grant type '${authority.type}' does not match expected '${type}'`,
60
+ },
61
+ };
62
+ }
63
+
64
+ if (authority.status !== "AUTHORIZED") {
65
+ return {
66
+ valid: false,
67
+ error: {
68
+ code: E_AUTHORITY_INVALID,
69
+ message: `Authority grant is not active (status: '${authority.status}')`,
70
+ },
71
+ };
72
+ }
73
+
74
+ if (authority.source === "agent-self") {
75
+ return {
76
+ valid: false,
77
+ error: {
78
+ code: E_AUTHORITY_INVALID,
79
+ message: "Self-asserted authority grants with source 'agent-self' are not permitted",
80
+ },
81
+ };
82
+ }
83
+
84
+ if (!["operator", "host", "project-policy"].includes(authority.source)) {
85
+ return {
86
+ valid: false,
87
+ error: {
88
+ code: E_AUTHORITY_INVALID,
89
+ message: `Authority grant source '${authority.source}' is not recognized`,
90
+ },
91
+ };
92
+ }
93
+
94
+ if (taskId && authority.taskId && authority.taskId !== taskId) {
95
+ return {
96
+ valid: false,
97
+ error: {
98
+ code: E_AUTHORITY_INVALID,
99
+ message: `Authority grant taskId '${authority.taskId}' does not match current task '${taskId}'`,
100
+ },
101
+ };
102
+ }
103
+
104
+ if (tool && authority.scope?.tool && authority.scope.tool !== "*") {
105
+ const requestedNorm = normalizeToolName(tool);
106
+ const scopeNorm = normalizeToolName(authority.scope.tool);
107
+ const exactMatch = authority.scope.tool === tool
108
+ || tool.startsWith(`${authority.scope.tool}@`)
109
+ || authority.scope.tool.startsWith(`${tool}@`)
110
+ || requestedNorm === scopeNorm;
111
+
112
+ if (!exactMatch) {
113
+ return {
114
+ valid: false,
115
+ error: {
116
+ code: E_AUTHORITY_SCOPE_MISMATCH,
117
+ message: `Authority grant scope tool '${authority.scope.tool}' does not match requested verification tool '${tool}'`,
118
+ },
119
+ };
120
+ }
121
+ }
122
+
123
+ return { valid: true, error: null };
124
+ }
125
+
126
+ export function validateVerificationAuthority(check, options = {}) {
127
+ const canonicalResolution = check?.execution?.resolution ?? check?.details?.execution?.resolution;
128
+ const command = check?.details?.command
129
+ ?? (check?.kind === "command" && typeof check?.source === "string" && !check.source.startsWith("check:")
130
+ ? check.source
131
+ : null);
132
+
133
+ if (!canonicalResolution && (!command || typeof command !== "string")) return { valid: true, error: null };
134
+
135
+ const classification = canonicalResolution ?? classifyCommandResolution(command);
136
+ if (!classification.mayInstall) return { valid: true, error: null };
137
+
138
+ const authorityRef = getInstallationAuthorityRef(check);
139
+ if (!authorityRef) {
140
+ return {
141
+ valid: false,
142
+ error: {
143
+ code: E_INSTALLATION_AUTHORITY_REQUIRED,
144
+ message: `Verification command '${command}' uses installation-capable resolution (${classification.resolutionMode}) without recorded installation authority reference`,
145
+ },
146
+ };
147
+ }
148
+
149
+ const resolved = resolveTrustedAuthority({
150
+ authorityRef,
151
+ target: options.target,
152
+ trustedAuthorityFile: options.trustedAuthorityFile,
153
+ trustedAuthorityDir: options.trustedAuthorityDir,
154
+ authorities: options.authorities,
155
+ authority: options.authority,
156
+ authorityContext: options.authorityContext,
157
+ runtimeContext: options.runtimeContext,
158
+ });
159
+ if (!resolved.trusted) {
160
+ if (resolved.error?.code === E_AUTHORITY_INVALID && resolved.sourceConfigured === false) {
161
+ return {
162
+ valid: false,
163
+ error: {
164
+ code: E_INSTALLATION_AUTHORITY_REQUIRED,
165
+ message: "Installation-capable verification requires a host-attested authority context",
166
+ },
167
+ };
168
+ }
169
+ return { valid: false, error: resolved.error };
170
+ }
171
+
172
+ return validateAuthorityGrant({
173
+ authority: resolved.authority,
174
+ taskId: options.taskId,
175
+ type: "SOFTWARE_INSTALLATION",
176
+ tool: classification.tool,
177
+ });
178
+ }
@@ -23,7 +23,7 @@ export function assertJsonBytes(bytes, label = "JSON artifact", limits = JSON_LI
23
23
  }
24
24
 
25
25
  export function assertJsonLimits(value, label = "JSON artifact", limits = JSON_LIMITS) {
26
- const visited = new WeakSet();
26
+ const ancestors = new Set();
27
27
 
28
28
  function visit(current, depth, location) {
29
29
  if (typeof current === "string") {
@@ -33,19 +33,23 @@ export function assertJsonLimits(value, label = "JSON artifact", limits = JSON_L
33
33
  return;
34
34
  }
35
35
  if (!current || typeof current !== "object") return;
36
- if (visited.has(current)) throw new JsonLimitError(`${location} contains a circular reference`);
37
- visited.add(current);
38
- if (depth > limits.maxDepth) throw new JsonLimitError(`${location} exceeds the JSON depth limit`);
39
- if (Array.isArray(current)) {
40
- if (current.length > limits.maxArrayLength) throw new JsonLimitError(`${location} exceeds the array length limit`);
41
- current.forEach((item, index) => visit(item, depth + 1, `${location}[${index}]`));
42
- } else {
43
- const keys = Object.keys(current);
44
- if (keys.length > limits.maxObjectKeys) throw new JsonLimitError(`${location} exceeds the object key limit`);
45
- for (const key of keys) {
46
- if (key.length > limits.maxStringLength) throw new JsonLimitError(`${location} contains an oversized key`);
47
- visit(current[key], depth + 1, `${location}.${key}`);
36
+ if (ancestors.has(current)) throw new JsonLimitError(`${location} contains a circular reference`);
37
+ ancestors.add(current);
38
+ try {
39
+ if (depth > limits.maxDepth) throw new JsonLimitError(`${location} exceeds the JSON depth limit`);
40
+ if (Array.isArray(current)) {
41
+ if (current.length > limits.maxArrayLength) throw new JsonLimitError(`${location} exceeds the array length limit`);
42
+ current.forEach((item, index) => visit(item, depth + 1, `${location}[${index}]`));
43
+ } else {
44
+ const keys = Object.keys(current);
45
+ if (keys.length > limits.maxObjectKeys) throw new JsonLimitError(`${location} exceeds the object key limit`);
46
+ for (const key of keys) {
47
+ if (key.length > limits.maxStringLength) throw new JsonLimitError(`${location} contains an oversized key`);
48
+ visit(current[key], depth + 1, `${location}.${key}`);
49
+ }
48
50
  }
51
+ } finally {
52
+ ancestors.delete(current);
49
53
  }
50
54
  }
51
55
 
@@ -0,0 +1,118 @@
1
+ import { ARTIFACT_PATHS } from "./artifacts.js";
2
+ import { requiredEvidenceForTarget } from "./completion-artifacts.js";
3
+ import { coverageForRequirements } from "./coverage.js";
4
+ import { assertCheckList } from "./checks.js";
5
+
6
+ export function artifactError(code, message, artifacts = []) {
7
+ return { code, message, artifacts };
8
+ }
9
+
10
+ export function staleReasons(state, contract, route) {
11
+ const reasons = [];
12
+ if (state.contractFingerprint !== contract.fingerprint) {
13
+ reasons.push(artifactError(
14
+ "E_CONTRACT_STALE",
15
+ "Work state references a different current contract",
16
+ [ARTIFACT_PATHS.state, ARTIFACT_PATHS.contract],
17
+ ));
18
+ }
19
+ if (route.value.contractFingerprint !== undefined && route.value.contractFingerprint !== contract.fingerprint) {
20
+ reasons.push(artifactError(
21
+ "E_ROUTE_STALE",
22
+ "Routing result references a different current contract",
23
+ [ARTIFACT_PATHS.route, ARTIFACT_PATHS.contract],
24
+ ));
25
+ }
26
+ if (state.routeFingerprint !== route.fingerprint) {
27
+ reasons.push(artifactError(
28
+ "E_ROUTE_STALE",
29
+ "Work state references a different routing result",
30
+ [ARTIFACT_PATHS.state, ARTIFACT_PATHS.route],
31
+ ));
32
+ }
33
+ if (JSON.stringify(state.selectedGuides) !== JSON.stringify(route.value.guides)) {
34
+ reasons.push(artifactError(
35
+ "E_ROUTE_STALE",
36
+ "Work state guides do not match the persisted routing result",
37
+ [ARTIFACT_PATHS.state, ARTIFACT_PATHS.route],
38
+ ));
39
+ }
40
+ return reasons;
41
+ }
42
+
43
+ export function freshnessReasons(state, classification) {
44
+ const requiredArtifactPaths = state.requiredArtifacts?.map((artifact) => artifact.path) ?? [];
45
+ const reasons = [artifactError(
46
+ "E_STATE_REVALIDATION_REQUIRED",
47
+ `Work-state checkpoint requires revalidation: ${classification.reasons.join(", ")}`,
48
+ [ARTIFACT_PATHS.state],
49
+ )];
50
+ for (const reason of classification.reasons) {
51
+ const contractRelated = ["CONTRACT_CHANGED", "CONTRACT_INVALID", "CONTRACT_NOT_VERIFIED"].includes(reason);
52
+ const artifactRelated = reason.startsWith("REQUIRED_ARTIFACT");
53
+ reasons.push(artifactError(
54
+ contractRelated ? "E_CONTRACT_STALE" : artifactRelated ? "E_REQUIRED_ARTIFACT_STALE" : "E_REPOSITORY_CHANGED",
55
+ `Work-state freshness check failed: ${reason}`,
56
+ uniqueSorted([
57
+ ARTIFACT_PATHS.state,
58
+ ...(contractRelated ? [ARTIFACT_PATHS.contract] : []),
59
+ ...(artifactRelated ? requiredArtifactPaths : []),
60
+ ]),
61
+ ));
62
+ }
63
+ return reasons;
64
+ }
65
+
66
+ export function checkListReasons(state) {
67
+ try {
68
+ assertCheckList(state.checks, "work-state.checks");
69
+ return [];
70
+ } catch (error) {
71
+ return [artifactError(error.code ?? "E_CHECK_INVALID", error.message, [ARTIFACT_PATHS.state])];
72
+ }
73
+ }
74
+
75
+ export async function loadArtifact(loader, fallback) {
76
+ try {
77
+ return { value: await loader(), error: null, missingArtifacts: [] };
78
+ } catch (error) {
79
+ return {
80
+ value: null,
81
+ error,
82
+ missingArtifacts: error?.code === "ARTIFACT_MISSING"
83
+ ? (error.artifacts?.length ? error.artifacts : [fallback])
84
+ : [],
85
+ };
86
+ }
87
+ }
88
+
89
+ export async function requirementsAndCoverage({
90
+ target,
91
+ packageRoot,
92
+ contract,
93
+ route,
94
+ checks,
95
+ additionalEvidence = [],
96
+ authorityContext,
97
+ runtimeContext,
98
+ }) {
99
+ const requirements = await requiredEvidenceForTarget({
100
+ target,
101
+ contract,
102
+ route,
103
+ packageRoot,
104
+ additionalEvidence,
105
+ });
106
+ return {
107
+ requirements,
108
+ coverage: coverageForRequirements(requirements, checks, {
109
+ target,
110
+ taskId: contract?.value?.taskId,
111
+ options: { authorityContext, runtimeContext },
112
+ }),
113
+ };
114
+ }
115
+
116
+ function uniqueSorted(values) {
117
+ return [...new Set(values.filter(Boolean))].sort();
118
+ }