@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.
- package/AGENT_COMPATIBILITY.md +15 -0
- package/DELEGATION_PROTOCOL.md +6 -0
- package/DOCS_INDEX.md +4 -2
- package/LOOP_ENGINEERING.md +13 -0
- package/LOOP_SYSTEM_DESIGN.md +33 -0
- package/ORCHESTRATOR_INTEGRATION.md +9 -0
- package/PROTOCOL_INTEGRATION.md +31 -0
- package/README.md +40 -0
- package/TERMINOLOGY.md +12 -0
- package/THREAT_MODEL.md +24 -0
- package/completions/_forgeloop +2 -1
- package/completions/forgeloop.bash +3 -1
- package/completions/forgeloop.fish +9 -1
- package/docs/ADVISORY_CONTEXT.md +174 -0
- package/docs/AGENT_PROTOCOL_SUMMARY.md +28 -2
- package/docs/ARTIFACT_REFERENCE.md +14 -0
- package/docs/CLI_REFERENCE.md +40 -0
- package/docs/CROSS_HARNESS_CONTINUITY.md +85 -0
- package/docs/DOCUMENTATION_GUIDE.md +7 -0
- package/docs/GETTING_STARTED.md +22 -0
- package/docs/KNOWLEDGE_SOURCES.md +10 -0
- package/docs/MCP.md +17 -1
- package/docs/RECIPES.md +80 -0
- package/docs/RELEASE_CHECKLIST.md +14 -0
- package/docs/TROUBLESHOOTING.md +54 -2
- package/docs/UNIVERSAL_INTEGRATION.md +60 -0
- package/package.json +2 -1
- package/schemas/handoff-envelope.schema.json +1 -0
- package/scripts/check-changelog-freshness.mjs +27 -3
- package/scripts/generate-agent-protocol-summary.mjs +18 -0
- package/src/cli.js +6 -0
- package/src/commands/handoff-accept.js +36 -0
- package/src/commands/handoff-list.js +28 -2
- package/src/commands/handoff-show.js +27 -2
- package/src/commands/reconcile-continuity.js +4 -0
- package/src/core/advisory-context/constants.js +74 -0
- package/src/core/advisory-context/provider.js +287 -0
- package/src/core/advisory-context/service.js +140 -0
- package/src/core/cli-command-definitions.js +17 -0
- package/src/core/command-executors.js +12 -0
- package/src/core/command-input.js +11 -1
- package/src/core/continuity-lint.js +89 -0
- package/src/core/continuity-reconciliation.js +16 -0
- package/src/core/continuity.js +10 -11
- package/src/core/error-codes.js +113 -0
- package/src/core/events.js +32 -0
- package/src/core/execution-profile-context.js +15 -1
- package/src/core/filesystem.js +18 -2
- package/src/core/handoff-acceptance.js +277 -0
- package/src/core/handoff.js +41 -8
- package/src/core/integration-invocation-policy.js +19 -2
- package/src/core/integration-resources.js +21 -1
- package/src/core/portable-context.js +103 -0
- package/src/core/protocol-info.js +18 -2
- package/src/core/runtime-context.js +31 -0
- package/src/integration.d.ts +116 -0
- 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
|
}
|
package/src/core/continuity.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
package/src/core/error-codes.js
CHANGED
|
@@ -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
|
/**
|
package/src/core/events.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/core/filesystem.js
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
94
|
+
return platform === "win32"
|
|
95
|
+
? normalizeWindowsPathForComparison(value)
|
|
96
|
+
: pathApi.normalize(value);
|
|
81
97
|
};
|
|
82
98
|
const relative = pathApi.relative(
|
|
83
99
|
normalizeForComparison(root),
|