@cassiomc1/forgeloop 1.10.0 → 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 +12 -1
- package/LOOP_ENGINEERING.md +4 -4
- package/LOOP_SYSTEM_DESIGN.md +23 -0
- package/QUALITY_SCORECARD.md +2 -2
- package/README.md +22 -8
- package/docs/ADVISORY_CONTEXT.md +24 -0
- package/docs/AGENT_PROTOCOL_SUMMARY.md +1 -1
- package/docs/ARTIFACT_REFERENCE.md +15 -0
- package/docs/CODE_ATTESTATION.md +9 -0
- package/docs/DOCUMENTATION_GUIDE.md +4 -4
- package/docs/EXECUTION_PROFILE_BENCHMARKS.md +10 -0
- package/docs/MCP.md +13 -1
- package/docs/PACKAGE_CONTENTS.md +88 -0
- package/docs/RELEASE_CHECKLIST.md +10 -2
- package/docs/REVISION_PROVIDERS.md +9 -0
- package/docs/RIPWIRE_ADAPTER.md +189 -0
- package/docs/TROUBLESHOOTING.md +12 -0
- package/docs/assets/diagrams/forgeloop-code-attestation-flow.html +13 -2
- package/docs/assets/diagrams/forgeloop-code-attestation-flow.receipt.json +6 -6
- package/docs/assets/diagrams/forgeloop-code-attestation-flow.svg +10 -1
- package/docs/assets/diagrams/forgeloop-engineering-flow.html +22 -11
- package/docs/assets/diagrams/forgeloop-engineering-flow.receipt.json +6 -6
- package/docs/assets/diagrams/forgeloop-engineering-flow.svg +17 -8
- package/docs/assets/diagrams/forgeloop-verification-trust-flow.html +14 -3
- package/docs/assets/diagrams/forgeloop-verification-trust-flow.receipt.json +6 -6
- package/docs/assets/diagrams/forgeloop-verification-trust-flow.svg +10 -1
- package/docs/diagrams/README.md +26 -0
- package/docs/diagrams/forgeloop-code-attestation-flow.workflow.json +383 -57
- package/docs/diagrams/forgeloop-engineering-flow.workflow.json +374 -55
- package/docs/diagrams/forgeloop-verification-trust-flow.workflow.json +328 -47
- package/docs/diagrams/reviews/forgeloop-code-attestation-flow.review.json +4 -4
- package/docs/diagrams/reviews/forgeloop-engineering-flow.review.json +4 -4
- package/docs/diagrams/reviews/forgeloop-verification-trust-flow.review.json +4 -4
- package/package.json +14 -4
- package/scripts/CI_VALIDATORS.md +24 -0
- package/scripts/check-critical-coverage.mjs +9 -0
- 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/commands/doctor.js +11 -10
- package/src/core/actions.js +2 -2
- package/src/core/advisory-context/service.js +36 -14
- package/src/core/approvals.js +2 -2
- package/src/core/artifacts.js +3 -3
- package/src/core/checks.js +0 -33
- package/src/core/completion.js +2 -2
- package/src/core/events.js +5 -5
- package/src/core/execution-profile.js +18 -5
- package/src/core/handoff-acceptance.js +10 -1
- package/src/core/next-action-pending-actions.js +255 -0
- package/src/core/next-action-phases.js +26 -764
- package/src/core/next-action-planned-phase.js +51 -0
- package/src/core/next-action-quality-guidance.js +19 -0
- package/src/core/next-action-recovery-phases.js +97 -0
- package/src/core/next-action-refresh.js +20 -0
- package/src/core/next-action-review-phase.js +189 -0
- package/src/core/next-action-verification-phase.js +192 -0
- package/src/core/task-recovery.js +2 -2
- package/src/core/transaction-maintenance.js +70 -0
- package/src/core/transaction.js +31 -10
- package/src/core/work-state.js +20 -11
- package/src/integration.d.ts +30 -3
- package/src/integration.js +2 -0
- package/src/core/cli-metadata.js +0 -23
- package/src/core/decision-classification.js +0 -55
- package/src/core/gates.js +0 -57
- package/src/core/workflow-compatibility.js +0 -151
|
@@ -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 };
|
package/src/commands/doctor.js
CHANGED
|
@@ -116,16 +116,7 @@ async function adoptAdapters({ target, manifest, adoptPaths, findings }) {
|
|
|
116
116
|
|
|
117
117
|
export async function runDoctor({ target, packageRoot, adoptPaths = [], strict = false, fix = false }) {
|
|
118
118
|
const findings = [];
|
|
119
|
-
|
|
120
|
-
for (const transaction of incompleteTransactions) {
|
|
121
|
-
findings.push(finding(
|
|
122
|
-
"E_TRANSACTION_INCOMPLETE",
|
|
123
|
-
"error",
|
|
124
|
-
`.forgeloop/.txn/${transaction.transactionId}`,
|
|
125
|
-
`Transaction ${transaction.transactionId} is ${transaction.status} and requires inspection or deterministic recovery.`,
|
|
126
|
-
"Run forgeloop doctor --fix only after ensuring no active process owns the task lock.",
|
|
127
|
-
));
|
|
128
|
-
}
|
|
119
|
+
let incompleteTransactions = await findIncompleteTransactions(target);
|
|
129
120
|
if (fix && incompleteTransactions.some((transaction) => transaction.status === "COMMITTING")) {
|
|
130
121
|
const recovered = await recoverIncompleteTransactions(target);
|
|
131
122
|
for (const transaction of recovered) {
|
|
@@ -136,6 +127,16 @@ export async function runDoctor({ target, packageRoot, adoptPaths = [], strict =
|
|
|
136
127
|
`Transaction recovery result: ${transaction.status}.`,
|
|
137
128
|
));
|
|
138
129
|
}
|
|
130
|
+
incompleteTransactions = await findIncompleteTransactions(target);
|
|
131
|
+
}
|
|
132
|
+
for (const transaction of incompleteTransactions) {
|
|
133
|
+
findings.push(finding(
|
|
134
|
+
"E_TRANSACTION_INCOMPLETE",
|
|
135
|
+
"error",
|
|
136
|
+
`.forgeloop/.txn/${transaction.transactionId}`,
|
|
137
|
+
`Transaction ${transaction.transactionId} is ${transaction.status} and requires inspection or deterministic recovery.`,
|
|
138
|
+
"Run forgeloop doctor --fix only after ensuring no active process owns the task lock.",
|
|
139
|
+
));
|
|
139
140
|
}
|
|
140
141
|
let manifest = null;
|
|
141
142
|
let manifestChanged = false;
|
package/src/core/actions.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { getTaskTransaction, withTaskTransaction } from "./transaction.js";
|
|
5
5
|
import { appendProtocolEvent, readEvents } from "./events.js";
|
|
6
6
|
import {
|
|
7
7
|
canonicalActionFingerprint,
|
|
@@ -63,7 +63,7 @@ async function writeActionFile(target, packageRoot, taskId, action) {
|
|
|
63
63
|
const relPath = taskActionPath(taskId, action.actionId);
|
|
64
64
|
await assertSafePath(target, relPath);
|
|
65
65
|
const serialized = `${JSON.stringify(action, null, 2)}\n`;
|
|
66
|
-
const activeTransaction =
|
|
66
|
+
const activeTransaction = (await getTaskTransaction(target));
|
|
67
67
|
if (activeTransaction) {
|
|
68
68
|
await activeTransaction.stageText(relPath, serialized);
|
|
69
69
|
} else {
|
|
@@ -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, {
|
package/src/core/approvals.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { getTaskTransaction, withTaskTransaction } from "./transaction.js";
|
|
5
5
|
import { appendProtocolEvent } from "./events.js";
|
|
6
6
|
import { canonicalFingerprint } from "./artifacts.js";
|
|
7
7
|
import {
|
|
@@ -49,7 +49,7 @@ async function writeApprovalFile(target, taskId, approval) {
|
|
|
49
49
|
const relPath = taskApprovalPath(taskId, approval.approvalId);
|
|
50
50
|
await assertSafePath(target, relPath);
|
|
51
51
|
const serialized = `${JSON.stringify(approval, null, 2)}\n`;
|
|
52
|
-
const activeTransaction =
|
|
52
|
+
const activeTransaction = (await getTaskTransaction(target));
|
|
53
53
|
if (activeTransaction) {
|
|
54
54
|
await activeTransaction.stageText(relPath, serialized);
|
|
55
55
|
} else {
|
package/src/core/artifacts.js
CHANGED
|
@@ -5,7 +5,7 @@ import { assertSecretFree } from "./receipt.js";
|
|
|
5
5
|
import { assertJsonBytes, assertJsonLimits } from "./json-safety.js";
|
|
6
6
|
import { assertSchema, readSchema } from "./schema-validation.js";
|
|
7
7
|
import { getPackageRoot } from "./templates.js";
|
|
8
|
-
import {
|
|
8
|
+
import { getTaskTransaction, withTaskTransaction } from "./transaction.js";
|
|
9
9
|
|
|
10
10
|
export const ARTIFACT_PATHS = Object.freeze({
|
|
11
11
|
contract: ".forgeloop/current-contract.json",
|
|
@@ -76,7 +76,7 @@ export async function readJsonArtifact(
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
const artifactPath = ensureWithin(target, relativePath);
|
|
79
|
-
const transaction =
|
|
79
|
+
const transaction = (await getTaskTransaction(target));
|
|
80
80
|
const stagedText = transaction ? await transaction.readText(relativePath) : null;
|
|
81
81
|
if (stagedText === null && !(await fileExists(artifactPath))) {
|
|
82
82
|
throw new ArtifactError(
|
|
@@ -124,7 +124,7 @@ export async function writeJsonArtifact(
|
|
|
124
124
|
} catch (error) {
|
|
125
125
|
throw artifactError("ARTIFACT_PATH_INVALID", relativePath, error);
|
|
126
126
|
}
|
|
127
|
-
const activeTransaction =
|
|
127
|
+
const activeTransaction = (await getTaskTransaction(target));
|
|
128
128
|
if (!activeTransaction && taskId && !dryRun) {
|
|
129
129
|
return withTaskTransaction({ target, taskId, operation, packageRoot }, async () => (
|
|
130
130
|
writeJsonArtifact(target, relativePath, value, schemaName, packageRoot, { dryRun, taskId, operation })
|
package/src/core/checks.js
CHANGED
|
@@ -134,36 +134,3 @@ export function assertCheckList(value, label = "checks", options = {}) {
|
|
|
134
134
|
});
|
|
135
135
|
return value;
|
|
136
136
|
}
|
|
137
|
-
|
|
138
|
-
function requiredChecksSatisfiedBy(checks, requiredValues, selector, { allowInferred = false } = {}) {
|
|
139
|
-
assertCheckList(checks);
|
|
140
|
-
if (!Array.isArray(requiredValues)) throw checkError("E_CHECK_INVALID", "required values must be an array");
|
|
141
|
-
const errors = [];
|
|
142
|
-
for (const value of requiredValues) {
|
|
143
|
-
const candidates = checks.filter((check) => selector(check) === value);
|
|
144
|
-
const check = candidates.find((candidate) => candidate.status === "passed"
|
|
145
|
-
&& (allowInferred || candidate.evidenceKind === "OBSERVED"))
|
|
146
|
-
?? candidates.find((candidate) => candidate.status === "passed")
|
|
147
|
-
?? candidates[0];
|
|
148
|
-
if (!check) {
|
|
149
|
-
errors.push(checkError("E_EVIDENCE_REQUIRED", `Required check is missing: ${value}`, [value]));
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
if (check.status !== "passed") {
|
|
153
|
-
errors.push(checkError("E_EVIDENCE_REQUIRED", `Required check is not passed: ${value}`, [value]));
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
if (!allowInferred && check.evidenceKind !== "OBSERVED") {
|
|
157
|
-
errors.push(checkError("E_EVIDENCE_KIND_INVALID", `Required check must be observed: ${value}`, [value]));
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
return errors;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export function requiredChecksSatisfied(checks, requiredIds, options = {}) {
|
|
164
|
-
return requiredChecksSatisfiedBy(checks, requiredIds, (check) => check.id, options);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export function requiredChecksSatisfiedForRequirements(checks, requiredRequirements, options = {}) {
|
|
168
|
-
return requiredChecksSatisfiedBy(checks, requiredRequirements, (check) => check.requirement, options);
|
|
169
|
-
}
|
package/src/core/completion.js
CHANGED
|
@@ -17,7 +17,7 @@ import { listActions } from "./actions.js";
|
|
|
17
17
|
import { readConfig } from "./config.js";
|
|
18
18
|
import { resolveResponsibilityStatus } from "./responsibility.js";
|
|
19
19
|
import { createCodeManifest, readCodeManifest, validateCodeManifestBindings, writeCodeManifest } from "./code-manifest.js";
|
|
20
|
-
import {
|
|
20
|
+
import { getTaskTransaction, withTaskTransaction } from "./transaction.js";
|
|
21
21
|
import { validateStructuralQualityCheckProvenance } from "./structural-quality/service.js";
|
|
22
22
|
|
|
23
23
|
async function attestationConfiguration(target, packageRoot, errors) {
|
|
@@ -856,7 +856,7 @@ async function runCompleteInternal({
|
|
|
856
856
|
|
|
857
857
|
export async function runComplete(options = {}) {
|
|
858
858
|
const taskId = options.taskId ?? null;
|
|
859
|
-
if (options.persist !== false && taskId && !
|
|
859
|
+
if (options.persist !== false && taskId && !(await getTaskTransaction(options.target))) {
|
|
860
860
|
return withTaskTransaction({
|
|
861
861
|
target: options.target,
|
|
862
862
|
taskId,
|
package/src/core/events.js
CHANGED
|
@@ -11,7 +11,7 @@ import { PROTOCOL_VERSION } from "./protocol.js";
|
|
|
11
11
|
import { isRecoverableCompletionEvidenceCode } from "./completion-recovery.js";
|
|
12
12
|
|
|
13
13
|
import { taskArtifactPath } from "./task-paths.js";
|
|
14
|
-
import {
|
|
14
|
+
import { getTaskTransaction, withTaskTransaction } from "./transaction.js";
|
|
15
15
|
|
|
16
16
|
import { assertDiagnosisDetails } from "./diagnosis-model.js";
|
|
17
17
|
import {
|
|
@@ -311,7 +311,7 @@ export function buildProtocolEvent(input, { checkpoint } = {}) {
|
|
|
311
311
|
}
|
|
312
312
|
|
|
313
313
|
export async function previewProtocolEvent(target, input, packageRoot, options = {}) {
|
|
314
|
-
const activeTransaction =
|
|
314
|
+
const activeTransaction = (await getTaskTransaction(target));
|
|
315
315
|
if (!activeTransaction) {
|
|
316
316
|
return withTaskTransaction({
|
|
317
317
|
target,
|
|
@@ -337,7 +337,7 @@ export async function readEvents(target, packageRoot, options = {}) {
|
|
|
337
337
|
const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
|
|
338
338
|
await assertSafePath(target, relPath);
|
|
339
339
|
const eventsPath = ensureWithin(target, relPath);
|
|
340
|
-
const transaction =
|
|
340
|
+
const transaction = (await getTaskTransaction(target));
|
|
341
341
|
if (transaction) {
|
|
342
342
|
const stagedText = await transaction.readText(relPath);
|
|
343
343
|
if (stagedText !== null) return parseEventsText(stagedText, relPath, packageRoot);
|
|
@@ -355,7 +355,7 @@ export async function readEventTail(target, packageRoot, options = {}) {
|
|
|
355
355
|
const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
|
|
356
356
|
await assertSafePath(target, relPath);
|
|
357
357
|
const eventsPath = ensureWithin(target, relPath);
|
|
358
|
-
const transaction =
|
|
358
|
+
const transaction = (await getTaskTransaction(target));
|
|
359
359
|
if (transaction) {
|
|
360
360
|
const stagedText = await transaction.readText(relPath);
|
|
361
361
|
if (stagedText !== null) {
|
|
@@ -411,7 +411,7 @@ async function parseEventsText(text, relPath, packageRoot) {
|
|
|
411
411
|
}
|
|
412
412
|
|
|
413
413
|
export async function appendProtocolEvent(target, input, packageRoot, options = {}) {
|
|
414
|
-
const activeTransaction =
|
|
414
|
+
const activeTransaction = (await getTaskTransaction(target));
|
|
415
415
|
if (typeof input?.taskId !== "string" || !input.taskId) throw protocolError("E_EVENT_INVALID", "event taskId is required");
|
|
416
416
|
if (typeof input?.event !== "string" || !input.event) throw protocolError("E_EVENT_INVALID", "event type is required");
|
|
417
417
|
const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
|
|
@@ -66,24 +66,37 @@ function contractCollections(contract) {
|
|
|
66
66
|
|
|
67
67
|
function contractSignals(contract) {
|
|
68
68
|
const collections = contractCollections(contract);
|
|
69
|
+
const obligations = [];
|
|
70
|
+
const types = new Set();
|
|
71
|
+
function collect(requirement) {
|
|
72
|
+
if (typeof requirement === "string") obligations.push(requirement);
|
|
73
|
+
else if (requirement && typeof requirement === "object") {
|
|
74
|
+
if (typeof requirement.text === "string") obligations.push(requirement.text);
|
|
75
|
+
types.add(requirement.type);
|
|
76
|
+
if (Array.isArray(requirement.requirements)) requirement.requirements.forEach(collect);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
collections.successCriteria.forEach(collect);
|
|
80
|
+
if (Array.isArray(contract?.verification)) contract.verification.forEach(collect);
|
|
69
81
|
const riskText = collections.risks.join(" ").toLowerCase();
|
|
82
|
+
// Constraints and stop conditions describe boundaries, not obligations.
|
|
83
|
+
// Declared route risks remain authoritative even when a constraint excludes
|
|
84
|
+
// an operation; do not try to interpret prose negation as authorization.
|
|
70
85
|
const allText = [
|
|
71
86
|
...collections.deliverables,
|
|
72
|
-
...
|
|
73
|
-
...collections.constraints,
|
|
74
|
-
...collections.stopConditions,
|
|
87
|
+
...obligations,
|
|
75
88
|
].join(" ").toLowerCase();
|
|
76
89
|
const combinedText = `${riskText} ${allText}`;
|
|
77
90
|
const signals = {
|
|
78
91
|
secrets: /\bsecrets?\b|\bcredentials?\b|\bpasswords?\b|\bprivate keys?\b/.test(combinedText),
|
|
79
92
|
personalData: /\bpersonal[- ]data\b|\bpii\b|\bsensitive data\b/.test(combinedText),
|
|
80
|
-
publication: /\bpublication\b|\bpublish(?:ing|ed)?\b|\bdeploy(?:ment|ed)?\b/.test(combinedText),
|
|
93
|
+
publication: types.has("PUBLICATION") || /\bpublication\b|\bpublish(?:ing|ed)?\b|\bdeploy(?:ment|ed)?\b/.test(combinedText),
|
|
81
94
|
destructive: /\bdestructive\b|\birreversible\b|\bdrop database\b|\bdelete production\b/.test(combinedText),
|
|
82
95
|
migration: /\bmigration\b|\bmigrate\b|\bschema change\b|\birreversible persistence\b/.test(combinedText),
|
|
83
96
|
payment: /\bpayments?\b|\bcheckout\b|\bbilling\b/.test(combinedText),
|
|
84
97
|
externalMutation: /\bexternal mutation\b|\bmutate(?:s|d)? external\b|\bpublish(?:ing|ed)?\b/.test(combinedText),
|
|
85
98
|
authoritySensitiveExternalMutation: /\bauthority[- ]sensitive\b|\bhost[- ]authorized external\b|\bexternal mutation requiring authority\b/.test(combinedText),
|
|
86
|
-
broadProductionValidation: /\bbroad production validation\b|\bproduction validation\b|\bvalidate in production\b/.test(combinedText),
|
|
99
|
+
broadProductionValidation: types.has("PRODUCTION_READINESS") || /\bbroad production validation\b|\bproduction validation\b|\bvalidate in production\b/.test(combinedText),
|
|
87
100
|
};
|
|
88
101
|
return signals;
|
|
89
102
|
}
|
|
@@ -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
|
}
|