@cassiomc1/forgeloop 1.8.1 → 1.10.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 (93) hide show
  1. package/.cursor/rules/project-loop.mdc +6 -3
  2. package/.github/copilot-instructions.md +5 -0
  3. package/AGENTS.md +6 -0
  4. package/AGENT_COMPATIBILITY.md +15 -0
  5. package/CLAUDE.md +6 -0
  6. package/DELEGATION_PROTOCOL.md +6 -0
  7. package/DOCS_INDEX.md +9 -2
  8. package/ENG/accessibility-eng.md +12 -2
  9. package/ENG/design-code-eng.md +22 -1
  10. package/LOOP_ENGINEERING.md +41 -0
  11. package/LOOP_SYSTEM_DESIGN.md +33 -0
  12. package/ORCHESTRATOR_INTEGRATION.md +9 -0
  13. package/PROTOCOL_INTEGRATION.md +57 -0
  14. package/QUALITY_SCORECARD.md +2 -0
  15. package/README.md +51 -0
  16. package/TERMINOLOGY.md +12 -0
  17. package/THREAT_MODEL.md +48 -0
  18. package/completions/_forgeloop +5 -1
  19. package/completions/forgeloop.bash +9 -1
  20. package/completions/forgeloop.fish +27 -1
  21. package/docs/ADVISORY_CONTEXT.md +174 -0
  22. package/docs/AGENT_PROTOCOL_SUMMARY.md +33 -2
  23. package/docs/ARTIFACT_REFERENCE.md +142 -0
  24. package/docs/CLI_REFERENCE.md +124 -1
  25. package/docs/CROSS_HARNESS_CONTINUITY.md +85 -0
  26. package/docs/DOCUMENTATION_GUIDE.md +7 -0
  27. package/docs/GETTING_STARTED.md +22 -0
  28. package/docs/KNOWLEDGE_SOURCES.md +171 -0
  29. package/docs/MCP.md +17 -1
  30. package/docs/RECIPES.md +111 -0
  31. package/docs/RELEASE_CHECKLIST.md +14 -0
  32. package/docs/STRUCTURAL_QUALITY.md +350 -0
  33. package/docs/TROUBLESHOOTING.md +161 -2
  34. package/docs/UNIVERSAL_INTEGRATION.md +60 -0
  35. package/package.json +4 -1
  36. package/schemas/config.schema.json +46 -0
  37. package/schemas/handoff-envelope.schema.json +1 -0
  38. package/schemas/preflight.schema.json +2 -1
  39. package/schemas/structural-quality.schema.json +175 -0
  40. package/scripts/check-changelog-freshness.mjs +27 -3
  41. package/scripts/generate-agent-protocol-summary.mjs +18 -0
  42. package/src/cli.js +24 -0
  43. package/src/commands/handoff-accept.js +36 -0
  44. package/src/commands/handoff-list.js +28 -2
  45. package/src/commands/handoff-show.js +27 -2
  46. package/src/commands/quality-baseline.js +28 -0
  47. package/src/commands/quality-status.js +34 -0
  48. package/src/commands/quality-verify.js +30 -0
  49. package/src/commands/reconcile-continuity.js +4 -0
  50. package/src/core/advisory-context/constants.js +74 -0
  51. package/src/core/advisory-context/provider.js +287 -0
  52. package/src/core/advisory-context/service.js +140 -0
  53. package/src/core/artifact-registry.js +12 -0
  54. package/src/core/audit.js +38 -0
  55. package/src/core/bundles.js +134 -1
  56. package/src/core/cli-command-definitions.js +62 -0
  57. package/src/core/command-executors.js +28 -0
  58. package/src/core/command-input.js +23 -1
  59. package/src/core/completion-artifacts.js +2 -0
  60. package/src/core/completion.js +42 -0
  61. package/src/core/config.js +3 -0
  62. package/src/core/continuity-lint.js +89 -0
  63. package/src/core/continuity-reconciliation.js +16 -0
  64. package/src/core/continuity.js +10 -11
  65. package/src/core/error-codes.js +186 -0
  66. package/src/core/events.js +32 -0
  67. package/src/core/execution-profile-context.js +15 -1
  68. package/src/core/filesystem.js +34 -3
  69. package/src/core/handoff-acceptance.js +277 -0
  70. package/src/core/handoff.js +41 -8
  71. package/src/core/inspect.js +64 -0
  72. package/src/core/integration-invocation-policy.js +34 -2
  73. package/src/core/integration-resources.js +38 -1
  74. package/src/core/next-action-model.js +11 -1
  75. package/src/core/next-action-phases.js +84 -5
  76. package/src/core/phase.js +9 -1
  77. package/src/core/portable-context.js +103 -0
  78. package/src/core/preflight.js +33 -0
  79. package/src/core/protocol-info.js +33 -2
  80. package/src/core/runtime-context.js +58 -0
  81. package/src/core/schema-validation.js +1 -0
  82. package/src/core/structural-quality/artifacts.js +329 -0
  83. package/src/core/structural-quality/constants.js +67 -0
  84. package/src/core/structural-quality/policy.js +227 -0
  85. package/src/core/structural-quality/provider.js +287 -0
  86. package/src/core/structural-quality/sentrux-mcp.js +477 -0
  87. package/src/core/structural-quality/service.js +1138 -0
  88. package/src/core/structural-quality/source-fingerprint.js +112 -0
  89. package/src/core/structural-quality/status.js +3 -0
  90. package/src/core/task-paths.js +24 -0
  91. package/src/core/templates.js +1 -0
  92. package/src/integration.d.ts +141 -0
  93. package/src/integration.js +36 -0
@@ -0,0 +1,277 @@
1
+ import { canonicalFingerprint } from "./artifacts.js";
2
+ import { readContract } from "./contract.js";
3
+ import { readPersistedRoute } from "./route-artifact.js";
4
+ import { readWorkState } from "./work-state.js";
5
+ import { assertStateIdentity } from "./completion-relationships.js";
6
+ import { compareRepositoryFingerprint, readCanonicalHandoff } from "./handoff.js";
7
+ import { appendProtocolEvent, validateEventLedger } from "./events.js";
8
+ import { currentChangedPaths, currentRepositoryFingerprint } from "./repository.js";
9
+ import { withTaskTransaction } from "./transaction.js";
10
+ import { getPackageRoot } from "./templates.js";
11
+ import {
12
+ normalizePortableText,
13
+ assertPortableContextSafe,
14
+ } from "./portable-context.js";
15
+ import {
16
+ E_HANDOFF_ACCEPTANCE_UNBOUND,
17
+ E_HANDOFF_STALE,
18
+ E_HANDOFF_ALREADY_ACCEPTED,
19
+ E_HANDOFF_ACCEPTANCE_INCONSISTENT,
20
+ } from "./error-codes.js";
21
+
22
+ function acceptanceError(code, message) {
23
+ const error = new Error(message);
24
+ error.name = "HandoffAcceptanceError";
25
+ error.code = code;
26
+ return error;
27
+ }
28
+
29
+ function uniqueSorted(values) {
30
+ return [...new Set(values.filter(Boolean))].sort();
31
+ }
32
+
33
+ export function resolveHandoffAcceptance(input = {}, legacyHandoff) {
34
+ const projection = Array.isArray(input)
35
+ ? { events: input, handoff: legacyHandoff }
36
+ : input;
37
+ const {
38
+ events = [],
39
+ handoff,
40
+ ledgerValid = true,
41
+ ledgerErrors = [],
42
+ } = projection ?? {};
43
+
44
+ if (ledgerValid !== true) {
45
+ return {
46
+ status: "INCONSISTENT",
47
+ reasonCodes: uniqueSorted(ledgerErrors.map((error) => typeof error === "string" ? error : error?.code)),
48
+ };
49
+ }
50
+
51
+ if (!handoff?.state?.workStateFingerprint) {
52
+ return { status: "UNBOUND" };
53
+ }
54
+
55
+ const acceptedEvents = (events ?? []).filter(
56
+ (e) => e.event === "HANDOFF_ACCEPTED" && e.details?.handoffId === handoff.handoffId,
57
+ );
58
+
59
+ if (acceptedEvents.length === 0) {
60
+ return { status: "OPEN" };
61
+ }
62
+
63
+ if (acceptedEvents.length > 1) {
64
+ return { status: "INCONSISTENT" };
65
+ }
66
+
67
+ const accepted = acceptedEvents[0];
68
+ if (accepted.details?.handoffDigest !== handoff.artifactDigest) {
69
+ return { status: "INCONSISTENT" };
70
+ }
71
+
72
+ return {
73
+ status: "ACCEPTED",
74
+ consumerId: accepted.details?.consumerId,
75
+ ...(accepted.details?.harness ? { harness: accepted.details.harness } : {}),
76
+ acceptedAt: accepted.at,
77
+ };
78
+ }
79
+
80
+ export async function readHandoffAcceptanceLedger(target, packageRoot = getPackageRoot(), { taskId } = {}) {
81
+ try {
82
+ return await validateEventLedger(target, packageRoot, { taskId });
83
+ } catch (error) {
84
+ return {
85
+ valid: false,
86
+ events: [],
87
+ errors: [{
88
+ code: error.code ?? "E_EVENT_INVALID",
89
+ message: error.message,
90
+ }],
91
+ };
92
+ }
93
+ }
94
+
95
+ export async function acceptCanonicalHandoff(target, {
96
+ taskId,
97
+ handoffId,
98
+ consumerId,
99
+ harness,
100
+ packageRoot = getPackageRoot(),
101
+ } = {}) {
102
+ if (!target || typeof target !== "string") {
103
+ throw acceptanceError("E_TARGET_REQUIRED", "target path is required");
104
+ }
105
+ if (!taskId || typeof taskId !== "string") {
106
+ throw acceptanceError("E_TASK_REQUIRED", "taskId is required");
107
+ }
108
+ if (!handoffId || typeof handoffId !== "string") {
109
+ throw acceptanceError("E_HANDOFF_INVALID", "handoffId is required");
110
+ }
111
+
112
+ const normalizedConsumerId = normalizePortableText(consumerId, {
113
+ label: "consumerId",
114
+ maxLength: 128,
115
+ });
116
+ assertPortableContextSafe(normalizedConsumerId, { label: "consumerId" });
117
+
118
+ const normalizedHarness = harness !== undefined
119
+ ? normalizePortableText(harness, {
120
+ label: "harness",
121
+ maxLength: 64,
122
+ optional: true,
123
+ })
124
+ : null;
125
+ if (normalizedHarness) {
126
+ assertPortableContextSafe(normalizedHarness, { label: "harness" });
127
+ }
128
+
129
+ return withTaskTransaction(
130
+ {
131
+ target,
132
+ taskId,
133
+ packageRoot,
134
+ operation: "handoff-accept",
135
+ recordCommitEvent: true,
136
+ },
137
+ async () => {
138
+ const { value: handoff } = await readCanonicalHandoff(target, {
139
+ taskId,
140
+ handoffId,
141
+ packageRoot,
142
+ });
143
+
144
+ if (!handoff.state?.workStateFingerprint) {
145
+ throw acceptanceError(
146
+ E_HANDOFF_ACCEPTANCE_UNBOUND,
147
+ "Handoff snapshot is unbound (lacks state.workStateFingerprint); create a fresh handoff",
148
+ );
149
+ }
150
+
151
+ const currentState = await readWorkState(target, { packageRoot, taskId });
152
+ if (!currentState) {
153
+ throw acceptanceError(
154
+ E_HANDOFF_STALE,
155
+ "Canonical work state is unavailable for handoff acceptance",
156
+ );
157
+ }
158
+
159
+ const currentWorkStateFingerprint = canonicalFingerprint(currentState);
160
+ if (currentWorkStateFingerprint !== handoff.state.workStateFingerprint) {
161
+ throw acceptanceError(
162
+ E_HANDOFF_STALE,
163
+ "Current work state has drifted from handoff workStateFingerprint",
164
+ );
165
+ }
166
+
167
+ const contract = await readContract(target, packageRoot, { taskId });
168
+ const route = await readPersistedRoute(target, packageRoot, { taskId });
169
+ try {
170
+ assertStateIdentity({ contract, route, state: currentState });
171
+ } catch (error) {
172
+ throw acceptanceError(
173
+ E_HANDOFF_STALE,
174
+ `Current canonical task artifacts are not coherent with one another: ${error.message}`,
175
+ );
176
+ }
177
+ if (contract.fingerprint !== handoff.state.contractFingerprint) {
178
+ throw acceptanceError(
179
+ E_HANDOFF_STALE,
180
+ "Current contract fingerprint has drifted from handoff contractFingerprint",
181
+ );
182
+ }
183
+ if ((route.fingerprint ?? null) !== (handoff.state.routeFingerprint ?? null)) {
184
+ throw acceptanceError(
185
+ E_HANDOFF_STALE,
186
+ "Current route fingerprint has drifted from handoff routeFingerprint",
187
+ );
188
+ }
189
+
190
+ const repositoryFingerprint = await currentRepositoryFingerprint(target);
191
+ if (compareRepositoryFingerprint(handoff.state.repositoryFingerprint, repositoryFingerprint) === "MISMATCH") {
192
+ throw acceptanceError(
193
+ E_HANDOFF_STALE,
194
+ "Current repository branch or HEAD has drifted from handoff snapshot",
195
+ );
196
+ }
197
+
198
+ const changedPaths = await currentChangedPaths(target);
199
+ const currentList = [...new Set((changedPaths ?? []).map((p) => p.replaceAll("\\", "/")))].sort();
200
+ const handoffList = [...new Set((handoff.state.changedPaths ?? []).map((p) => p.replaceAll("\\", "/")))].sort();
201
+ if (JSON.stringify(currentList) !== JSON.stringify(handoffList)) {
202
+ throw acceptanceError(
203
+ E_HANDOFF_STALE,
204
+ "Current changed paths have drifted from handoff snapshot",
205
+ );
206
+ }
207
+
208
+ const ledgerResult = await validateEventLedger(target, packageRoot, { taskId });
209
+ if (!ledgerResult.valid) {
210
+ throw acceptanceError(
211
+ E_HANDOFF_ACCEPTANCE_INCONSISTENT,
212
+ "Task event ledger is invalid",
213
+ );
214
+ }
215
+
216
+ const events = ledgerResult.events;
217
+ const createdEvent = events.find(
218
+ (e) => e.event === "HANDOFF_CREATED" && e.details?.handoffId === handoffId,
219
+ );
220
+ if (!createdEvent || createdEvent.details?.digest !== handoff.artifactDigest) {
221
+ throw acceptanceError(
222
+ E_HANDOFF_ACCEPTANCE_INCONSISTENT,
223
+ "No matching HANDOFF_CREATED event with matching digest found in ledger",
224
+ );
225
+ }
226
+
227
+ const existingAccepted = events.filter(
228
+ (e) => e.event === "HANDOFF_ACCEPTED" && e.details?.handoffId === handoffId,
229
+ );
230
+
231
+ if (existingAccepted.length > 0) {
232
+ const prev = existingAccepted[0];
233
+ if (prev.details?.consumerId === normalizedConsumerId) {
234
+ return {
235
+ accepted: true,
236
+ idempotent: true,
237
+ handoffId,
238
+ consumerId: normalizedConsumerId,
239
+ harness: prev.details?.harness ?? null,
240
+ acceptedAt: prev.at,
241
+ };
242
+ }
243
+ throw acceptanceError(
244
+ E_HANDOFF_ALREADY_ACCEPTED,
245
+ `Handoff ${handoffId} has already been accepted by consumer "${prev.details?.consumerId}"`,
246
+ );
247
+ }
248
+
249
+ const eventDetails = {
250
+ handoffId,
251
+ handoffDigest: handoff.artifactDigest,
252
+ consumerId: normalizedConsumerId,
253
+ ...(normalizedHarness ? { harness: normalizedHarness } : {}),
254
+ };
255
+
256
+ const appended = await appendProtocolEvent(
257
+ target,
258
+ {
259
+ taskId,
260
+ event: "HANDOFF_ACCEPTED",
261
+ details: eventDetails,
262
+ },
263
+ packageRoot,
264
+ { taskId },
265
+ );
266
+
267
+ return {
268
+ accepted: true,
269
+ idempotent: false,
270
+ handoffId,
271
+ consumerId: normalizedConsumerId,
272
+ harness: normalizedHarness ?? null,
273
+ acceptedAt: appended.at,
274
+ };
275
+ },
276
+ );
277
+ }
@@ -2,12 +2,13 @@ import { randomUUID } from "node:crypto";
2
2
  import { readdir } from "node:fs/promises";
3
3
 
4
4
  import { canonicalFingerprint, readJsonArtifact, writeJsonArtifact } from "./artifacts.js";
5
- import { assertSecretFree } from "./receipt.js";
5
+ import { assertPortableContextSafe, normalizePortableText } from "./portable-context.js";
6
6
  import { appendProtocolEvent } from "./events.js";
7
- import { currentChangedPaths } from "./repository.js";
7
+ import { currentChangedPaths, currentRepositoryFingerprint } from "./repository.js";
8
8
  import { readContract } from "./contract.js";
9
9
  import { readPersistedRoute } from "./route-artifact.js";
10
10
  import { readWorkState } from "./work-state.js";
11
+ import { assertStateIdentity } from "./completion-relationships.js";
11
12
  import { resolveTaskClaimState } from "./task-claim-state.js";
12
13
  import { readContinuity } from "./continuity.js";
13
14
  import { getPackageRoot } from "./templates.js";
@@ -26,12 +27,13 @@ function handoffError(code, message, artifacts = []) {
26
27
  return error;
27
28
  }
28
29
 
29
- function optionalText(value, label) {
30
+ function optionalText(value, label, maxLength = 2000) {
30
31
  if (value === undefined || value === null) return null;
31
- if (typeof value !== "string" || value.trim() === "") {
32
+ try {
33
+ return normalizePortableText(value, { label, maxLength, optional: true });
34
+ } catch (error) {
32
35
  throw handoffError("E_HANDOFF_INVALID", `${label} must be a non-empty string when provided`);
33
36
  }
34
- return value;
35
37
  }
36
38
 
37
39
  function pathList(value) {
@@ -43,8 +45,22 @@ function handoffWithoutDigest(value) {
43
45
  return body;
44
46
  }
45
47
 
48
+ export function compareRepositoryFingerprint(expected, current) {
49
+ const expectedUnavailable = !expected || (expected.branch === null && expected.head === null);
50
+ const currentUnavailable = !current || (current.branch === null && current.head === null);
51
+ if (expectedUnavailable && currentUnavailable) return "NOT_VERIFIED";
52
+ if (expectedUnavailable !== currentUnavailable) return "MISMATCH";
53
+ return expected.branch === current.branch && expected.head === current.head
54
+ ? "MATCH"
55
+ : "MISMATCH";
56
+ }
57
+
46
58
  export function validateHandoffDigest(handoff) {
47
- assertSecretFree(handoff);
59
+ try {
60
+ assertPortableContextSafe(handoff);
61
+ } catch (error) {
62
+ throw handoffError("E_HANDOFF_INVALID", error.message);
63
+ }
48
64
  if (typeof handoff?.artifactDigest !== "string"
49
65
  || handoff.artifactDigest !== canonicalFingerprint(handoffWithoutDigest(handoff))) {
50
66
  throw handoffError("E_HANDOFF_TAMPERED", "Handoff artifact digest does not match its canonical content");
@@ -73,17 +89,33 @@ export async function buildCanonicalHandoff(target, {
73
89
  createdAt = new Date().toISOString(),
74
90
  } = {}) {
75
91
  if (!taskId) throw handoffError("E_HANDOFF_STATE_UNAVAILABLE", "taskId is required to build a handoff");
76
- const [state, contract, route, claims, changedPaths, continuity] = await Promise.all([
92
+ const [state, contract, route, claims, changedPaths, continuity, repositoryFingerprint] = await Promise.all([
77
93
  readWorkState(target, { packageRoot, taskId }),
78
94
  readContract(target, packageRoot, { taskId }),
79
95
  readPersistedRoute(target, packageRoot, { taskId }),
80
96
  resolveTaskClaimState(target, { packageRoot, taskId }),
81
97
  currentChangedPaths(target),
82
98
  currentContinuity(target, packageRoot, taskId),
99
+ currentRepositoryFingerprint(target),
83
100
  ]);
84
101
  if (!state || !contract || !route || changedPaths === null || !claims.valid) {
85
102
  throw handoffError("E_HANDOFF_STATE_UNAVAILABLE", "Canonical task state, route, claims, and changed paths are required for a handoff");
86
103
  }
104
+ try {
105
+ assertStateIdentity({ contract, route, state });
106
+ } catch (error) {
107
+ throw handoffError(
108
+ "E_HANDOFF_STATE_UNAVAILABLE",
109
+ `Canonical task artifacts are not coherent for handoff creation: ${error.message}`,
110
+ error.artifacts ?? [],
111
+ );
112
+ }
113
+ if (compareRepositoryFingerprint(state.repositoryFingerprint, repositoryFingerprint) === "MISMATCH") {
114
+ throw handoffError(
115
+ "E_HANDOFF_STATE_UNAVAILABLE",
116
+ "Canonical repository fingerprint has drifted from current repository state",
117
+ );
118
+ }
87
119
  const checkItems = [...(state.checks ?? [])];
88
120
  const executionRefs = pathList(checkItems.map((check) => check.executionRef).filter(Boolean));
89
121
  const checkIds = pathList(checkItems.map((check) => check.id).filter(Boolean));
@@ -101,9 +133,10 @@ export async function buildCanonicalHandoff(target, {
101
133
  phase: WORK_PHASES.includes(state.phase) ? state.phase : "UNKNOWN",
102
134
  revision: state.revision ?? 0,
103
135
  verificationCycle: state.verificationCycle ?? 1,
136
+ workStateFingerprint: canonicalFingerprint(state),
104
137
  contractFingerprint: contract.fingerprint,
105
138
  routeFingerprint: route.fingerprint ?? null,
106
- repositoryFingerprint: state.repositoryFingerprint ?? { branch: null, head: null },
139
+ repositoryFingerprint,
107
140
  writeClaims: pathList(claims.effectiveWriteClaims ?? []),
108
141
  changedPaths: pathList(changedPaths),
109
142
  },
@@ -15,6 +15,8 @@ import { findTaskById } from "./task-discovery.js";
15
15
  import { buildTaskTrace } from "./trace.js";
16
16
  import { evaluateProgress } from "./progress.js";
17
17
  import { readEvents } from "./events.js";
18
+ import { taskStructuralQualityDirectory } from "./task-paths.js";
19
+ import { projectStructuralQualityStatus } from "./structural-quality/status.js";
18
20
 
19
21
  function reasonCodesFor({ state, trace, progress }) {
20
22
  const codes = [];
@@ -28,6 +30,40 @@ function reasonCodesFor({ state, trace, progress }) {
28
30
  return codes;
29
31
  }
30
32
 
33
+ function structuralQualityIssues(quality, taskId) {
34
+ if (!quality || quality.mode !== "gate") return [];
35
+ const qualityPath = quality.current?.artifactRef ?? taskStructuralQualityDirectory(taskId);
36
+ if (quality.baseline?.status !== "OBSERVED") {
37
+ return [{
38
+ code: "E_STRUCTURAL_QUALITY_BASELINE_MISSING",
39
+ message: "Structural-quality gate evidence has no valid immutable baseline.",
40
+ path: taskStructuralQualityDirectory(taskId),
41
+ }];
42
+ }
43
+ if (!quality.current?.artifactRef) {
44
+ return [{
45
+ code: "E_STRUCTURAL_QUALITY_EVIDENCE_STALE",
46
+ message: "Structural-quality gate has no evaluation for the current verification cycle.",
47
+ path: qualityPath,
48
+ }];
49
+ }
50
+ if (quality.current.status === "FAIL") {
51
+ return [{
52
+ code: "E_STRUCTURAL_QUALITY_REGRESSION",
53
+ message: "Structural-quality verification detected a regression.",
54
+ path: qualityPath,
55
+ }];
56
+ }
57
+ if (quality.current.status === "BLOCKED") {
58
+ return [{
59
+ code: "E_STRUCTURAL_QUALITY_EVALUATION_INCOMPARABLE",
60
+ message: "Structural-quality verification is blocked or incomparable.",
61
+ path: qualityPath,
62
+ }];
63
+ }
64
+ return [];
65
+ }
66
+
31
67
  async function buildTaskInspection({ target, packageRoot, taskId, state, classifiedStatus = null }) {
32
68
  const trace = await buildTaskTrace({ target, packageRoot, taskId });
33
69
  const events = await readEvents(target, packageRoot, { taskId });
@@ -39,6 +75,22 @@ async function buildTaskInspection({ target, packageRoot, taskId, state, classif
39
75
  )].sort();
40
76
 
41
77
  const issues = [];
78
+ let structuralQuality;
79
+ try {
80
+ structuralQuality = await projectStructuralQualityStatus({ target, packageRoot, taskId });
81
+ } catch (error) {
82
+ structuralQuality = {
83
+ mode: "off",
84
+ provider: null,
85
+ baseline: { status: "INVALID", qualitySignal: null, artifactRef: null, fingerprint: null },
86
+ current: { status: "NOT_OBSERVED", verificationCycle: null, attempt: null, qualitySignal: null, delta: null, bottleneck: null, artifactRef: null },
87
+ comparable: null,
88
+ completionRequired: false,
89
+ reasonCodes: [error.code ?? "E_STRUCTURAL_QUALITY_EVIDENCE_STALE"],
90
+ next: null,
91
+ };
92
+ }
93
+ issues.push(...structuralQualityIssues(structuralQuality, taskId));
42
94
  if (!trace.snapshot.consistent) {
43
95
  issues.push({ code: "E_TRACE_SNAPSHOT_INCONSISTENT", message: "Task artifacts changed while being read; rerun inspect for a consistent view." });
44
96
  }
@@ -98,6 +150,7 @@ async function buildTaskInspection({ target, packageRoot, taskId, state, classif
98
150
  valid: trace.integrity.valid,
99
151
  errors: trace.integrity.errors,
100
152
  },
153
+ structuralQuality,
101
154
  audit: {},
102
155
  completion: {},
103
156
  issues,
@@ -213,6 +266,17 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
213
266
  const taskInspection = taskId
214
267
  ? await buildTaskInspection({ target, packageRoot, taskId, state: rawState, classifiedStatus: classifiedState.status })
215
268
  : null;
269
+ for (const issue of taskInspection?.issues ?? []) {
270
+ if (!issue.code.startsWith("E_STRUCTURAL_QUALITY")) continue;
271
+ findings.push({
272
+ code: issue.code,
273
+ severity: "error",
274
+ path: issue.path,
275
+ message: issue.message,
276
+ remediation: `Run forgeloop next --task ${taskId} --json and follow the structural-quality guidance.`,
277
+ evidence: createEvidence({ kind: "BLOCKED", source: issue.path, result: issue.code }),
278
+ });
279
+ }
216
280
  return {
217
281
  target: { path: target },
218
282
  authority: trustedAuthorityConfiguration({ target, authorityContext, runtimeContext }),
@@ -32,6 +32,7 @@ const READ_ONLY_COMMANDS = Object.freeze(new Set([
32
32
  "efficiency",
33
33
  "workspace-status", "handoff-list", "handoff-show", "responsibility-status",
34
34
  "attestation-verify", "attestation-status", "attestation-verify-range",
35
+ "quality-status",
35
36
  ]));
36
37
 
37
38
  const LOOP_MUTATION_COMMANDS = Object.freeze(new Set([
@@ -41,7 +42,7 @@ const LOOP_MUTATION_COMMANDS = Object.freeze(new Set([
41
42
  "record-terminal-result", "complete",
42
43
  "record-intervention", "record-hypothesis-disposition",
43
44
  "usage-record",
44
- "workspace-bind", "handoff-create", "responsibility-set", "verify-scope", "attestation-create",
45
+ "workspace-bind", "handoff-create", "handoff-accept", "responsibility-set", "verify-scope", "attestation-create",
45
46
  ]));
46
47
 
47
48
  const STATIC_RISK_CLASSES = Object.freeze({
@@ -49,6 +50,8 @@ const STATIC_RISK_CLASSES = Object.freeze({
49
50
  ...Object.fromEntries([...LOOP_MUTATION_COMMANDS].map((name) => [name, INTEGRATION_RISK_CLASSES.LOOP_MUTATION])),
50
51
  "task-resume": INTEGRATION_RISK_CLASSES.CLAIM_REACQUISITION,
51
52
  "run-check": INTEGRATION_RISK_CLASSES.EXTERNAL_EXECUTION,
53
+ "quality-baseline": INTEGRATION_RISK_CLASSES.EXTERNAL_EXECUTION,
54
+ "quality-verify": INTEGRATION_RISK_CLASSES.EXTERNAL_EXECUTION,
52
55
  "run-action": INTEGRATION_RISK_CLASSES.EXTERNAL_EXECUTION,
53
56
  "reconcile-closure": INTEGRATION_RISK_CLASSES.EXTERNAL_EXECUTION,
54
57
  "action-propose": INTEGRATION_RISK_CLASSES.LOOP_MUTATION,
@@ -186,10 +189,27 @@ export function getForgeLoopCapabilities({ packageVersion = null } = {}) {
186
189
  explicitRebinding: false,
187
190
  },
188
191
  canonicalHandoffs: {
189
- version: 1,
192
+ version: 2,
190
193
  supported: true,
191
194
  immutable: true,
192
195
  lifecycleAuthority: false,
196
+ evidenceAuthority: false,
197
+ exactlyOnceAcceptance: true,
198
+ acceptanceLedgerBacked: true,
199
+ acceptanceCommand: "handoff-accept",
200
+ acceptanceStatuses: ["OPEN", "ACCEPTED", "UNBOUND", "INCONSISTENT"],
201
+ },
202
+ advisoryContextProviders: {
203
+ version: 1,
204
+ supported: true,
205
+ providerNeutral: true,
206
+ integrationApiOnly: true,
207
+ lazy: true,
208
+ optIn: true,
209
+ persistedByForgeLoop: false,
210
+ lifecycleAuthority: false,
211
+ evidenceAuthority: false,
212
+ executable: false,
193
213
  },
194
214
  responsibilityConstraints: {
195
215
  version: 1,
@@ -203,6 +223,17 @@ export function getForgeLoopCapabilities({ packageVersion = null } = {}) {
203
223
  modes: ["AUTO", "CHANGED", "CLAIMED", "FULL"],
204
224
  impactedMode: false,
205
225
  },
226
+ structuralQuality: {
227
+ version: 1,
228
+ supported: true,
229
+ schemaVersion: 1,
230
+ providerNeutral: true,
231
+ modes: ["off", "observe", "gate"],
232
+ builtInProviders: ["sentrux"],
233
+ commands: ["quality-baseline", "quality-verify", "quality-status"],
234
+ baselineImmutableAfterExecution: true,
235
+ maxOutputBytes: 2 * 1024 * 1024,
236
+ },
206
237
  codeAttestation: {
207
238
  version: 1,
208
239
  supported: true,
@@ -232,6 +263,7 @@ export function getForgeLoopCapabilities({ packageVersion = null } = {}) {
232
263
  { name: "task/responsibility", scope: "TASK" },
233
264
  { name: "task/verification-scope", scope: "TASK" },
234
265
  { name: "task/attestation", scope: "TASK" },
266
+ { name: "task/structural-quality", scope: "TASK" },
235
267
  { name: "task/actions", scope: "TASK" },
236
268
  { name: "task/action", scope: "TASK" },
237
269
  { name: "task/approvals", scope: "TASK" },
@@ -14,10 +14,12 @@ import { readJsonArtifact } from "./artifacts.js";
14
14
  import { taskDirectory } from "./task-paths.js";
15
15
  import { resolveWorkspaceBindingStatus } from "./workspace-binding.js";
16
16
  import { listCanonicalHandoffs } from "./handoff.js";
17
+ import { readHandoffAcceptanceLedger, resolveHandoffAcceptance } from "./handoff-acceptance.js";
17
18
  import { resolveResponsibilityStatus } from "./responsibility.js";
18
19
  import { readVerificationScope } from "./verification-scope.js";
19
20
  import { resolveAttestationStatus } from "./attestation.js";
20
21
  import { buildExecutionProfileContext } from "./execution-profile-context.js";
22
+ import { projectStructuralQualityStatus } from "./structural-quality/service.js";
21
23
 
22
24
  /**
23
25
  * Canonical integration resource allowlist.
@@ -77,6 +79,7 @@ export const INTEGRATION_RESOURCE_DEFINITIONS = Object.freeze({
77
79
  scope: "TASK",
78
80
  description: "Local code-attestation status and trust level for one task.",
79
81
  }),
82
+ "task/structural-quality": Object.freeze({ scope: "TASK", description: "Read-only structural-quality baseline, evaluation, comparison, and next-action projection." }),
80
83
  "task/actions": Object.freeze({ scope: "TASK", description: "Canonical durable action summaries for one task." }),
81
84
  "task/action": Object.freeze({ scope: "TASK", description: "One canonical durable action artifact." }),
82
85
  "task/approvals": Object.freeze({ scope: "TASK", description: "Durable approval artifacts for one task." }),
@@ -157,6 +160,14 @@ export async function readForgeLoopIntegrationResource(uri, {
157
160
  }
158
161
  break;
159
162
  }
163
+ case "task/structural-quality": {
164
+ if (typeof taskId !== "string" || !taskId) {
165
+ const error = new Error(`Resource ${uri} requires a taskId`);
166
+ error.code = "E_TASK_REQUIRED";
167
+ throw error;
168
+ }
169
+ break;
170
+ }
160
171
  case "task/actions":
161
172
  case "task/action":
162
173
  case "task/approvals":
@@ -174,12 +185,38 @@ export async function readForgeLoopIntegrationResource(uri, {
174
185
  const projection = await resolveTaskClaimState(projectPath, { taskId, packageRoot });
175
186
  return { uri, taskId, data: ownershipProjection(projection) };
176
187
  }
188
+ if (uri === "task/structural-quality") {
189
+ return {
190
+ uri,
191
+ taskId,
192
+ data: await projectStructuralQualityStatus({ projectRoot: projectPath, target: projectPath, packageRoot, taskId }),
193
+ };
194
+ }
177
195
  if (uri === "task/workspace-binding") {
178
196
  return { uri, taskId, data: await resolveWorkspaceBindingStatus(projectPath, { packageRoot, taskId }) };
179
197
  }
180
198
  if (uri === "task/handoffs") {
181
199
  const handoffs = await listCanonicalHandoffs(projectPath, { packageRoot, taskId });
182
- return { uri, taskId, data: { taskId, count: handoffs.length, handoffs } };
200
+ const ledger = await readHandoffAcceptanceLedger(projectPath, packageRoot, { taskId });
201
+ const projectedHandoffs = handoffs.map((handoff) => {
202
+ const resolved = resolveHandoffAcceptance({
203
+ events: ledger.events,
204
+ handoff,
205
+ ledgerValid: ledger.valid,
206
+ ledgerErrors: ledger.errors,
207
+ });
208
+ return {
209
+ ...handoff,
210
+ acceptance: {
211
+ status: resolved.status,
212
+ consumerId: resolved.consumerId ?? null,
213
+ harness: resolved.harness ?? null,
214
+ acceptedAt: resolved.acceptedAt ?? null,
215
+ ...(resolved.reasonCodes ? { reasonCodes: [...resolved.reasonCodes] } : {}),
216
+ },
217
+ };
218
+ });
219
+ return { uri, taskId, data: { taskId, count: projectedHandoffs.length, handoffs: projectedHandoffs } };
183
220
  }
184
221
  if (uri === "task/responsibility") {
185
222
  return { uri, taskId, data: await resolveResponsibilityStatus(projectPath, { packageRoot, taskId }) };
@@ -12,6 +12,10 @@ export const NEXT_ACTIONS = Object.freeze({
12
12
  ENTER_VERIFYING: "ENTER_VERIFYING",
13
13
  CONTINUE_IMPLEMENTATION: "CONTINUE_IMPLEMENTATION",
14
14
  RECORD_VERIFICATION: "RECORD_VERIFICATION",
15
+ CAPTURE_STRUCTURAL_QUALITY_BASELINE: "CAPTURE_STRUCTURAL_QUALITY_BASELINE",
16
+ VERIFY_STRUCTURAL_QUALITY: "VERIFY_STRUCTURAL_QUALITY",
17
+ DIAGNOSE_STRUCTURAL_QUALITY_REGRESSION: "DIAGNOSE_STRUCTURAL_QUALITY_REGRESSION",
18
+ RESOLVE_STRUCTURAL_QUALITY_BLOCKER: "RESOLVE_STRUCTURAL_QUALITY_BLOCKER",
15
19
  DIAGNOSE: "DIAGNOSE",
16
20
  RECORD_DIAGNOSIS: "RECORD_DIAGNOSIS",
17
21
  CORRECT: "CORRECT",
@@ -118,6 +122,7 @@ export function result({
118
122
  approvalRequired = undefined,
119
123
  capabilityDecision = undefined,
120
124
  reconciliationAuthorityRequired = undefined,
125
+ optionalActions = undefined,
121
126
  }) {
122
127
  const normalizedReasons = reasons
123
128
  .map((reason) => {
@@ -158,6 +163,7 @@ export function result({
158
163
  ...(reconciliationAuthorityRequired
159
164
  ? { reconciliationAuthorityRequired: structuredClone(reconciliationAuthorityRequired) }
160
165
  : {}),
166
+ ...(optionalActions?.length ? { optionalActions: structuredClone(optionalActions) } : {}),
161
167
  };
162
168
  }
163
169
 
@@ -167,6 +173,9 @@ export function commandFor(action) {
167
173
  [NEXT_ACTIONS.RUN_PREFLIGHT]: "forgeloop preflight --json",
168
174
  [NEXT_ACTIONS.START_EXECUTION]: "forgeloop advance --to EXECUTING",
169
175
  [NEXT_ACTIONS.ENTER_VERIFYING]: "forgeloop advance --to VERIFYING",
176
+ [NEXT_ACTIONS.CAPTURE_STRUCTURAL_QUALITY_BASELINE]: "forgeloop quality-baseline --task <id> --json",
177
+ [NEXT_ACTIONS.VERIFY_STRUCTURAL_QUALITY]: "forgeloop quality-verify --task <id> --json",
178
+ [NEXT_ACTIONS.DIAGNOSE_STRUCTURAL_QUALITY_REGRESSION]: "forgeloop advance --to DIAGNOSING",
170
179
  [NEXT_ACTIONS.DIAGNOSE]: "forgeloop advance --to DIAGNOSING",
171
180
  [NEXT_ACTIONS.CORRECT]: "forgeloop advance --to CORRECTING",
172
181
  [NEXT_ACTIONS.ENTER_REVIEWING]: "forgeloop advance --to REVIEWING",
@@ -237,7 +246,7 @@ export function recordTerminalResultCommandSpec(requirement) {
237
246
  };
238
247
  }
239
248
 
240
- export function decision(input, action, reason, requiredArtifacts = [], missingArtifacts = []) {
249
+ export function decision(input, action, reason, requiredArtifacts = [], missingArtifacts = [], optionalActions = undefined) {
241
250
  const command = commandFor(action);
242
251
  return result({
243
252
  ...input,
@@ -246,6 +255,7 @@ export function decision(input, action, reason, requiredArtifacts = [], missingA
246
255
  ...(command ? { commands: [command] } : {}),
247
256
  requiredArtifacts,
248
257
  missingArtifacts,
258
+ optionalActions,
249
259
  });
250
260
  }
251
261