@cassiomc1/forgeloop 1.9.0 → 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 (57) hide show
  1. package/AGENT_COMPATIBILITY.md +15 -0
  2. package/DELEGATION_PROTOCOL.md +6 -0
  3. package/DOCS_INDEX.md +4 -2
  4. package/LOOP_ENGINEERING.md +13 -0
  5. package/LOOP_SYSTEM_DESIGN.md +33 -0
  6. package/ORCHESTRATOR_INTEGRATION.md +9 -0
  7. package/PROTOCOL_INTEGRATION.md +31 -0
  8. package/README.md +40 -0
  9. package/TERMINOLOGY.md +12 -0
  10. package/THREAT_MODEL.md +24 -0
  11. package/completions/_forgeloop +2 -1
  12. package/completions/forgeloop.bash +3 -1
  13. package/completions/forgeloop.fish +9 -1
  14. package/docs/ADVISORY_CONTEXT.md +174 -0
  15. package/docs/AGENT_PROTOCOL_SUMMARY.md +28 -2
  16. package/docs/ARTIFACT_REFERENCE.md +14 -0
  17. package/docs/CLI_REFERENCE.md +40 -0
  18. package/docs/CROSS_HARNESS_CONTINUITY.md +85 -0
  19. package/docs/DOCUMENTATION_GUIDE.md +7 -0
  20. package/docs/GETTING_STARTED.md +22 -0
  21. package/docs/KNOWLEDGE_SOURCES.md +10 -0
  22. package/docs/MCP.md +17 -1
  23. package/docs/RECIPES.md +80 -0
  24. package/docs/RELEASE_CHECKLIST.md +14 -0
  25. package/docs/TROUBLESHOOTING.md +54 -2
  26. package/docs/UNIVERSAL_INTEGRATION.md +60 -0
  27. package/package.json +2 -1
  28. package/schemas/handoff-envelope.schema.json +1 -0
  29. package/scripts/check-changelog-freshness.mjs +27 -3
  30. package/scripts/generate-agent-protocol-summary.mjs +18 -0
  31. package/src/cli.js +6 -0
  32. package/src/commands/handoff-accept.js +36 -0
  33. package/src/commands/handoff-list.js +28 -2
  34. package/src/commands/handoff-show.js +27 -2
  35. package/src/commands/reconcile-continuity.js +4 -0
  36. package/src/core/advisory-context/constants.js +74 -0
  37. package/src/core/advisory-context/provider.js +287 -0
  38. package/src/core/advisory-context/service.js +140 -0
  39. package/src/core/cli-command-definitions.js +17 -0
  40. package/src/core/command-executors.js +12 -0
  41. package/src/core/command-input.js +11 -1
  42. package/src/core/continuity-lint.js +89 -0
  43. package/src/core/continuity-reconciliation.js +16 -0
  44. package/src/core/continuity.js +10 -11
  45. package/src/core/error-codes.js +113 -0
  46. package/src/core/events.js +32 -0
  47. package/src/core/execution-profile-context.js +15 -1
  48. package/src/core/filesystem.js +18 -2
  49. package/src/core/handoff-acceptance.js +277 -0
  50. package/src/core/handoff.js +41 -8
  51. package/src/core/integration-invocation-policy.js +19 -2
  52. package/src/core/integration-resources.js +21 -1
  53. package/src/core/portable-context.js +103 -0
  54. package/src/core/protocol-info.js +18 -2
  55. package/src/core/runtime-context.js +31 -0
  56. package/src/integration.d.ts +116 -0
  57. package/src/integration.js +22 -0
@@ -0,0 +1,140 @@
1
+ import path from "node:path";
2
+
3
+ import {
4
+ normalizePortableText,
5
+ assertPortableContextSafe,
6
+ } from "../portable-context.js";
7
+ import {
8
+ ADVISORY_CONTEXT_LIMITS,
9
+ normalizeAdvisoryRecallOptions,
10
+ } from "./constants.js";
11
+ import {
12
+ normalizeAdvisoryContextResult,
13
+ resolveAdvisoryContextProvider,
14
+ } from "./provider.js";
15
+ import {
16
+ E_ADVISORY_CONTEXT_PROVIDER_INVALID,
17
+ E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE,
18
+ E_ADVISORY_CONTEXT_QUERY_INVALID,
19
+ E_ADVISORY_CONTEXT_TIMEOUT,
20
+ E_PORTABLE_CONTEXT_INVALID,
21
+ } from "../error-codes.js";
22
+
23
+ function serviceError(code, message, cause) {
24
+ const error = new Error(message, cause !== undefined ? { cause } : undefined);
25
+ error.name = "AdvisoryContextServiceError";
26
+ error.code = code;
27
+ return error;
28
+ }
29
+
30
+ export async function recallAdvisoryContext({
31
+ target,
32
+ taskId,
33
+ providerName,
34
+ query,
35
+ limit,
36
+ maxItemChars,
37
+ maxTotalChars,
38
+ timeoutMs,
39
+ runtimeContext,
40
+ } = {}) {
41
+ if (!target || typeof target !== "string") {
42
+ throw serviceError(
43
+ E_ADVISORY_CONTEXT_PROVIDER_INVALID,
44
+ "Target project path is required for advisory recall",
45
+ );
46
+ }
47
+
48
+ if (!taskId || typeof taskId !== "string") {
49
+ throw serviceError(
50
+ E_ADVISORY_CONTEXT_PROVIDER_INVALID,
51
+ "taskId is required for advisory recall",
52
+ );
53
+ }
54
+
55
+ if (!providerName || typeof providerName !== "string") {
56
+ throw serviceError(
57
+ E_ADVISORY_CONTEXT_PROVIDER_INVALID,
58
+ "providerName is required for advisory recall",
59
+ );
60
+ }
61
+
62
+ let normalizedQuery;
63
+ try {
64
+ normalizedQuery = normalizePortableText(query, {
65
+ label: "advisory query",
66
+ maxLength: ADVISORY_CONTEXT_LIMITS.maxQueryChars,
67
+ });
68
+ assertPortableContextSafe(normalizedQuery, { label: "advisory query" });
69
+ } catch (err) {
70
+ if (err.code === E_PORTABLE_CONTEXT_INVALID) {
71
+ throw err;
72
+ }
73
+ throw serviceError(
74
+ E_ADVISORY_CONTEXT_QUERY_INVALID,
75
+ `Advisory context query is invalid: ${err.message}`,
76
+ err,
77
+ );
78
+ }
79
+
80
+ const effectiveOptions = normalizeAdvisoryRecallOptions({
81
+ limit,
82
+ maxItemChars,
83
+ maxTotalChars,
84
+ timeoutMs,
85
+ });
86
+
87
+ let provider;
88
+ try {
89
+ provider = await resolveAdvisoryContextProvider({
90
+ providers: runtimeContext?.advisoryContextProviders,
91
+ providerName,
92
+ });
93
+ } catch (err) {
94
+ throw serviceError(
95
+ E_ADVISORY_CONTEXT_PROVIDER_INVALID,
96
+ `Advisory context provider "${providerName}" failed validation: ${err.message}`,
97
+ err,
98
+ );
99
+ }
100
+
101
+ if (!provider) {
102
+ throw serviceError(
103
+ E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE,
104
+ `Advisory context provider "${providerName}" is not registered in runtime context`,
105
+ );
106
+ }
107
+
108
+ let timer;
109
+ const timeoutPromise = new Promise((_, reject) => {
110
+ timer = setTimeout(() => {
111
+ reject(
112
+ serviceError(
113
+ E_ADVISORY_CONTEXT_TIMEOUT,
114
+ `Advisory recall from provider "${providerName}" timed out after ${effectiveOptions.timeoutMs}ms`,
115
+ ),
116
+ );
117
+ }, effectiveOptions.timeoutMs);
118
+ });
119
+
120
+ let rawResult;
121
+ try {
122
+ const recallPromise = Promise.resolve(
123
+ provider.recall({
124
+ projectPath: path.resolve(target),
125
+ taskId,
126
+ query: normalizedQuery,
127
+ ...effectiveOptions,
128
+ }),
129
+ );
130
+ rawResult = await Promise.race([recallPromise, timeoutPromise]);
131
+ } finally {
132
+ clearTimeout(timer);
133
+ }
134
+
135
+ return normalizeAdvisoryContextResult(rawResult, {
136
+ provider,
137
+ taskId,
138
+ ...effectiveOptions,
139
+ });
140
+ }
@@ -1172,6 +1172,23 @@ export const CLI_COMMAND_DEFINITIONS = Object.freeze({
1172
1172
  mayExecuteExternalProcess: false,
1173
1173
  description: "Reads and verifies one immutable handoff snapshot.",
1174
1174
  }),
1175
+ "handoff-accept": Object.freeze({
1176
+ name: "handoff-accept",
1177
+ category: "task",
1178
+ mutation: "MUTATING",
1179
+ options: Object.freeze({
1180
+ ...CLI_COMMON_OPTIONS,
1181
+ ...CLI_TASK_OPTION,
1182
+ "--handoff": Object.freeze({ targetKey: "handoffId", parseType: "string", takesValue: true, valueName: "id", missingValueMessage: "--handoff requires a handoff ID", description: "handoff identifier" }),
1183
+ "--consumer-id": Object.freeze({ targetKey: "consumerId", parseType: "string", takesValue: true, valueName: "id", missingValueMessage: "--consumer-id requires a consumer ID", description: "consumer identifier accepting the handoff" }),
1184
+ "--harness": Object.freeze({ targetKey: "harness", parseType: "string", takesValue: true, valueName: "name", missingValueMessage: "--harness requires a harness name", description: "optional harness accepting the handoff" }),
1185
+ "--json": Object.freeze({ targetKey: "json", parseType: "boolean", takesValue: false, description: "emit acceptance result as JSON" }),
1186
+ }),
1187
+ writes: [".forgeloop/task-state/<taskKey>/events.ndjson"],
1188
+ removes: [],
1189
+ mayExecuteExternalProcess: false,
1190
+ description: "Accepts an immutable handoff exactly once in the task event ledger.",
1191
+ }),
1175
1192
  "responsibility-set": Object.freeze({
1176
1193
  name: "responsibility-set",
1177
1194
  category: "scope",
@@ -73,6 +73,7 @@ import { runWorkspaceStatus } from "../commands/workspace-status.js";
73
73
  import { runHandoffCreate } from "../commands/handoff-create.js";
74
74
  import { runHandoffList } from "../commands/handoff-list.js";
75
75
  import { runHandoffShow } from "../commands/handoff-show.js";
76
+ import { runHandoffAccept } from "../commands/handoff-accept.js";
76
77
  import { runResponsibilitySet } from "../commands/responsibility-set.js";
77
78
  import { runResponsibilityStatus } from "../commands/responsibility-status.js";
78
79
  import { runVerifyScope } from "../commands/verify-scope.js";
@@ -236,6 +237,17 @@ export const COMMAND_EXECUTORS = {
236
237
  result: await runHandoffShow({ target, packageRoot, taskId: options.taskId, handoffId: options.handoffId }),
237
238
  exitCode: 0,
238
239
  }),
240
+ "handoff-accept": async ({ target, packageRoot, options }) => ({
241
+ result: await runHandoffAccept({
242
+ target,
243
+ packageRoot,
244
+ taskId: options.taskId,
245
+ handoffId: options.handoffId,
246
+ consumerId: options.consumerId,
247
+ harness: options.harness,
248
+ }),
249
+ exitCode: 0,
250
+ }),
239
251
  "responsibility-set": async ({ target, packageRoot, options }) => ({
240
252
  result: await runResponsibilitySet({
241
253
  target,
@@ -70,6 +70,8 @@ export function defaultCommandInputValues() {
70
70
  recipientHint: null,
71
71
  handoffNote: null,
72
72
  handoffId: null,
73
+ consumerId: null,
74
+ harness: null,
73
75
  responsibilityLabel: null,
74
76
  responsibilityAllowedPaths: [],
75
77
  responsibilityReadOnlyPaths: [],
@@ -148,13 +150,21 @@ export function validateForgeLoopCommandInput({ command, input, help = false } =
148
150
  if (options.compact === true && !["next", "task-show"].includes(command)) {
149
151
  throw inputError(`compact output is not valid for ${command}`);
150
152
  }
151
- if (["workspace-bind", "workspace-status", "handoff-create", "handoff-list", "handoff-show", "responsibility-set", "responsibility-status", "verify-scope", "attestation-create", "attestation-status", "attestation-verify"].includes(command)
153
+ if (["workspace-bind", "workspace-status", "handoff-create", "handoff-list", "handoff-show", "handoff-accept", "responsibility-set", "responsibility-status", "verify-scope", "attestation-create", "attestation-status", "attestation-verify"].includes(command)
152
154
  && !options.taskId) {
153
155
  throw inputError(`${command} requires --task`);
154
156
  }
155
157
  if (command === "handoff-show" && !help && !options.handoffId) {
156
158
  throw inputError("handoff-show requires --id");
157
159
  }
160
+ if (command === "handoff-accept" && !help) {
161
+ if (!options.handoffId) {
162
+ throw inputError("handoff-accept requires --handoff");
163
+ }
164
+ if (!options.consumerId) {
165
+ throw inputError("handoff-accept requires --consumer-id");
166
+ }
167
+ }
158
168
  if (command === "responsibility-set" && !help && !options.responsibilityLabel) {
159
169
  throw inputError("responsibility-set requires --label");
160
170
  }
@@ -0,0 +1,89 @@
1
+ import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
2
+
3
+ function finding(code, severity, field, itemId = null) {
4
+ return { code, severity, field, itemId };
5
+ }
6
+
7
+ function hasOperationalHints(continuity) {
8
+ return Boolean(
9
+ continuity.currentFocus
10
+ || continuity.remainingWork?.length
11
+ || continuity.knownIssues?.length
12
+ || continuity.changedAreas?.length
13
+ || continuity.inspectFirst?.length
14
+ || (typeof continuity.resumeNote === "string" && continuity.resumeNote.trim() !== ""),
15
+ );
16
+ }
17
+
18
+ export async function lintContinuity({ target, continuity, state } = {}) {
19
+ const findings = [];
20
+ if (!continuity || typeof continuity !== "object" || Array.isArray(continuity)) {
21
+ return { status: "PASS", findings };
22
+ }
23
+
24
+ const completedSteps = new Set(
25
+ Array.isArray(state?.completedSteps) ? state.completedSteps : [],
26
+ );
27
+
28
+ for (const [index, item] of (continuity.remainingWork ?? []).entries()) {
29
+ if (!item?.id) continue;
30
+ if (completedSteps.has(item.id)) {
31
+ findings.push(finding(
32
+ "CONTINUITY_REMAINING_ALREADY_COMPLETED",
33
+ "WARN",
34
+ `remainingWork[${index}]`,
35
+ item.id,
36
+ ));
37
+ }
38
+ }
39
+
40
+ if (continuity.currentFocus?.id && completedSteps.has(continuity.currentFocus.id)) {
41
+ findings.push(finding(
42
+ "CONTINUITY_FOCUS_ALREADY_COMPLETED",
43
+ "WARN",
44
+ "currentFocus",
45
+ continuity.currentFocus.id,
46
+ ));
47
+ }
48
+
49
+ const knownIssueIds = new Set(
50
+ (continuity.knownIssues ?? []).map((item) => item?.id).filter(Boolean),
51
+ );
52
+ for (const [index, item] of (continuity.remainingWork ?? []).entries()) {
53
+ if (item?.id && knownIssueIds.has(item.id)) {
54
+ findings.push(finding(
55
+ "CONTINUITY_ITEM_ROLE_CONFLICT",
56
+ "WARN",
57
+ `remainingWork[${index}]`,
58
+ item.id,
59
+ ));
60
+ }
61
+ }
62
+
63
+ if (typeof target === "string" && target.trim() !== "") {
64
+ for (const [index, inspectPath] of (continuity.inspectFirst ?? []).entries()) {
65
+ try {
66
+ await assertSafePath(target, inspectPath);
67
+ if (!(await fileExists(ensureWithin(target, inspectPath)))) {
68
+ findings.push(finding(
69
+ "CONTINUITY_INSPECT_PATH_MISSING",
70
+ "WARN",
71
+ `inspectFirst[${index}]`,
72
+ ));
73
+ }
74
+ } catch {
75
+ // Invalid or unsafe paths are rejected by continuity schema validation;
76
+ // lint never resolves an unchecked path or turns it into authority.
77
+ }
78
+ }
79
+ }
80
+
81
+ if (!hasOperationalHints(continuity)) {
82
+ findings.push(finding("CONTINUITY_EMPTY_HINT_SET", "INFO", "continuity"));
83
+ }
84
+
85
+ return {
86
+ status: findings.some((item) => item.severity === "WARN") ? "WARN" : "PASS",
87
+ findings,
88
+ };
89
+ }
@@ -1,6 +1,7 @@
1
1
  import { canonicalFingerprint } from "./artifacts.js";
2
2
  import { assertContinuitySemantics, readContinuity } from "./continuity.js";
3
3
  import { WORK_TRANSITIONS } from "./protocol.js";
4
+ import { lintContinuity } from "./continuity-lint.js";
4
5
 
5
6
  const RECONCILIATION_CODE = "E_CONTINUITY_RECONCILIATION_REQUIRED";
6
7
 
@@ -188,6 +189,7 @@ export async function reconcileContinuity({ target, packageRoot, taskId = null }
188
189
  path: ".forgeloop/continuity.json",
189
190
  present: false,
190
191
  latestHandoff,
192
+ lint: { status: "PASS", findings: [] },
191
193
  diagnosticContext: await deriveDiagnosticContextSafe({ target, packageRoot, state }),
192
194
  };
193
195
  }
@@ -200,6 +202,15 @@ export async function reconcileContinuity({ target, packageRoot, taskId = null }
200
202
  present: true,
201
203
  error: error.message,
202
204
  latestHandoff,
205
+ lint: {
206
+ status: "WARN",
207
+ findings: [{
208
+ code: error.code ?? "CONTINUITY_INVALID",
209
+ severity: "WARN",
210
+ field: "continuity",
211
+ itemId: null,
212
+ }],
213
+ },
203
214
  };
204
215
  }
205
216
 
@@ -229,6 +240,11 @@ export async function reconcileContinuity({ target, packageRoot, taskId = null }
229
240
  fingerprint: continuityArtifact.fingerprint,
230
241
  continuity: continuityArtifact.value,
231
242
  latestHandoff,
243
+ lint: await lintContinuity({
244
+ target,
245
+ continuity: continuityArtifact.value,
246
+ state,
247
+ }),
232
248
  diagnosticContext: await deriveDiagnosticContextSafe({ target, packageRoot, state }),
233
249
  };
234
250
  }
@@ -9,7 +9,7 @@ import {
9
9
  } from "./artifacts.js";
10
10
  import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
11
11
  import { PROTOCOL_VERSION, WORK_PHASES } from "./protocol.js";
12
- import { assertSecretFree } from "./receipt.js";
12
+ import { normalizePortableText, assertPortableContextSafe } from "./portable-context.js";
13
13
  import { getPackageRoot } from "./templates.js";
14
14
  import { taskArtifactPath } from "./task-paths.js";
15
15
 
@@ -37,16 +37,11 @@ function continuityError(code, message, artifacts = [CONTINUITY_PATH]) {
37
37
  }
38
38
 
39
39
  function nonEmptyString(value, label, maxLength) {
40
- if (typeof value !== "string" || value.trim() === "") {
41
- throw continuityError("E_CONTINUITY_INVALID", `${label} must be a non-empty string`);
40
+ try {
41
+ return normalizePortableText(value, { label, maxLength });
42
+ } catch (error) {
43
+ throw continuityError("E_CONTINUITY_INVALID", error.message);
42
44
  }
43
- if (value.length > maxLength) {
44
- throw continuityError("E_CONTINUITY_INVALID", `${label} exceeds the ${maxLength}-character limit`);
45
- }
46
- if (/\p{Cc}/u.test(value)) {
47
- throw continuityError("E_CONTINUITY_INVALID", `${label} contains control characters`);
48
- }
49
- return value;
50
45
  }
51
46
 
52
47
  function fingerprint(value, label) {
@@ -171,7 +166,11 @@ export function assertContinuitySemantics(input) {
171
166
  ? { resumeNote: nonEmptyString(input.resumeNote, "resumeNote", LIMITS.resumeNote) }
172
167
  : {}),
173
168
  };
174
- assertSecretFree(normalized);
169
+ try {
170
+ assertPortableContextSafe(normalized);
171
+ } catch (error) {
172
+ throw continuityError("E_CONTINUITY_INVALID", error.message);
173
+ }
175
174
  return normalized;
176
175
  }
177
176
 
@@ -105,6 +105,19 @@ export const E_STRUCTURAL_QUALITY_OBSERVATION_EPOCH_STALE = "E_STRUCTURAL_QUALIT
105
105
  export const E_STRUCTURAL_QUALITY_PROJECTION_INCOMPLETE = "E_STRUCTURAL_QUALITY_PROJECTION_INCOMPLETE";
106
106
  export const E_STRUCTURAL_QUALITY_REGRESSION = "E_STRUCTURAL_QUALITY_REGRESSION";
107
107
 
108
+ export const E_ADVISORY_CONTEXT_PROVIDER_INVALID = "E_ADVISORY_CONTEXT_PROVIDER_INVALID";
109
+ export const E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE = "E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE";
110
+ export const E_ADVISORY_CONTEXT_QUERY_INVALID = "E_ADVISORY_CONTEXT_QUERY_INVALID";
111
+ export const E_ADVISORY_CONTEXT_REQUEST_INVALID = "E_ADVISORY_CONTEXT_REQUEST_INVALID";
112
+ export const E_ADVISORY_CONTEXT_RESULT_INVALID = "E_ADVISORY_CONTEXT_RESULT_INVALID";
113
+ export const E_ADVISORY_CONTEXT_TIMEOUT = "E_ADVISORY_CONTEXT_TIMEOUT";
114
+ export const E_ADVISORY_CONTEXT_OUTPUT_LIMIT = "E_ADVISORY_CONTEXT_OUTPUT_LIMIT";
115
+ export const E_PORTABLE_CONTEXT_INVALID = "E_PORTABLE_CONTEXT_INVALID";
116
+ export const E_HANDOFF_ACCEPTANCE_UNBOUND = "E_HANDOFF_ACCEPTANCE_UNBOUND";
117
+ export const E_HANDOFF_STALE = "E_HANDOFF_STALE";
118
+ export const E_HANDOFF_ALREADY_ACCEPTED = "E_HANDOFF_ALREADY_ACCEPTED";
119
+ export const E_HANDOFF_ACCEPTANCE_INCONSISTENT = "E_HANDOFF_ACCEPTANCE_INCONSISTENT";
120
+
108
121
  const STRUCTURAL_QUALITY_ERROR_METADATA = Object.freeze(Object.fromEntries([
109
122
  [E_STRUCTURAL_QUALITY_CONFIGURATION_INVALID, "Correct structuralQuality mode, provider ID, budgets, floors, or optimization limits in .forgeloop/config.json."],
110
123
  [E_STRUCTURAL_QUALITY_PROVIDER_INVALID, "Use a provider implementing id, detect(input), and scan(input) with the documented normalized boundary."],
@@ -273,12 +286,100 @@ export const E_APPROVAL_ALREADY_RESOLVED = "E_APPROVAL_ALREADY_RESOLVED";
273
286
  export const E_TRAJECTORY_SCENARIO_INVALID = "E_TRAJECTORY_SCENARIO_INVALID";
274
287
  export const E_TRAJECTORY_REFERENCE_REQUIRED = "E_TRAJECTORY_REFERENCE_REQUIRED";
275
288
 
289
+ const ADVISORY_CONTEXT_AND_HANDOFF_ERROR_METADATA = Object.freeze(Object.fromEntries([
290
+ [E_ADVISORY_CONTEXT_PROVIDER_INVALID, Object.freeze({
291
+ code: E_ADVISORY_CONTEXT_PROVIDER_INVALID,
292
+ category: "advisory-context",
293
+ classification: "PUBLIC_STABLE",
294
+ meaning: "Advisory context provider configuration or interface implementation is invalid.",
295
+ safeResolution: "Use a provider implementing id, recall(input) with bounded query parameters; advisory context is optional.",
296
+ })],
297
+ [E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE, Object.freeze({
298
+ code: E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE,
299
+ category: "advisory-context",
300
+ classification: "PUBLIC_STABLE",
301
+ meaning: "Requested advisory context provider is not registered in runtime context.",
302
+ safeResolution: "Register the provider in runtime context before recall, or proceed without advisory context; provider failure never blocks canonical lifecycle.",
303
+ })],
304
+ [E_ADVISORY_CONTEXT_QUERY_INVALID, Object.freeze({
305
+ code: E_ADVISORY_CONTEXT_QUERY_INVALID,
306
+ category: "advisory-context",
307
+ classification: "PUBLIC_STABLE",
308
+ meaning: "Advisory context query failed portable-context validation or exceeded budget.",
309
+ safeResolution: "Provide a bounded query free of control characters and secret-like values.",
310
+ })],
311
+ [E_ADVISORY_CONTEXT_REQUEST_INVALID, Object.freeze({
312
+ code: E_ADVISORY_CONTEXT_REQUEST_INVALID,
313
+ category: "advisory-context",
314
+ classification: "PUBLIC_STABLE",
315
+ meaning: "Advisory context recall budgets are not finite integer values within the supported request contract.",
316
+ safeResolution: "Provide finite integer limit, maxItemChars, maxTotalChars, and timeoutMs values; oversized valid values are clamped to documented maxima.",
317
+ })],
318
+ [E_ADVISORY_CONTEXT_RESULT_INVALID, Object.freeze({
319
+ code: E_ADVISORY_CONTEXT_RESULT_INVALID,
320
+ category: "advisory-context",
321
+ classification: "PUBLIC_STABLE",
322
+ meaning: "Advisory context provider returned an invalid result structure.",
323
+ safeResolution: "Ensure provider returns items with string summary and optional title, sourceRef, observedAt, confidence.",
324
+ })],
325
+ [E_ADVISORY_CONTEXT_TIMEOUT, Object.freeze({
326
+ code: E_ADVISORY_CONTEXT_TIMEOUT,
327
+ category: "advisory-context",
328
+ classification: "PUBLIC_STABLE",
329
+ meaning: "Advisory context recall exceeded its execution timeout.",
330
+ safeResolution: "Use a responsive provider or increase timeout within limits; advisory context is optional.",
331
+ })],
332
+ [E_ADVISORY_CONTEXT_OUTPUT_LIMIT, Object.freeze({
333
+ code: E_ADVISORY_CONTEXT_OUTPUT_LIMIT,
334
+ category: "advisory-context",
335
+ classification: "PUBLIC_STABLE",
336
+ meaning: "Advisory context output exceeded the configured character or item limit.",
337
+ safeResolution: "Reduce query scope, limit items, or truncate oversized summaries at the provider.",
338
+ })],
339
+ [E_PORTABLE_CONTEXT_INVALID, Object.freeze({
340
+ code: E_PORTABLE_CONTEXT_INVALID,
341
+ category: "portable-context",
342
+ classification: "PUBLIC_STABLE",
343
+ meaning: "Text or object failed portable-context safety, character, or secret limits.",
344
+ safeResolution: "Ensure text is bounded, contains no control characters, and contains no secret-like values.",
345
+ })],
346
+ [E_HANDOFF_ACCEPTANCE_UNBOUND, Object.freeze({
347
+ code: E_HANDOFF_ACCEPTANCE_UNBOUND,
348
+ category: "handoff",
349
+ classification: "PUBLIC_STABLE",
350
+ meaning: "Handoff snapshot lacks required workStateFingerprint binding.",
351
+ safeResolution: "Create a fresh handoff from the current ForgeLoop version before accepting it.",
352
+ })],
353
+ [E_HANDOFF_STALE, Object.freeze({
354
+ code: E_HANDOFF_STALE,
355
+ category: "handoff",
356
+ classification: "PUBLIC_STABLE",
357
+ meaning: "Handoff snapshot has drifted from the current canonical task state or repository.",
358
+ safeResolution: "Create a new fresh handoff from the current task state instead of accepting a stale snapshot.",
359
+ })],
360
+ [E_HANDOFF_ALREADY_ACCEPTED, Object.freeze({
361
+ code: E_HANDOFF_ALREADY_ACCEPTED,
362
+ category: "handoff",
363
+ classification: "PUBLIC_STABLE",
364
+ meaning: "Handoff was already accepted by a different consumer.",
365
+ safeResolution: "Create a new handoff for the new consumer; do not manually edit acceptance events.",
366
+ })],
367
+ [E_HANDOFF_ACCEPTANCE_INCONSISTENT, Object.freeze({
368
+ code: E_HANDOFF_ACCEPTANCE_INCONSISTENT,
369
+ category: "handoff",
370
+ classification: "PUBLIC_STABLE",
371
+ meaning: "Handoff acceptance disagrees with task event ledger history.",
372
+ safeResolution: "Verify ledger integrity and require a preceding valid HANDOFF_CREATED event.",
373
+ })],
374
+ ]));
375
+
276
376
  /**
277
377
  * Public, stable ForgeLoop error and reason codes documented for users and harnesses.
278
378
  */
279
379
  export const PUBLIC_ERROR_CODES = Object.freeze({
280
380
  ...EXTENSION_PUBLIC_ERROR_CODES,
281
381
  ...STRUCTURAL_QUALITY_ERROR_METADATA,
382
+ ...ADVISORY_CONTEXT_AND_HANDOFF_ERROR_METADATA,
282
383
  E_PREFLIGHT_NOT_READY: Object.freeze({
283
384
  code: "E_PREFLIGHT_NOT_READY",
284
385
  category: "preflight",
@@ -1205,6 +1306,18 @@ export const ALL_KNOWN_ERROR_CODES = Object.freeze(new Set([
1205
1306
  E_FAILURE_SIGNATURE_INVALID,
1206
1307
  E_STRATEGY_OSCILLATION,
1207
1308
  "E_TRACE_SNAPSHOT_INCONSISTENT",
1309
+ E_ADVISORY_CONTEXT_PROVIDER_INVALID,
1310
+ E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE,
1311
+ E_ADVISORY_CONTEXT_QUERY_INVALID,
1312
+ E_ADVISORY_CONTEXT_REQUEST_INVALID,
1313
+ E_ADVISORY_CONTEXT_RESULT_INVALID,
1314
+ E_ADVISORY_CONTEXT_TIMEOUT,
1315
+ E_ADVISORY_CONTEXT_OUTPUT_LIMIT,
1316
+ E_PORTABLE_CONTEXT_INVALID,
1317
+ E_HANDOFF_ACCEPTANCE_UNBOUND,
1318
+ E_HANDOFF_STALE,
1319
+ E_HANDOFF_ALREADY_ACCEPTED,
1320
+ E_HANDOFF_ACCEPTANCE_INCONSISTENT,
1208
1321
  ]));
1209
1322
 
1210
1323
  /**
@@ -160,6 +160,16 @@ export function validateKnownEventDetails(event) {
160
160
  }
161
161
  assertFingerprint(event.details.digest, "HANDOFF_CREATED details.digest");
162
162
  return;
163
+ case "HANDOFF_ACCEPTED":
164
+ if (!event.details || typeof event.details !== "object" || Array.isArray(event.details)
165
+ || typeof event.details.handoffId !== "string" || !/^handoff-[A-Za-z0-9_-]+$/.test(event.details.handoffId)
166
+ || typeof event.details.handoffDigest !== "string"
167
+ || typeof event.details.consumerId !== "string" || !event.details.consumerId.trim()
168
+ || (event.details.harness !== undefined && (typeof event.details.harness !== "string" || !event.details.harness.trim()))) {
169
+ throw protocolError("E_EVENT_INVALID", "HANDOFF_ACCEPTED requires a valid handoffId, handoffDigest, and consumerId");
170
+ }
171
+ assertFingerprint(event.details.handoffDigest, "HANDOFF_ACCEPTED details.handoffDigest");
172
+ return;
163
173
  case "RESPONSIBILITY_SET":
164
174
  assertStructuredArtifactEvent(event, ["responsibilityFingerprint"], "RESPONSIBILITY_SET");
165
175
  if (typeof event.details.label !== "string" || !event.details.label) {
@@ -513,6 +523,8 @@ export async function validateEventLedger(target, packageRoot, options = {}) {
513
523
  const seen = new Set();
514
524
  let lastMilestone = -1;
515
525
  const milestoneCounts = new Map();
526
+ const createdHandoffs = new Map();
527
+ const acceptedHandoffs = new Set();
516
528
  for (const [index, event] of events.entries()) {
517
529
  if (event.seq !== index + 1) {
518
530
  errors.push({ code: "E_EVENT_INVALID", message: `event sequence must be ${index + 1}` });
@@ -584,6 +596,26 @@ export async function validateEventLedger(target, packageRoot, options = {}) {
584
596
  if (event.event === "COMPLETION_REJECTED" && !seen.has("VERIFICATION_STARTED")) {
585
597
  errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: "completion rejected before verification started" });
586
598
  }
599
+ if (event.event === "HANDOFF_CREATED") {
600
+ if (event.details?.handoffId) {
601
+ createdHandoffs.set(event.details.handoffId, event.details.digest);
602
+ }
603
+ }
604
+ if (event.event === "HANDOFF_ACCEPTED") {
605
+ const hId = event.details?.handoffId;
606
+ if (hId) {
607
+ if (acceptedHandoffs.has(hId)) {
608
+ errors.push({ code: "E_HANDOFF_ALREADY_ACCEPTED", message: `duplicate HANDOFF_ACCEPTED for handoffId ${hId}` });
609
+ } else {
610
+ acceptedHandoffs.add(hId);
611
+ if (!createdHandoffs.has(hId)) {
612
+ errors.push({ code: "E_HANDOFF_ACCEPTANCE_INCONSISTENT", message: `HANDOFF_ACCEPTED has no preceding HANDOFF_CREATED for handoffId ${hId}` });
613
+ } else if (createdHandoffs.get(hId) !== event.details?.handoffDigest) {
614
+ errors.push({ code: "E_HANDOFF_ACCEPTANCE_INCONSISTENT", message: `HANDOFF_ACCEPTED digest mismatch for handoffId ${hId}` });
615
+ }
616
+ }
617
+ }
618
+ }
587
619
  }
588
620
  validateLegacyRecoveryMigrations(events, errors, {
589
621
  allowUnmigratedLegacyRecoveryEvents: options?.allowUnmigratedLegacyRecoveryEvents === true,
@@ -112,6 +112,7 @@ export function projectExecutionProfileContext({
112
112
  route,
113
113
  state,
114
114
  nextAction,
115
+ runtimeContext,
115
116
  } = {}) {
116
117
  if (typeof taskId !== "string" || !taskId) throw contextError("E_TASK_REQUIRED", "taskId is required");
117
118
  if (!contract || typeof contract !== "object" || Array.isArray(contract)) {
@@ -126,6 +127,18 @@ export function projectExecutionProfileContext({
126
127
  const profile = route.executionProfile ? assertExecutionProfile(route.executionProfile) : legacyExecutionProfile();
127
128
  const resolvedProfile = profile.resolved;
128
129
  const policy = getExecutionProfileContextPolicy(resolvedProfile);
130
+ const available = [...OPTIONAL_CONTEXT_BY_PROFILE[resolvedProfile]];
131
+ const advisoryProviders = runtimeContext?.advisoryContextProviders;
132
+ const hasAdvisory = Boolean(
133
+ advisoryProviders && (
134
+ advisoryProviders instanceof Map
135
+ ? advisoryProviders.size > 0
136
+ : Object.keys(advisoryProviders).length > 0
137
+ ),
138
+ );
139
+ if (hasAdvisory && !available.includes("advisory-context")) {
140
+ available.push("advisory-context");
141
+ }
129
142
  return {
130
143
  schemaVersion: 1,
131
144
  protocolVersion: 1,
@@ -140,7 +153,7 @@ export function projectExecutionProfileContext({
140
153
  verificationRequirements: verificationRequirements(contract),
141
154
  contextPolicy: policy,
142
155
  optionalContext: {
143
- available: [...OPTIONAL_CONTEXT_BY_PROFILE[resolvedProfile]],
156
+ available,
144
157
  loaded: [],
145
158
  },
146
159
  invariants: { ...PROFILE_INVARIANTS },
@@ -173,5 +186,6 @@ export async function buildExecutionProfileContext({
173
186
  route: routeArtifact.value,
174
187
  state,
175
188
  nextAction,
189
+ runtimeContext,
176
190
  });
177
191
  }
@@ -73,11 +73,27 @@ export function ensureWithin(root, relativePath) {
73
73
  return path.join(root, normalized);
74
74
  }
75
75
 
76
+ function normalizeWindowsPathForComparison(value) {
77
+ const lowerValue = value.toLowerCase();
78
+ let normalized = value;
79
+ if (lowerValue.startsWith("\\\\?\\unc\\")) {
80
+ normalized = `\\\\${value.slice(8)}`;
81
+ } else if (lowerValue.startsWith("\\\\.\\unc\\")) {
82
+ normalized = `\\\\${value.slice(8)}`;
83
+ } else if (lowerValue.startsWith("\\\\?\\")) {
84
+ normalized = value.slice(4);
85
+ } else if (lowerValue.startsWith("\\\\.\\")) {
86
+ normalized = value.slice(4);
87
+ }
88
+ return path.win32.normalize(normalized).toLowerCase();
89
+ }
90
+
76
91
  export function isPathWithin(root, candidate, { platform = process.platform } = {}) {
77
92
  const pathApi = platform === "win32" ? path.win32 : path;
78
93
  const normalizeForComparison = (value) => {
79
- const normalized = pathApi.normalize(value);
80
- return platform === "win32" ? normalized.toLowerCase() : normalized;
94
+ return platform === "win32"
95
+ ? normalizeWindowsPathForComparison(value)
96
+ : pathApi.normalize(value);
81
97
  };
82
98
  const relative = pathApi.relative(
83
99
  normalizeForComparison(root),