@cassiomc1/forgeloop 1.10.1 → 1.10.2
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/DOCS_INDEX.md +3 -1
- package/README.md +9 -8
- package/docs/ADVISORY_CONTEXT.md +24 -0
- package/docs/AGENT_PROTOCOL_SUMMARY.md +1 -1
- package/docs/MCP.md +1 -1
- package/docs/PACKAGE_CONTENTS.md +5 -0
- package/docs/RELEASE_CHECKLIST.md +2 -2
- package/docs/RIPWIRE_ADAPTER.md +189 -0
- package/docs/diagrams/README.md +9 -0
- package/package.json +2 -1
- package/src/adapters/ripwire/normalize.js +352 -0
- package/src/adapters/ripwire/process.js +248 -0
- package/src/adapters/ripwire/provider.js +245 -0
- package/src/core/advisory-context/service.js +36 -14
- package/src/core/handoff-acceptance.js +10 -1
- package/src/core/work-state.js +15 -6
- package/src/integration.d.ts +11 -0
- package/src/integration.js +1 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { assertSafePath } from "../../core/filesystem.js";
|
|
4
|
+
import {
|
|
5
|
+
normalizeAdvisoryRecallOptions,
|
|
6
|
+
} from "../../core/advisory-context/constants.js";
|
|
7
|
+
import {
|
|
8
|
+
E_ADVISORY_CONTEXT_PROVIDER_INVALID,
|
|
9
|
+
E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE,
|
|
10
|
+
E_ADVISORY_CONTEXT_RESULT_INVALID,
|
|
11
|
+
E_ADVISORY_CONTEXT_TIMEOUT,
|
|
12
|
+
} from "../../core/error-codes.js";
|
|
13
|
+
import {
|
|
14
|
+
RIPWIRE_PROCESS_LIMITS,
|
|
15
|
+
parseRipwireVersion,
|
|
16
|
+
runRipwireCommand,
|
|
17
|
+
} from "./process.js";
|
|
18
|
+
import {
|
|
19
|
+
extractCandidateRows,
|
|
20
|
+
getRipwireRowPath,
|
|
21
|
+
normalizeReportedPath,
|
|
22
|
+
normalizeRipwireResult,
|
|
23
|
+
} from "./normalize.js";
|
|
24
|
+
|
|
25
|
+
function providerError(code, message) {
|
|
26
|
+
const error = new Error(message);
|
|
27
|
+
error.name = "RipwireAdvisoryProviderError";
|
|
28
|
+
error.code = code;
|
|
29
|
+
return error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assertAbsoluteExecutablePath(value) {
|
|
33
|
+
if (typeof value !== "string" || value.trim() === "" || !path.isAbsolute(value) || /\p{Cc}/u.test(value)) {
|
|
34
|
+
throw providerError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire executablePath must be an absolute portable path");
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function assertExpectedVersion(value) {
|
|
40
|
+
if (
|
|
41
|
+
typeof value !== "string"
|
|
42
|
+
|| value.trim() === ""
|
|
43
|
+
|| value.length > 64
|
|
44
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._+-]*$/u.test(value)
|
|
45
|
+
) {
|
|
46
|
+
throw providerError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire expectedVersion must be a qualified version token under 64 characters");
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function assertProcessLimit(value, maximum, label) {
|
|
52
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
|
53
|
+
throw providerError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, `${label} must be an integer between 1 and ${maximum}`);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function remainingTimeout(deadline) {
|
|
59
|
+
const remaining = deadline - Date.now();
|
|
60
|
+
if (remaining < 1) {
|
|
61
|
+
throw providerError(E_ADVISORY_CONTEXT_TIMEOUT, "Ripwire advisory recall deadline expired");
|
|
62
|
+
}
|
|
63
|
+
return remaining;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function reportTransport(callback, payload) {
|
|
67
|
+
if (typeof callback !== "function") return;
|
|
68
|
+
try {
|
|
69
|
+
callback(Object.freeze({ ...payload }));
|
|
70
|
+
} catch {
|
|
71
|
+
// Observability hooks cannot change advisory correctness or failure mapping.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function runAndReport(executablePath, args, {
|
|
76
|
+
cwd,
|
|
77
|
+
timeoutMs,
|
|
78
|
+
spawnImpl,
|
|
79
|
+
env,
|
|
80
|
+
maxStdoutBytes,
|
|
81
|
+
maxStderrBytes,
|
|
82
|
+
onTransport,
|
|
83
|
+
kind,
|
|
84
|
+
} = {}) {
|
|
85
|
+
const result = await runRipwireCommand(executablePath, args, {
|
|
86
|
+
cwd,
|
|
87
|
+
timeoutMs,
|
|
88
|
+
spawnImpl,
|
|
89
|
+
env,
|
|
90
|
+
maxStdoutBytes,
|
|
91
|
+
maxStderrBytes,
|
|
92
|
+
});
|
|
93
|
+
reportTransport(onTransport, {
|
|
94
|
+
kind,
|
|
95
|
+
stdoutBytes: result.stdoutBytes,
|
|
96
|
+
stderrBytes: result.stderrBytes,
|
|
97
|
+
durationMs: result.durationMs,
|
|
98
|
+
});
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function queryArguments(projectPath, query) {
|
|
103
|
+
return [
|
|
104
|
+
projectPath,
|
|
105
|
+
`--for=${query}`,
|
|
106
|
+
"--signatures-only",
|
|
107
|
+
"--json",
|
|
108
|
+
"--no-cache",
|
|
109
|
+
"--exclude=.forgeloop",
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Create an optional host-injected Ripwire adapter.
|
|
115
|
+
*
|
|
116
|
+
* Construction is inert: it validates the explicit executable/version pair
|
|
117
|
+
* but does not discover binaries, spawn processes, access the network, or
|
|
118
|
+
* write lifecycle state. Version qualification is repeated lazily before a
|
|
119
|
+
* query so a changed executable cannot silently change the provider identity.
|
|
120
|
+
*/
|
|
121
|
+
export function createRipwireAdvisoryContextProvider({
|
|
122
|
+
executablePath,
|
|
123
|
+
expectedVersion,
|
|
124
|
+
spawnImpl,
|
|
125
|
+
env,
|
|
126
|
+
maxStdoutBytes = RIPWIRE_PROCESS_LIMITS.maxStdoutBytes,
|
|
127
|
+
maxStderrBytes = RIPWIRE_PROCESS_LIMITS.maxStderrBytes,
|
|
128
|
+
onTransport,
|
|
129
|
+
} = {}) {
|
|
130
|
+
const qualifiedExecutablePath = assertAbsoluteExecutablePath(executablePath);
|
|
131
|
+
const qualifiedExpectedVersion = assertExpectedVersion(expectedVersion);
|
|
132
|
+
const qualifiedStdoutLimit = assertProcessLimit(maxStdoutBytes, RIPWIRE_PROCESS_LIMITS.maxStdoutBytes, "Ripwire stdout limit");
|
|
133
|
+
const qualifiedStderrLimit = assertProcessLimit(maxStderrBytes, RIPWIRE_PROCESS_LIMITS.maxStderrBytes, "Ripwire stderr limit");
|
|
134
|
+
|
|
135
|
+
const provider = {
|
|
136
|
+
id: "ripwire",
|
|
137
|
+
version: qualifiedExpectedVersion,
|
|
138
|
+
async recall({
|
|
139
|
+
projectPath,
|
|
140
|
+
taskId,
|
|
141
|
+
query,
|
|
142
|
+
limit,
|
|
143
|
+
maxItemChars,
|
|
144
|
+
maxTotalChars,
|
|
145
|
+
timeoutMs,
|
|
146
|
+
} = {}) {
|
|
147
|
+
if (typeof projectPath !== "string" || !path.isAbsolute(projectPath) || /\p{Cc}/u.test(projectPath)) {
|
|
148
|
+
throw providerError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire projectPath must be an absolute path");
|
|
149
|
+
}
|
|
150
|
+
if (typeof taskId !== "string" || taskId.trim() === "") {
|
|
151
|
+
throw providerError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire taskId must be a non-empty string");
|
|
152
|
+
}
|
|
153
|
+
if (typeof query !== "string" || query.trim() === "" || /\p{Cc}/u.test(query)) {
|
|
154
|
+
throw providerError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire query must be a non-empty portable string");
|
|
155
|
+
}
|
|
156
|
+
const options = normalizeAdvisoryRecallOptions({ limit, maxItemChars, maxTotalChars, timeoutMs });
|
|
157
|
+
const projectRoot = path.resolve(projectPath);
|
|
158
|
+
try {
|
|
159
|
+
await assertSafePath(projectRoot, ".");
|
|
160
|
+
} catch {
|
|
161
|
+
throw providerError(E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE, "Ripwire target directory is unavailable or unsafe");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
165
|
+
let versionResult;
|
|
166
|
+
try {
|
|
167
|
+
versionResult = await runAndReport(qualifiedExecutablePath, ["--version"], {
|
|
168
|
+
cwd: projectRoot,
|
|
169
|
+
timeoutMs: remainingTimeout(deadline),
|
|
170
|
+
spawnImpl,
|
|
171
|
+
env,
|
|
172
|
+
maxStdoutBytes: Math.min(qualifiedStdoutLimit, 64 * 1024),
|
|
173
|
+
maxStderrBytes: qualifiedStderrLimit,
|
|
174
|
+
onTransport,
|
|
175
|
+
kind: "version",
|
|
176
|
+
});
|
|
177
|
+
} catch (error) {
|
|
178
|
+
throw error.code ? error : providerError(E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE, "Ripwire version probe failed");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let actualVersion;
|
|
182
|
+
try {
|
|
183
|
+
actualVersion = parseRipwireVersion(versionResult.stdout);
|
|
184
|
+
} catch {
|
|
185
|
+
throw providerError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire version probe was not qualified");
|
|
186
|
+
}
|
|
187
|
+
if (actualVersion !== qualifiedExpectedVersion) {
|
|
188
|
+
throw providerError(
|
|
189
|
+
E_ADVISORY_CONTEXT_PROVIDER_INVALID,
|
|
190
|
+
"Ripwire executable version does not match the expected qualified version",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let queryResult;
|
|
195
|
+
try {
|
|
196
|
+
queryResult = await runAndReport(qualifiedExecutablePath, queryArguments(projectRoot, query), {
|
|
197
|
+
cwd: projectRoot,
|
|
198
|
+
timeoutMs: remainingTimeout(deadline),
|
|
199
|
+
spawnImpl,
|
|
200
|
+
env,
|
|
201
|
+
maxStdoutBytes: qualifiedStdoutLimit,
|
|
202
|
+
maxStderrBytes: qualifiedStderrLimit,
|
|
203
|
+
onTransport,
|
|
204
|
+
kind: "query",
|
|
205
|
+
});
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw error.code ? error : providerError(E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE, "Ripwire query failed");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let raw;
|
|
211
|
+
try {
|
|
212
|
+
raw = JSON.parse(queryResult.stdout);
|
|
213
|
+
} catch {
|
|
214
|
+
throw providerError(E_ADVISORY_CONTEXT_RESULT_INVALID, "Ripwire query did not return valid JSON");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
218
|
+
throw providerError(E_ADVISORY_CONTEXT_RESULT_INVALID, "Ripwire query JSON root must be an object");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const safeSourcePaths = new Set();
|
|
222
|
+
const rows = extractCandidateRows(raw) ?? [];
|
|
223
|
+
for (const row of rows) {
|
|
224
|
+
const relativePath = normalizeReportedPath(getRipwireRowPath(row), projectRoot);
|
|
225
|
+
if (!relativePath) continue;
|
|
226
|
+
try {
|
|
227
|
+
await assertSafePath(projectRoot, relativePath);
|
|
228
|
+
safeSourcePaths.add(relativePath);
|
|
229
|
+
} catch {
|
|
230
|
+
// The normalizer will disclose and drop this candidate.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return normalizeRipwireResult(raw, {
|
|
235
|
+
projectPath: projectRoot,
|
|
236
|
+
...options,
|
|
237
|
+
sourcePathValidator: (relativePath) => safeSourcePaths.has(relativePath),
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
return Object.freeze(provider);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export { queryArguments };
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
E_PORTABLE_CONTEXT_INVALID,
|
|
21
21
|
} from "../error-codes.js";
|
|
22
22
|
|
|
23
|
+
const PROVIDER_CLEANUP_GRACE_MS = 1000;
|
|
24
|
+
|
|
23
25
|
function serviceError(code, message, cause) {
|
|
24
26
|
const error = new Error(message, cause !== undefined ? { cause } : undefined);
|
|
25
27
|
error.name = "AdvisoryContextServiceError";
|
|
@@ -106,30 +108,50 @@ export async function recallAdvisoryContext({
|
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
let timer;
|
|
111
|
+
let cleanupTimer;
|
|
112
|
+
const timeoutError = serviceError(
|
|
113
|
+
E_ADVISORY_CONTEXT_TIMEOUT,
|
|
114
|
+
`Advisory recall from provider "${providerName}" timed out after ${effectiveOptions.timeoutMs}ms`,
|
|
115
|
+
);
|
|
116
|
+
const recallPromise = Promise.resolve(
|
|
117
|
+
provider.recall({
|
|
118
|
+
projectPath: path.resolve(target),
|
|
119
|
+
taskId,
|
|
120
|
+
query: normalizedQuery,
|
|
121
|
+
...effectiveOptions,
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
let timedOut = false;
|
|
125
|
+
const guardedRecallPromise = recallPromise.then(
|
|
126
|
+
(value) => timedOut ? new Promise(() => {}) : value,
|
|
127
|
+
(error) => {
|
|
128
|
+
if (timedOut) return new Promise(() => {});
|
|
129
|
+
throw error;
|
|
130
|
+
},
|
|
131
|
+
);
|
|
109
132
|
const timeoutPromise = new Promise((_, reject) => {
|
|
110
133
|
timer = setTimeout(() => {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
134
|
+
timedOut = true;
|
|
135
|
+
cleanupTimer = setTimeout(() => reject(timeoutError), PROVIDER_CLEANUP_GRACE_MS);
|
|
136
|
+
recallPromise.then(
|
|
137
|
+
() => {
|
|
138
|
+
clearTimeout(cleanupTimer);
|
|
139
|
+
reject(timeoutError);
|
|
140
|
+
},
|
|
141
|
+
() => {
|
|
142
|
+
clearTimeout(cleanupTimer);
|
|
143
|
+
reject(timeoutError);
|
|
144
|
+
},
|
|
116
145
|
);
|
|
117
146
|
}, effectiveOptions.timeoutMs);
|
|
118
147
|
});
|
|
119
148
|
|
|
120
149
|
let rawResult;
|
|
121
150
|
try {
|
|
122
|
-
|
|
123
|
-
provider.recall({
|
|
124
|
-
projectPath: path.resolve(target),
|
|
125
|
-
taskId,
|
|
126
|
-
query: normalizedQuery,
|
|
127
|
-
...effectiveOptions,
|
|
128
|
-
}),
|
|
129
|
-
);
|
|
130
|
-
rawResult = await Promise.race([recallPromise, timeoutPromise]);
|
|
151
|
+
rawResult = await Promise.race([guardedRecallPromise, timeoutPromise]);
|
|
131
152
|
} finally {
|
|
132
153
|
clearTimeout(timer);
|
|
154
|
+
clearTimeout(cleanupTimer);
|
|
133
155
|
}
|
|
134
156
|
|
|
135
157
|
return normalizeAdvisoryContextResult(rawResult, {
|
|
@@ -126,7 +126,7 @@ export async function acceptCanonicalHandoff(target, {
|
|
|
126
126
|
assertPortableContextSafe(normalizedHarness, { label: "harness" });
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
const runAcceptance = () => withTaskTransaction(
|
|
130
130
|
{
|
|
131
131
|
target,
|
|
132
132
|
taskId,
|
|
@@ -274,4 +274,13 @@ export async function acceptCanonicalHandoff(target, {
|
|
|
274
274
|
};
|
|
275
275
|
},
|
|
276
276
|
);
|
|
277
|
+
|
|
278
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
279
|
+
try {
|
|
280
|
+
return await runAcceptance();
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (error?.code !== "E_TASK_LOCKED" || attempt === 2) throw error;
|
|
283
|
+
await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1)));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
277
286
|
}
|
package/src/core/work-state.js
CHANGED
|
@@ -307,12 +307,21 @@ export async function mutateWorkState(target, { expectedRevision, packageRoot =
|
|
|
307
307
|
throw error;
|
|
308
308
|
}
|
|
309
309
|
if (!(await getTaskTransaction(target))) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
310
|
+
try {
|
|
311
|
+
return await withTaskTransaction({
|
|
312
|
+
target,
|
|
313
|
+
taskId: taskId ?? "legacy-work-state",
|
|
314
|
+
lockTaskId: taskId ?? "legacy-work-state",
|
|
315
|
+
operation: "mutate-work-state",
|
|
316
|
+
}, async () => mutateWorkState(target, { expectedRevision, packageRoot, taskId, statePath }, updater));
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (error?.code === "E_TASK_LOCKED") {
|
|
319
|
+
const revisionError = new WorkStateError("Work state revision is being mutated concurrently");
|
|
320
|
+
revisionError.code = "E_STATE_REVISION_CONFLICT";
|
|
321
|
+
throw revisionError;
|
|
322
|
+
}
|
|
323
|
+
throw error;
|
|
324
|
+
}
|
|
316
325
|
}
|
|
317
326
|
const current = await readWorkState(target, { packageRoot, taskId, statePath });
|
|
318
327
|
if (!current || (current.revision ?? 0) !== expectedRevision) {
|
package/src/integration.d.ts
CHANGED
|
@@ -108,6 +108,14 @@ export interface ForgeLoopAdvisoryContextProvider {
|
|
|
108
108
|
|
|
109
109
|
export type ForgeLoopAdvisoryContextProviderFactory = () => ForgeLoopAdvisoryContextProvider | Promise<ForgeLoopAdvisoryContextProvider>;
|
|
110
110
|
|
|
111
|
+
/** Options for the optional, host-injected Ripwire advisory adapter. */
|
|
112
|
+
export interface ForgeLoopRipwireProviderOptions {
|
|
113
|
+
/** Absolute path selected by the host; ForgeLoop does not discover it. */
|
|
114
|
+
executablePath: string;
|
|
115
|
+
/** Exact version string qualified by the host and checked lazily at recall time. */
|
|
116
|
+
expectedVersion: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
111
119
|
export interface ForgeLoopNormalizedAdvisoryContextResult {
|
|
112
120
|
provider: {
|
|
113
121
|
id: string;
|
|
@@ -269,6 +277,9 @@ export declare function recallAdvisoryContext(input: {
|
|
|
269
277
|
timeoutMs?: number;
|
|
270
278
|
runtimeContext?: ForgeLoopContext | Record<string, unknown>;
|
|
271
279
|
}): Promise<ForgeLoopNormalizedAdvisoryContextResult>;
|
|
280
|
+
export declare function createRipwireAdvisoryContextProvider(
|
|
281
|
+
options: ForgeLoopRipwireProviderOptions,
|
|
282
|
+
): ForgeLoopAdvisoryContextProvider;
|
|
272
283
|
export declare const ADVISORY_CONTEXT_LIMITS: Readonly<Record<string, number>>;
|
|
273
284
|
export declare const ADVISORY_CONTEXT_TRUST: Readonly<Record<string, unknown>>;
|
|
274
285
|
export declare function normalizeAdvisoryRecallOptions(input?: Partial<ForgeLoopAdvisoryRecallOptions>): ForgeLoopAdvisoryRecallOptions;
|
package/src/integration.js
CHANGED
|
@@ -81,6 +81,7 @@ export {
|
|
|
81
81
|
VERIFICATION_ISOLATION_MODES,
|
|
82
82
|
} from "./core/verification-execution.js";
|
|
83
83
|
export { recallAdvisoryContext } from "./core/advisory-context/service.js";
|
|
84
|
+
export { createRipwireAdvisoryContextProvider } from "./adapters/ripwire/provider.js";
|
|
84
85
|
export {
|
|
85
86
|
ADVISORY_CONTEXT_LIMITS,
|
|
86
87
|
ADVISORY_CONTEXT_TRUST,
|