fraim 2.0.346 → 2.0.349
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/dist/registry/scripts/learning/completion-state.js +80 -4
- package/dist/registry/scripts/learning/corpus-audit.js +7 -7
- package/dist/registry/scripts/learning/entry-authoring.js +181 -0
- package/dist/registry/scripts/learning/entry-format.js +10 -1
- package/dist/registry/scripts/learning/outcome-assessment.js +162 -0
- package/dist/registry/scripts/learning/outcome-types.js +92 -0
- package/dist/registry/scripts/learning/owner-identity.js +11 -0
- package/dist/registry/scripts/learning/proposal-queue.js +81 -0
- package/dist/registry/scripts/learning/run-application.js +1003 -0
- package/dist/registry/scripts/learning/run-publication.js +364 -0
- package/dist/registry/scripts/learning/run-record.js +895 -0
- package/dist/registry/scripts/learning-usage/learning-source-record.js +131 -0
- package/dist/src/cli/commands/learning.js +64 -0
- package/dist/src/cli/doctor/checks/scripts-checks.js +53 -1
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/learning/inventory.js +285 -0
- package/dist/src/cli/learning/queue-operations.js +199 -0
- package/dist/src/cli/learning/runtime.js +82 -0
- package/dist/src/cli/learning/usage.js +277 -0
- package/dist/src/core/fraim-config-schema.generated.js +0 -4
- package/dist/src/core/utils/local-registry-resolver.js +13 -3
- package/dist/src/local-mcp-server/learning-context-builder.js +109 -379
- package/dist/src/local-mcp-server/learning-entry-format.js +25 -0
- package/dist/src/local-mcp-server/learning-usage-analysis.js +12 -410
- package/dist/src/local-mcp-server/learning-usage-eligibility.js +8 -14
- package/dist/src/local-mcp-server/learning-usage-projection.js +5 -22
- package/dist/src/local-mcp-server/learning-usage-store.js +93 -393
- package/dist/src/local-mcp-server/learning-usage-verify.js +9 -40
- package/dist/src/local-mcp-server/stdio-server.js +50 -114
- package/dist/src/mcp/tool-schemas.js +2 -1
- package/package.json +9 -1
- package/dist/src/local-mcp-server/learning-firing-parser.js +0 -247
- package/dist/src/local-mcp-server/learning-usage-attestation.js +0 -238
- package/dist/src/local-mcp-server/learning-usage-command.js +0 -518
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.reserveRunAction = reserveRunAction;
|
|
7
|
+
exports.applyRunAction = applyRunAction;
|
|
8
|
+
exports.completeRun = completeRun;
|
|
9
|
+
exports.completionInventory = completionInventory;
|
|
10
|
+
exports.removeEmptyGenerated = removeEmptyGenerated;
|
|
11
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
12
|
+
const completion_state_1 = require("./completion-state");
|
|
13
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
14
|
+
const run_record_1 = require("./run-record");
|
|
15
|
+
const owner_identity_1 = require("./owner-identity");
|
|
16
|
+
const proposal_queue_1 = require("./proposal-queue");
|
|
17
|
+
const entry_authoring_1 = require("./entry-authoring");
|
|
18
|
+
const completion_state_2 = require("./completion-state");
|
|
19
|
+
const artifact_paths_1 = require("../artifact-paths");
|
|
20
|
+
const learning_source_record_1 = require("../learning-usage/learning-source-record");
|
|
21
|
+
const corpus_audit_1 = require("./corpus-audit");
|
|
22
|
+
const { learningEntryBlocks, learningEntryMetadata } = require('./entry-format');
|
|
23
|
+
function currentEntry(file, id) {
|
|
24
|
+
const body = node_fs_1.default.existsSync(file) ? node_fs_1.default.readFileSync(file, 'utf8') : '';
|
|
25
|
+
if (/^<{7}/m.test(body))
|
|
26
|
+
throw new Error('source-conflict: merge markers');
|
|
27
|
+
if (!id)
|
|
28
|
+
return { body, entry: node_fs_1.default.existsSync(file) ? body : null };
|
|
29
|
+
const blocks = learningEntryBlocks(body);
|
|
30
|
+
if (blocks.some(b => !b.id))
|
|
31
|
+
throw new Error('source-conflict: ambiguous learning entry');
|
|
32
|
+
const matches = blocks.filter(b => b.id === id);
|
|
33
|
+
if (matches.length > 1)
|
|
34
|
+
throw new Error('source-conflict: duplicate identity');
|
|
35
|
+
return { body, entry: matches[0]?.text ?? null };
|
|
36
|
+
}
|
|
37
|
+
function resolved(ref, options) {
|
|
38
|
+
const home = options.resolveHome(ref.owner);
|
|
39
|
+
(0, owner_identity_1.assertSameOwner)(ref.owner, home.owner);
|
|
40
|
+
return { home, file: (0, owner_identity_1.ownerArtifactPath)(home, ref.relativePath) };
|
|
41
|
+
}
|
|
42
|
+
function capabilityGuards(action, proof, options) {
|
|
43
|
+
if (action.delivery?.reader)
|
|
44
|
+
(0, run_record_1.validateDeliveryReader)(action.delivery.reader);
|
|
45
|
+
const snapshots = new Map((proof.localFiles || []).map(item => [node_path_1.default.resolve(item.file), { ...item }]));
|
|
46
|
+
const required = [action.target, ...(action.delivery?.callers || [])].map(ref => resolved(ref, options).file);
|
|
47
|
+
if (required.some(file => {
|
|
48
|
+
const snapshot = snapshots.get(node_path_1.default.resolve(file));
|
|
49
|
+
return !snapshot || typeof snapshot.realPath !== 'string' || !snapshot.realPath
|
|
50
|
+
|| typeof snapshot.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(snapshot.sha256);
|
|
51
|
+
}))
|
|
52
|
+
throw new Error('capability-local-proof-required');
|
|
53
|
+
if (snapshots.get(node_path_1.default.resolve(required[0]))?.sha256 !== action.delivery?.expectedHash)
|
|
54
|
+
throw new Error('capability-local-proof-conflict');
|
|
55
|
+
return [...snapshots.values()].map(item => {
|
|
56
|
+
const absent = item.realPath === null && item.sha256 === null;
|
|
57
|
+
if (!absent && (typeof item.realPath !== 'string' || typeof item.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(item.sha256)))
|
|
58
|
+
throw new Error('capability-local-proof-conflict');
|
|
59
|
+
return { file: item.file, verify: () => {
|
|
60
|
+
if (absent) {
|
|
61
|
+
try {
|
|
62
|
+
node_fs_1.default.lstatSync(item.file);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code === 'ENOENT')
|
|
66
|
+
return;
|
|
67
|
+
throw new Error('capability-local-proof-changed');
|
|
68
|
+
}
|
|
69
|
+
throw new Error('capability-local-proof-changed');
|
|
70
|
+
}
|
|
71
|
+
if (!node_fs_1.default.existsSync(item.file) || node_fs_1.default.realpathSync(item.file) !== item.realPath || (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(item.file, 'utf8')) !== item.sha256)
|
|
72
|
+
throw new Error('capability-local-proof-changed');
|
|
73
|
+
} };
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function withLocalGuards(guards, operation) {
|
|
77
|
+
// Locks fail immediately with busy; they never wait on another holder. The
|
|
78
|
+
// prepared reservation freezes approval before these sorted file locks are
|
|
79
|
+
// taken. Journal appends use their own short lock; completion acquires the
|
|
80
|
+
// sorted file set before entering RunRecord.complete's journal transaction.
|
|
81
|
+
const grouped = new Map();
|
|
82
|
+
for (const guard of guards)
|
|
83
|
+
grouped.set(guard.file, [...(grouped.get(guard.file) || []), guard.verify]);
|
|
84
|
+
const files = [...grouped.keys()].sort();
|
|
85
|
+
const locked = (index) => index === files.length ? operation() : (0, proposal_queue_1.withFileLock)(files[index], () => {
|
|
86
|
+
for (const verify of grouped.get(files[index]))
|
|
87
|
+
verify();
|
|
88
|
+
return locked(index + 1);
|
|
89
|
+
});
|
|
90
|
+
return locked(0);
|
|
91
|
+
}
|
|
92
|
+
function verifyHistory(before, action) {
|
|
93
|
+
if (!action.content)
|
|
94
|
+
return;
|
|
95
|
+
const meta = learningEntryMetadata(before);
|
|
96
|
+
const trace = action.content.trace;
|
|
97
|
+
if ((meta['First recorded'] && meta['First recorded'] !== trace.firstRecorded)
|
|
98
|
+
|| (meta['First synthesized'] && meta['First synthesized'] !== trace.firstSynthesized)
|
|
99
|
+
|| Number(meta.Recurrences || 0) > trace.recurrences)
|
|
100
|
+
throw new Error('immutable-learning-history');
|
|
101
|
+
}
|
|
102
|
+
function prepare(action, options) {
|
|
103
|
+
const target = resolved(action.target, options);
|
|
104
|
+
const before = currentEntry(target.file, action.target.entryId).entry;
|
|
105
|
+
const after = action.operation === 'remove' ? null : (0, entry_authoring_1.renderEntry)(action.target.entryId, action.content).trimEnd();
|
|
106
|
+
const expected = action.destinationHash || (action.operation !== 'move' ? action.sourceHash : undefined);
|
|
107
|
+
if (['insert', 'move'].includes(action.operation) || (action.operation === 'reconcile' && !action.destinationHash) ? before !== null : before === null || (0, proposal_queue_1.hashContent)(before) !== expected)
|
|
108
|
+
throw new Error('source-conflict: reviewed base changed');
|
|
109
|
+
if (before && action.operation !== 'reconcile')
|
|
110
|
+
verifyHistory(before, action);
|
|
111
|
+
const steps = [{ ref: action.target, before, after }];
|
|
112
|
+
if (action.operation === 'move') {
|
|
113
|
+
const source = resolved(action.source, options);
|
|
114
|
+
const old = currentEntry(source.file, action.source.entryId).entry;
|
|
115
|
+
if (old === null || (0, proposal_queue_1.hashContent)(old) !== action.sourceHash)
|
|
116
|
+
throw new Error('source-conflict: move source changed');
|
|
117
|
+
verifyHistory(old, action);
|
|
118
|
+
steps.push({ ref: action.source, before: old, after: null });
|
|
119
|
+
}
|
|
120
|
+
if (action.operation === 'reconcile') {
|
|
121
|
+
for (const copy of action.copies) {
|
|
122
|
+
const old = currentEntry(resolved(copy.ref, options).file, copy.ref.entryId).entry;
|
|
123
|
+
if (old === null || (0, proposal_queue_1.hashContent)(old) !== copy.sha256)
|
|
124
|
+
throw new Error('source-conflict: reconciliation copy changed');
|
|
125
|
+
steps.push({ ref: copy.ref, before: old, after: null });
|
|
126
|
+
}
|
|
127
|
+
const history = steps.flatMap(step => step.before ? [learningEntryMetadata(step.before)] : []);
|
|
128
|
+
const earliest = (key) => history.map(item => item[key]).filter(Boolean).sort()[0];
|
|
129
|
+
const sources = new Set(steps.flatMap(step => step.before?.match(/^\*\*Sources\*\*:\s*([^\r\n]*)$/m)?.[1].split(';').map(value => value.trim()).filter(Boolean) || []));
|
|
130
|
+
const trace = action.content.trace;
|
|
131
|
+
if ((earliest('First recorded') && trace.firstRecorded !== earliest('First recorded')) || (earliest('First synthesized') && trace.firstSynthesized !== earliest('First synthesized'))
|
|
132
|
+
|| trace.recurrences < Math.max(...history.map(item => Number(item.Recurrences || 1)))
|
|
133
|
+
|| new Set(trace.sources).size !== trace.sources.length || [...sources].some(source => !trace.sources.includes(source)))
|
|
134
|
+
throw new Error('immutable-learning-history');
|
|
135
|
+
}
|
|
136
|
+
return steps;
|
|
137
|
+
}
|
|
138
|
+
/** The caller holds this file together with all dependency guards in canonical order. */
|
|
139
|
+
function writeStepLocked(step, options) {
|
|
140
|
+
const { home, file } = resolved(step.ref, options);
|
|
141
|
+
const { body, entry } = currentEntry(file, step.ref.entryId);
|
|
142
|
+
if (entry !== step.after) {
|
|
143
|
+
if (entry !== step.before)
|
|
144
|
+
throw new Error('source-conflict: recovery observed a third state');
|
|
145
|
+
const blocks = learningEntryBlocks(body);
|
|
146
|
+
const match = blocks.find(b => b.id === step.ref.entryId);
|
|
147
|
+
const updated = match ? body.slice(0, match.start) + (step.after || '') + body.slice(match.end)
|
|
148
|
+
: body.trimEnd() + (body.trim() ? '\n\n' : '# Learnings\n\n') + (step.after || '') + '\n';
|
|
149
|
+
(0, proposal_queue_1.atomicWrite)(file, updated);
|
|
150
|
+
const actual = currentEntry(file, step.ref.entryId);
|
|
151
|
+
if (actual.entry !== step.after)
|
|
152
|
+
throw new Error('write-verification-failed');
|
|
153
|
+
const untouched = blocks.filter(b => b.id !== step.ref.entryId).map(b => b.text);
|
|
154
|
+
const nowUntouched = learningEntryBlocks(actual.body).filter(b => b.id !== step.ref.entryId).map(b => b.text);
|
|
155
|
+
if ((0, run_record_1.canonical)(untouched) !== (0, run_record_1.canonical)(nowUntouched))
|
|
156
|
+
throw new Error('sibling-preservation-failed');
|
|
157
|
+
}
|
|
158
|
+
return { home, file, fileHash: (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(file, 'utf8')) };
|
|
159
|
+
}
|
|
160
|
+
function actionKeys(action) { return [action.target, ...(action.source ? [action.source] : []), ...(action.copies?.map(copy => copy.ref) || [])].map(ref => action.kind === 'learning' ? 'learning:' + ref.entryId : (0, owner_identity_1.ownerKey)(ref.owner) + ':' + ref.relativePath); }
|
|
161
|
+
function actionHomes(run, action, options) {
|
|
162
|
+
const homes = options.homes || [run.owner, action.target.owner, ...(action.source ? [action.source.owner] : []), ...(action.copies?.map(copy => copy.ref.owner) || [])].map(options.resolveHome);
|
|
163
|
+
return [...new Map(homes.map(home => [(0, owner_identity_1.ownerKey)(home.owner), home])).values()];
|
|
164
|
+
}
|
|
165
|
+
/** Repeated at preparation and before each local write, including resumed removals. */
|
|
166
|
+
function assertReviewedCopies(run, action, options) {
|
|
167
|
+
if (action.kind !== 'learning')
|
|
168
|
+
return;
|
|
169
|
+
const state = run.read();
|
|
170
|
+
const homes = actionHomes(run, action, options);
|
|
171
|
+
const corpus = (0, corpus_audit_1.collectLearningCorpus)(homes, [state.reviewer, ...(state.sourceParticipants?.map(grant => grant.author) || [])], options.readerRoots?.());
|
|
172
|
+
const refs = [action.target, ...(action.operation === 'move' ? [action.source] : action.operation === 'reconcile' ? action.copies.map(copy => copy.ref) : [])];
|
|
173
|
+
const key = (file) => {
|
|
174
|
+
let ancestor = node_path_1.default.resolve(file);
|
|
175
|
+
const missing = [];
|
|
176
|
+
while (!node_fs_1.default.existsSync(ancestor)) {
|
|
177
|
+
const parent = node_path_1.default.dirname(ancestor);
|
|
178
|
+
if (parent === ancestor)
|
|
179
|
+
throw new Error('home-unavailable');
|
|
180
|
+
missing.unshift(node_path_1.default.basename(ancestor));
|
|
181
|
+
ancestor = parent;
|
|
182
|
+
}
|
|
183
|
+
const physical = node_path_1.default.join(node_fs_1.default.realpathSync(ancestor), ...missing);
|
|
184
|
+
return process.platform === 'win32' ? physical.toLowerCase() : physical;
|
|
185
|
+
};
|
|
186
|
+
const allowed = new Set(refs.map(ref => key(resolved(ref, options).file)));
|
|
187
|
+
if (allowed.size !== refs.length)
|
|
188
|
+
throw new Error('duplicate-physical-learning-target');
|
|
189
|
+
for (const error of corpus.errors) {
|
|
190
|
+
// An identifiable malformed sibling cannot hold this identity. Unreadable
|
|
191
|
+
// or linked paths are never reopened; only contained regular parse-error
|
|
192
|
+
// files may be inspected to keep independent work moving.
|
|
193
|
+
if (!['ambiguous-corpus-entry', 'corpus-merge-conflict'].includes(error.reason))
|
|
194
|
+
throw new Error('corpus-incomplete');
|
|
195
|
+
try {
|
|
196
|
+
const home = homes.find(candidate => {
|
|
197
|
+
const relative = node_path_1.default.relative(candidate.contentRoot, error.path);
|
|
198
|
+
return relative && relative !== '..' && !relative.startsWith('..' + node_path_1.default.sep) && !node_path_1.default.isAbsolute(relative);
|
|
199
|
+
});
|
|
200
|
+
if (!home)
|
|
201
|
+
throw new Error('corpus-incomplete');
|
|
202
|
+
const file = (0, owner_identity_1.ownerArtifactPath)(home, node_path_1.default.relative(home.contentRoot, error.path).replace(/\\/g, '/'));
|
|
203
|
+
const stat = node_fs_1.default.lstatSync(file);
|
|
204
|
+
if (!stat.isFile() || stat.isSymbolicLink() || allowed.has(key(file)) || node_fs_1.default.readFileSync(file, 'utf8').includes(action.target.entryId))
|
|
205
|
+
throw new Error('corpus-incomplete');
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
throw new Error('corpus-incomplete');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (const entry of corpus.entries.filter(entry => entry.ref.entryId === action.target.entryId)) {
|
|
212
|
+
if (!allowed.has(key(resolved(entry.ref, options).file)))
|
|
213
|
+
throw new Error('unreviewed-learning-copy');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function verifyCorpusAudit(run, homes, options) {
|
|
217
|
+
const state = run.read(), audit = state.corpusAudit;
|
|
218
|
+
if (!audit) {
|
|
219
|
+
if (state.corpusAuditRequired && !options.allowDeferred)
|
|
220
|
+
throw new Error('corpus-audit-required');
|
|
221
|
+
return !state.corpusAuditRequired;
|
|
222
|
+
}
|
|
223
|
+
(0, corpus_audit_1.validateCorpusAudit)(audit, state.reviewer);
|
|
224
|
+
const current = (0, corpus_audit_1.collectLearningCorpus)(homes, [state.reviewer, ...(state.sourceParticipants?.map(grant => grant.author) || [])], options.readerRoots?.());
|
|
225
|
+
if (!current.complete && !options.allowDeferred)
|
|
226
|
+
throw new Error('corpus-incomplete');
|
|
227
|
+
if ((0, run_record_1.canonical)(current.homes) !== (0, run_record_1.canonical)(audit.snapshot.homes))
|
|
228
|
+
throw new Error('corpus-audit-homes-changed');
|
|
229
|
+
const expected = new Map(audit.snapshot.entries.map(entry => [(0, corpus_audit_1.corpusEntryKey)(entry), entry]));
|
|
230
|
+
const disposition = new Map(audit.dispositions.map(item => [(0, corpus_audit_1.corpusEntryKey)(item), item]));
|
|
231
|
+
let covered = current.complete;
|
|
232
|
+
for (const item of audit.dispositions)
|
|
233
|
+
for (const binding of item.actionBindings) {
|
|
234
|
+
const action = state.actions[binding.actionId];
|
|
235
|
+
if (!action || action.revision !== binding.revision || action.digest !== binding.digest)
|
|
236
|
+
throw new Error('corpus-audit-action-changed');
|
|
237
|
+
}
|
|
238
|
+
// Inspection alone does not resolve identity conflicts. Keep every copy tied
|
|
239
|
+
// to one exact proposal; the ordinary decision gate then preserves pending
|
|
240
|
+
// work or records an attributed rejection without changing those copies.
|
|
241
|
+
for (const duplicate of audit.snapshot.duplicates) {
|
|
242
|
+
const first = disposition.get((0, corpus_audit_1.corpusEntryKey)(duplicate.occurrences[0]));
|
|
243
|
+
const accounted = first.actionIds.some(id => {
|
|
244
|
+
const action = state.actions[id];
|
|
245
|
+
if (action.kind !== 'learning' || action.operation !== 'reconcile')
|
|
246
|
+
return false;
|
|
247
|
+
const reviewed = [
|
|
248
|
+
...(action.destinationHash ? [{ ref: action.target, sha256: action.destinationHash }] : []),
|
|
249
|
+
...action.copies,
|
|
250
|
+
];
|
|
251
|
+
return reviewed.length === duplicate.occurrences.length && duplicate.occurrences.every(entry => disposition.get((0, corpus_audit_1.corpusEntryKey)(entry)).actionIds.includes(id)
|
|
252
|
+
&& reviewed.some(copy => (0, corpus_audit_1.corpusEntryKey)(copy) === (0, corpus_audit_1.corpusEntryKey)(entry) && copy.sha256 === entry.sha256));
|
|
253
|
+
});
|
|
254
|
+
if (!accounted && !options.allowDeferred)
|
|
255
|
+
throw new Error('corpus-duplicate-unresolved');
|
|
256
|
+
if (!accounted)
|
|
257
|
+
covered = false;
|
|
258
|
+
}
|
|
259
|
+
for (const id of state.appliedOrder) {
|
|
260
|
+
const action = state.actions[id];
|
|
261
|
+
if (action.kind !== 'learning')
|
|
262
|
+
continue;
|
|
263
|
+
for (const step of action.application.steps) {
|
|
264
|
+
const key = (0, corpus_audit_1.corpusEntryKey)({ ref: step.ref, sha256: '' });
|
|
265
|
+
const prior = expected.get(key), inspected = disposition.get(key);
|
|
266
|
+
if (prior && disposition.has(key) && (!inspected || inspected.outcome !== 'action' || !inspected.actionIds.includes(id)))
|
|
267
|
+
throw new Error('corpus-audit-action-unreviewed');
|
|
268
|
+
if (step.after === null)
|
|
269
|
+
expected.delete(key);
|
|
270
|
+
else
|
|
271
|
+
expected.set(key, { ref: { owner: step.ref.owner, relativePath: step.ref.relativePath, entryId: step.ref.entryId }, sha256: (0, proposal_queue_1.hashContent)(step.after) });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const entries = [...expected.values()].sort((a, b) => (0, corpus_audit_1.corpusEntryKey)(a).localeCompare((0, corpus_audit_1.corpusEntryKey)(b)));
|
|
275
|
+
if ((0, run_record_1.canonical)(entries) !== (0, run_record_1.canonical)(current.entries)) {
|
|
276
|
+
if (!options.allowDeferred)
|
|
277
|
+
throw new Error('corpus-audit-stale');
|
|
278
|
+
covered = false;
|
|
279
|
+
}
|
|
280
|
+
return covered;
|
|
281
|
+
}
|
|
282
|
+
/** Reserve before any external verification/publication; the durable prepared state survives unlocked waits. */
|
|
283
|
+
function reserveRunAction(run, actionId, revision, expectedSequence, options) {
|
|
284
|
+
(0, proposal_queue_1.withFileLock)(options.coordinationFile || node_path_1.default.join(run.directory, 'owner-application'), () => run.mutate(expectedSequence, state => {
|
|
285
|
+
const action = state.actions[actionId];
|
|
286
|
+
if (!action || action.revision !== revision)
|
|
287
|
+
throw new Error('stale-revision');
|
|
288
|
+
if (action.decision?.action !== 'approve' || action.decision.digest !== action.digest)
|
|
289
|
+
throw new Error('not-approved');
|
|
290
|
+
if (action.delivery?.reader)
|
|
291
|
+
(0, run_record_1.validateDeliveryReader)(action.delivery.reader);
|
|
292
|
+
if (action.dependencies.some(id => state.actions[id]?.application?.stage !== 'applied'))
|
|
293
|
+
throw new Error('dependency-unfinished');
|
|
294
|
+
for (const directory of new Set([run.directory, ...options.runDirectories])) {
|
|
295
|
+
if (!node_fs_1.default.existsSync(directory))
|
|
296
|
+
continue;
|
|
297
|
+
for (const name of node_fs_1.default.readdirSync(directory).filter(n => n.endsWith('.queue.jsonl'))) {
|
|
298
|
+
const otherFile = node_path_1.default.join(directory, name);
|
|
299
|
+
const other = (0, run_record_1.readSupportedRunRecord)(otherFile);
|
|
300
|
+
if (!other)
|
|
301
|
+
continue;
|
|
302
|
+
for (const candidate of Object.values(other.actions)) {
|
|
303
|
+
if (node_path_1.default.resolve(otherFile) === node_path_1.default.resolve(run.file) && candidate.actionId === actionId)
|
|
304
|
+
continue;
|
|
305
|
+
if (candidate.application && candidate.application.stage !== 'applied' && actionKeys(candidate).some(k => actionKeys(action).includes(k)))
|
|
306
|
+
throw new Error('entry-reserved-by-pending-run');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Capabilities must validate ownership even before the resolver starts remote work.
|
|
311
|
+
if (action.kind === 'capability') {
|
|
312
|
+
resolved(action.target, options);
|
|
313
|
+
if (!action.delivery)
|
|
314
|
+
throw new Error('capability-delivery-review-required');
|
|
315
|
+
}
|
|
316
|
+
assertReviewedCopies(run, action, options);
|
|
317
|
+
if (!action.application)
|
|
318
|
+
run.progress(actionId, { stage: 'prepared', steps: action.kind === 'learning' ? prepare(action, options) : [] });
|
|
319
|
+
}));
|
|
320
|
+
return run.read().actions[actionId];
|
|
321
|
+
}
|
|
322
|
+
function applyRunAction(run, actionId, revision, expectedSequence, options) {
|
|
323
|
+
reserveRunAction(run, actionId, revision, expectedSequence, options);
|
|
324
|
+
const reserved = run.read();
|
|
325
|
+
const dependencyProofs = new Map();
|
|
326
|
+
for (const dependencyId of reserved.actions[actionId].dependencies) {
|
|
327
|
+
const dependency = reserved.actions[dependencyId];
|
|
328
|
+
if (dependency.kind !== 'capability')
|
|
329
|
+
continue;
|
|
330
|
+
const delivered = options.verifyCapability?.(dependency);
|
|
331
|
+
if (!delivered?.published || !delivered.reachable || delivered.contentHash !== dependency.delivery?.expectedHash)
|
|
332
|
+
throw new Error('dependency-delivery-no-longer-verified');
|
|
333
|
+
dependencyProofs.set(dependencyId, capabilityGuards(dependency, delivered, options));
|
|
334
|
+
}
|
|
335
|
+
options.afterStage?.('prepared');
|
|
336
|
+
let action = run.read().actions[actionId];
|
|
337
|
+
if (action.application?.stage === 'applied' && action.kind === 'learning') {
|
|
338
|
+
for (const step of action.application.steps) {
|
|
339
|
+
if (currentEntry(resolved(step.ref, options).file, step.ref.entryId).entry !== step.after)
|
|
340
|
+
throw new Error('source-conflict: applied result changed');
|
|
341
|
+
}
|
|
342
|
+
return action;
|
|
343
|
+
}
|
|
344
|
+
if (action.kind === 'capability') {
|
|
345
|
+
const delivered = options.verifyCapability?.(action);
|
|
346
|
+
if (!delivered?.published || !delivered.reachable || delivered.contentHash !== action.delivery?.expectedHash)
|
|
347
|
+
throw new Error('capability-publication-or-reachability-pending');
|
|
348
|
+
withLocalGuards(capabilityGuards(action, delivered, options), () => {
|
|
349
|
+
if (action.application?.stage !== 'applied')
|
|
350
|
+
(0, proposal_queue_1.withFileLock)(run.file, () => run.progress(actionId, { stage: 'applied', steps: [], publicationRef: delivered.reference }));
|
|
351
|
+
});
|
|
352
|
+
return run.read().actions[actionId];
|
|
353
|
+
}
|
|
354
|
+
for (let index = 0; index < action.application.steps.length; index++) {
|
|
355
|
+
action = run.read().actions[actionId];
|
|
356
|
+
const step = action.application.steps[index];
|
|
357
|
+
if (index > 0 && !action.application.steps[index - 1].published)
|
|
358
|
+
throw new Error('destination-publication-pending');
|
|
359
|
+
if (step.published) {
|
|
360
|
+
if (currentEntry(resolved(step.ref, options).file, step.ref.entryId).entry !== step.after)
|
|
361
|
+
throw new Error('source-conflict: published result changed');
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
// Keep the confirmed destination stable while removing a move's source.
|
|
365
|
+
// Both file locks are local and no external publication occurs inside them.
|
|
366
|
+
const destination = index > 0 ? action.application.steps[0] : undefined;
|
|
367
|
+
const guards = [];
|
|
368
|
+
if (destination) {
|
|
369
|
+
const { file, home } = resolved(destination.ref, options);
|
|
370
|
+
if (home.requiresPublish) {
|
|
371
|
+
const proof = options.publish?.(file, home, `${run.runId}-${actionId}-${revision}-0`, (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(file, 'utf8')), { entryId: destination.ref.entryId, after: destination.after });
|
|
372
|
+
if (!proof?.published)
|
|
373
|
+
throw new Error('destination-publication-pending');
|
|
374
|
+
}
|
|
375
|
+
guards.push({ file, verify: () => { if (currentEntry(file, destination.ref.entryId).entry !== destination.after)
|
|
376
|
+
throw new Error('source-conflict: published result changed'); } });
|
|
377
|
+
}
|
|
378
|
+
if (step.after === null)
|
|
379
|
+
for (const id of action.dependencies) {
|
|
380
|
+
const dependency = run.read().actions[id];
|
|
381
|
+
if (dependency.kind !== 'capability')
|
|
382
|
+
continue;
|
|
383
|
+
guards.push(...(dependencyProofs.get(id) || []));
|
|
384
|
+
}
|
|
385
|
+
const writeFile = resolved(step.ref, options).file;
|
|
386
|
+
if (guards.some(guard => node_path_1.default.resolve(guard.file) === node_path_1.default.resolve(writeFile)))
|
|
387
|
+
throw new Error('dependency-proof-is-removal-target');
|
|
388
|
+
const written = withLocalGuards([...guards, { file: writeFile, verify: () => undefined }], () => {
|
|
389
|
+
assertReviewedCopies(run, action, options);
|
|
390
|
+
return writeStepLocked(step, options);
|
|
391
|
+
});
|
|
392
|
+
(0, proposal_queue_1.withFileLock)(run.file, () => {
|
|
393
|
+
const latest = run.read().actions[actionId];
|
|
394
|
+
latest.application.steps[index].written = true;
|
|
395
|
+
latest.application.stage = 'written';
|
|
396
|
+
run.progress(actionId, latest.application);
|
|
397
|
+
});
|
|
398
|
+
options.afterStage?.(`written-${index}`);
|
|
399
|
+
// External work occurs without local locks. Stable operation keys prevent duplicate publication.
|
|
400
|
+
const outcome = written.home.requiresPublish
|
|
401
|
+
? options.publish?.(written.file, written.home, `${run.runId}-${actionId}-${revision}-${index}`, written.fileHash, { entryId: step.ref.entryId, after: step.after })
|
|
402
|
+
: { published: true, reference: written.file };
|
|
403
|
+
if (!outcome)
|
|
404
|
+
throw new Error('publisher-required');
|
|
405
|
+
(0, proposal_queue_1.withFileLock)(run.file, () => (0, proposal_queue_1.withFileLock)(written.file, () => {
|
|
406
|
+
if ((0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(written.file, 'utf8')) !== written.fileHash)
|
|
407
|
+
throw new Error('source-conflict: changed during publication');
|
|
408
|
+
const latest = run.read().actions[actionId];
|
|
409
|
+
Object.assign(latest.application.steps[index], { published: outcome.published, publicationRef: outcome.reference });
|
|
410
|
+
latest.application.stage = 'published';
|
|
411
|
+
run.progress(actionId, latest.application);
|
|
412
|
+
}));
|
|
413
|
+
options.afterStage?.(`published-${index}`);
|
|
414
|
+
if (!outcome.published)
|
|
415
|
+
throw new Error('publication-pending');
|
|
416
|
+
}
|
|
417
|
+
(0, proposal_queue_1.withFileLock)(run.file, () => {
|
|
418
|
+
const latest = run.read().actions[actionId];
|
|
419
|
+
for (const step of latest.application.steps) {
|
|
420
|
+
const target = resolved(step.ref, options);
|
|
421
|
+
if (currentEntry(target.file, step.ref.entryId).entry !== step.after || !step.published)
|
|
422
|
+
throw new Error('application-verification-failed');
|
|
423
|
+
}
|
|
424
|
+
latest.application.stage = 'applied';
|
|
425
|
+
latest.application.publicationRef = latest.application.steps[0].publicationRef;
|
|
426
|
+
run.progress(actionId, latest.application);
|
|
427
|
+
});
|
|
428
|
+
return run.read().actions[actionId];
|
|
429
|
+
}
|
|
430
|
+
/** Finish the analyzed input batch, independently of whether its proposals were accepted. */
|
|
431
|
+
function sourceCompletion(run, homes, options, afterStage) {
|
|
432
|
+
const inputs = options.analyzedSources ?? [];
|
|
433
|
+
if (!Array.isArray(inputs) || inputs.some(item => !item || !node_path_1.default.isAbsolute(item.path) || !/^[a-f0-9]{64}$/.test(item.sha256) || !['raw', 'retrospective'].includes(item.kind)))
|
|
434
|
+
throw new Error('analyzed-sources-required');
|
|
435
|
+
const normalized = inputs.map(item => ({ path: node_path_1.default.resolve(item.path), sha256: item.sha256, kind: item.kind })).sort((a, b) => a.path.localeCompare(b.path));
|
|
436
|
+
if (new Set(normalized.map(item => process.platform === 'win32' ? item.path.toLowerCase() : item.path)).size !== normalized.length)
|
|
437
|
+
throw new Error('duplicate-analyzed-source');
|
|
438
|
+
const runHome = options.resolveHome(run.owner);
|
|
439
|
+
const retrospectiveFile = (0, owner_identity_1.configuredArtifactPath)(runHome, node_path_1.default.join((0, artifact_paths_1.resolveArtifactPaths)((0, owner_identity_1.ownerArtifactRoot)(runHome), runHome.contentRoot).retrospectives, node_path_1.default.basename(options.retrospective.relativePath)));
|
|
440
|
+
const allowed = (source) => {
|
|
441
|
+
for (const home of homes) {
|
|
442
|
+
const configured = options.resolveHome(home.owner);
|
|
443
|
+
(0, owner_identity_1.assertSameOwner)(home.owner, configured.owner);
|
|
444
|
+
if ((0, completion_state_2.sourcePathKey)(home.contentRoot) !== (0, completion_state_2.sourcePathKey)(configured.contentRoot) || (0, completion_state_2.sourcePathKey)(home.root) !== (0, completion_state_2.sourcePathKey)(configured.root) || (0, completion_state_2.sourcePathKey)((0, owner_identity_1.ownerArtifactRoot)(home)) !== (0, completion_state_2.sourcePathKey)((0, owner_identity_1.ownerArtifactRoot)(configured)))
|
|
445
|
+
throw new Error('analyzed-source-outside-home');
|
|
446
|
+
const locations = (0, artifact_paths_1.resolveArtifactPaths)((0, owner_identity_1.ownerArtifactRoot)(configured), configured.contentRoot);
|
|
447
|
+
const sourceDirectory = source.kind === 'raw' ? locations.raw : locations.retrospectives;
|
|
448
|
+
const archiveDirectory = source.kind === 'raw' ? locations.archiveRaw : locations.archiveRetrospectives;
|
|
449
|
+
if ((0, completion_state_2.sourcePathKey)(node_path_1.default.dirname(source.path)) !== (0, completion_state_2.sourcePathKey)(sourceDirectory))
|
|
450
|
+
continue;
|
|
451
|
+
(0, owner_identity_1.configuredArtifactPath)(configured, source.path);
|
|
452
|
+
const destination = (0, owner_identity_1.configuredArtifactPath)(configured, node_path_1.default.join(archiveDirectory, node_path_1.default.basename(source.path)));
|
|
453
|
+
const outsideContent = [sourceDirectory, archiveDirectory].some(directory => {
|
|
454
|
+
const relative = node_path_1.default.relative(configured.contentRoot, directory);
|
|
455
|
+
return relative === '..' || relative.startsWith('..' + node_path_1.default.sep) || node_path_1.default.isAbsolute(relative);
|
|
456
|
+
});
|
|
457
|
+
return { root: (0, owner_identity_1.ownerArtifactRoot)(configured), contentRoot: configured.contentRoot, owner: configured.owner, destination,
|
|
458
|
+
layout: { contentRoot: node_path_1.default.resolve(configured.contentRoot).replace(/\\/g, '/'), sourceDirectory, archiveDirectory, ...(outsideContent ? { homeRoot: (0, owner_identity_1.ownerArtifactRoot)(configured) } : {}) } };
|
|
459
|
+
}
|
|
460
|
+
throw new Error('analyzed-source-outside-home');
|
|
461
|
+
};
|
|
462
|
+
const regular = (file, root) => {
|
|
463
|
+
for (let current = file;; current = node_path_1.default.dirname(current)) {
|
|
464
|
+
try {
|
|
465
|
+
if (node_fs_1.default.lstatSync(current).isSymbolicLink())
|
|
466
|
+
throw new Error('analyzed-source-link');
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
if (error.code !== 'ENOENT')
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
472
|
+
if ((0, completion_state_2.sourcePathKey)(current) === (0, completion_state_2.sourcePathKey)(root))
|
|
473
|
+
break;
|
|
474
|
+
if (current === node_path_1.default.dirname(current))
|
|
475
|
+
throw new Error('analyzed-source-outside-home');
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
const stamp = (body, date) => {
|
|
479
|
+
const header = body.match(/^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
480
|
+
if (!header || (header[1].match(/^synthesized:/gm) || []).length > 1)
|
|
481
|
+
throw new Error('retrospective-frontmatter-required');
|
|
482
|
+
const newline = body.includes('\r\n') ? '\r\n' : '\n';
|
|
483
|
+
const fields = /^synthesized:/m.test(header[1]) ? header[1].replace(/^synthesized:[^\r\n]*/m, `synthesized: ${date}`) : header[1] + newline + `synthesized: ${date}`;
|
|
484
|
+
return body.replace(header[0], () => header[0].replace(header[1], () => fields));
|
|
485
|
+
};
|
|
486
|
+
run.assertReconciliations();
|
|
487
|
+
let state = run.read();
|
|
488
|
+
if (!options.allowDeferred && Object.values(state.actions).some(action => !action.decision || (action.decision.action === 'approve' && action.application?.stage !== 'applied')))
|
|
489
|
+
throw new Error('run-unfinished');
|
|
490
|
+
const completedActions = options.allowDeferred ? (0, run_record_1.completedRunActions)(state) : Object.values(state.actions);
|
|
491
|
+
for (const action of completedActions)
|
|
492
|
+
for (const ref of action.sourceRefs) {
|
|
493
|
+
if (!node_path_1.default.isAbsolute(ref))
|
|
494
|
+
continue;
|
|
495
|
+
const isInput = homes.some(home => {
|
|
496
|
+
const locations = (0, artifact_paths_1.resolveArtifactPaths)((0, owner_identity_1.ownerArtifactRoot)(home), home.contentRoot);
|
|
497
|
+
return [locations.raw, locations.retrospectives].some(directory => (0, completion_state_2.sourcePathKey)(node_path_1.default.dirname(ref)) === (0, completion_state_2.sourcePathKey)(directory));
|
|
498
|
+
});
|
|
499
|
+
if (isInput && !normalized.some(item => (0, completion_state_2.sourcePathKey)(item.path) === (0, completion_state_2.sourcePathKey)(ref) && item.sha256 === action.sourceHashes?.[ref])
|
|
500
|
+
&& !(options.allowDeferred && options.deferredSources?.some(item => (0, completion_state_2.sourcePathKey)(item.path) === (0, completion_state_2.sourcePathKey)(ref))))
|
|
501
|
+
throw new Error('analyzed-action-source-missing');
|
|
502
|
+
}
|
|
503
|
+
const directories = [...new Set([run.directory, ...options.runDirectories])];
|
|
504
|
+
const reservations = new Set(normalized.length ? (0, completion_state_2.readSourceReservations)(directories, run.file) : []);
|
|
505
|
+
for (const source of normalized) {
|
|
506
|
+
if ((0, completion_state_2.sourcePathKey)(source.path) === (0, completion_state_2.sourcePathKey)(retrospectiveFile))
|
|
507
|
+
throw new Error('new-retrospective-is-future-input');
|
|
508
|
+
const { destination, layout } = allowed(source);
|
|
509
|
+
const recorded = state.processedSources?.find(item => (0, completion_state_2.sourcePathKey)(item.path) === (0, completion_state_2.sourcePathKey)(source.path));
|
|
510
|
+
if (recorded && ((0, completion_state_2.sourcePathKey)(recorded.destination) !== (0, completion_state_2.sourcePathKey)(destination) || (recorded.layout && Object.keys(layout).some(key => typeof recorded.layout[key] !== 'string' || (0, completion_state_2.sourcePathKey)(layout[key]) !== (0, completion_state_2.sourcePathKey)(recorded.layout[key])))))
|
|
511
|
+
throw new Error('completion-sources-conflict');
|
|
512
|
+
if (reservations.has((0, completion_state_2.sourcePathKey)(source.path)) || reservations.has((0, completion_state_2.sourcePathKey)(destination)))
|
|
513
|
+
throw new Error('source-reserved-by-pending-run');
|
|
514
|
+
}
|
|
515
|
+
const prior = state.processedSources;
|
|
516
|
+
const batch = (sources) => sources.map(source => ({ ...source, path: (0, completion_state_2.sourcePathKey)(source.path) })).sort((a, b) => a.path.localeCompare(b.path));
|
|
517
|
+
if (prior && (0, run_record_1.canonical)(batch(prior.map(({ path, sha256, kind }) => ({ path, sha256, kind })))) !== (0, run_record_1.canonical)(batch(normalized))
|
|
518
|
+
&& (!options.allowDeferred || prior.some(source => !normalized.some(item => (0, completion_state_2.sourcePathKey)(source.path) === (0, completion_state_2.sourcePathKey)(item.path) && source.sha256 === item.sha256 && source.kind === item.kind))))
|
|
519
|
+
throw new Error('completion-sources-conflict');
|
|
520
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
521
|
+
const validateInput = (body, source, root, contentRoot, owner, archivePath) => {
|
|
522
|
+
const parsed = (0, learning_source_record_1.parseLearningSourceRecord)(body, archivePath ?? source.path, root, contentRoot);
|
|
523
|
+
if (parsed.status !== 'valid' || parsed.record.kind !== source.kind
|
|
524
|
+
|| !(0, completion_state_1.sourceAuthorAllowed)(state, parsed.record.author, owner)
|
|
525
|
+
|| parsed.record.state !== (archivePath ? 'archived' : 'pending')
|
|
526
|
+
|| (!archivePath && parsed.record.synthesized))
|
|
527
|
+
throw new Error('invalid-learning-source');
|
|
528
|
+
};
|
|
529
|
+
const plans = normalized.map(source => {
|
|
530
|
+
const recorded = prior?.find(item => (0, completion_state_2.sourcePathKey)(item.path) === (0, completion_state_2.sourcePathKey)(source.path));
|
|
531
|
+
if (recorded)
|
|
532
|
+
return recorded;
|
|
533
|
+
const { root, destination, contentRoot, owner, layout } = allowed(source);
|
|
534
|
+
regular(source.path, root);
|
|
535
|
+
regular(destination, root);
|
|
536
|
+
const body = node_fs_1.default.readFileSync(source.path, 'utf8');
|
|
537
|
+
if ((0, proposal_queue_1.hashContent)(body) !== source.sha256)
|
|
538
|
+
throw new Error('analyzed-source-changed');
|
|
539
|
+
validateInput(body, source, root, contentRoot, owner);
|
|
540
|
+
return { ...source, destination, resultHash: (0, proposal_queue_1.hashContent)(stamp(body, date)), synthesizedAt: date, layout };
|
|
541
|
+
});
|
|
542
|
+
if ((0, run_record_1.canonical)(prior) !== (0, run_record_1.canonical)(plans)) {
|
|
543
|
+
run.prepareSourceCompletion(plans, state.sequence, options.allowDeferred);
|
|
544
|
+
state = run.read();
|
|
545
|
+
}
|
|
546
|
+
afterStage?.('source-plan');
|
|
547
|
+
if (!run.reviewer || node_path_1.default.basename(run.reviewer) !== run.reviewer || run.reviewer.includes('\\') || run.reviewer.includes(':') || /[\x00-\x1f]/.test(run.reviewer))
|
|
548
|
+
throw new Error('invalid-rejection-reviewer');
|
|
549
|
+
const rejectedFile = (0, owner_identity_1.ownerArtifactPath)(runHome, `learnings/${run.reviewer}-rejected-proposals.md`);
|
|
550
|
+
const rejections = completedActions.filter(action => action.decision?.action === 'reject').map(action => {
|
|
551
|
+
const marker = `<!-- sleep-rejection:${run.runId}:${action.actionId}:${action.revision} -->`;
|
|
552
|
+
const end = '<!-- /sleep-rejection -->';
|
|
553
|
+
const { application: _application, ...proposal } = action;
|
|
554
|
+
const archivedSources = plans.filter(source => action.sourceRefs.some(ref => node_path_1.default.isAbsolute(ref) && (0, completion_state_2.sourcePathKey)(ref) === (0, completion_state_2.sourcePathKey)(source.path))).map(source => source.destination);
|
|
555
|
+
// Issue #1857 D2: the heading names which kind of rejection this was.
|
|
556
|
+
// learning-review-presentation records "a rule or skill now owns this" as a
|
|
557
|
+
// reject, and heading it "Rejected" reports the manager's own promotion back
|
|
558
|
+
// to them as a refusal. The decision object below carries `kind` for readers;
|
|
559
|
+
// this line carries it for the manager.
|
|
560
|
+
const heading = action.decision.kind === 'superseded' ? 'Superseded' : 'Rejected';
|
|
561
|
+
const render = (priorArchives) => `${marker}\n## ${heading}: ${action.content?.title || action.claimKey}\n\nReason: ${action.decision.reason}\n${action.decision.revisitCondition ? `\nRevisit when: ${action.decision.revisitCondition}\n` : ''}\n\x60\x60\x60json\n${JSON.stringify({ ...proposal, ...(priorArchives?.length ? { archivedSources: priorArchives } : {}) }, null, 2)}\n\x60\x60\x60\n${end}\n`;
|
|
562
|
+
const archives = plans.filter(source => archivedSources.includes(source.destination)).map(source => {
|
|
563
|
+
const archiveMarker = `<!-- sleep-rejection-archive:${run.runId}:${action.actionId}:${action.revision}:${(0, proposal_queue_1.hashContent)(source.destination).slice(0, 16)} -->`;
|
|
564
|
+
return { marker: archiveMarker, block: `${archiveMarker}\n\x60\x60\x60json\n${JSON.stringify({ actionId: action.actionId, revision: action.revision, digest: action.digest, sourceRef: source.path, sourceHash: source.sha256, archivedSources: [source.destination] }, null, 2)}\n\x60\x60\x60\n<!-- /sleep-rejection-archive -->\n` };
|
|
565
|
+
});
|
|
566
|
+
return { marker, end, block: render(), render, archivedSources, archives };
|
|
567
|
+
});
|
|
568
|
+
const persistRejections = (includeArchives) => {
|
|
569
|
+
if (!rejections.length)
|
|
570
|
+
return;
|
|
571
|
+
const currentHome = options.resolveHome(run.owner);
|
|
572
|
+
(0, owner_identity_1.ownerArtifactPath)(currentHome, `learnings/${run.reviewer}-rejected-proposals.md`);
|
|
573
|
+
regular(rejectedFile, currentHome.root);
|
|
574
|
+
const previous = node_fs_1.default.existsSync(rejectedFile) ? node_fs_1.default.readFileSync(rejectedFile, 'utf8') : undefined;
|
|
575
|
+
let body = previous ?? '# Rejected proposals\n\n';
|
|
576
|
+
for (const item of rejections) {
|
|
577
|
+
const start = body.indexOf(item.marker);
|
|
578
|
+
if (start >= 0) {
|
|
579
|
+
if (body.indexOf(item.marker, start + 1) >= 0)
|
|
580
|
+
throw new Error('rejected-proposal-conflict');
|
|
581
|
+
const end = body.indexOf(item.end, start);
|
|
582
|
+
const existing = body.slice(start, end + item.end.length + 1);
|
|
583
|
+
if (existing !== item.block) {
|
|
584
|
+
// Older writers embedded archive metadata in the proposal. Verify
|
|
585
|
+
// those exact historical bytes without rewriting them on recovery.
|
|
586
|
+
let archived;
|
|
587
|
+
try {
|
|
588
|
+
archived = JSON.parse(existing.match(/\x60\x60\x60json\n([\s\S]*?)\n\x60\x60\x60/)[1]).archivedSources;
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
throw new Error('rejected-proposal-conflict');
|
|
592
|
+
}
|
|
593
|
+
if (!Array.isArray(archived) || !archived.length || archived.some(source => !item.archivedSources.includes(source)) || existing !== item.render(archived))
|
|
594
|
+
throw new Error('rejected-proposal-conflict');
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
else
|
|
598
|
+
body += '\n' + item.block;
|
|
599
|
+
if (includeArchives)
|
|
600
|
+
for (const archive of item.archives) {
|
|
601
|
+
const index = body.indexOf(archive.marker);
|
|
602
|
+
if (index < 0)
|
|
603
|
+
body += '\n' + archive.block;
|
|
604
|
+
else if (body.slice(index, index + archive.block.length) !== archive.block || body.indexOf(archive.marker, index + 1) >= 0)
|
|
605
|
+
throw new Error('rejected-proposal-conflict');
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (body !== previous)
|
|
609
|
+
(0, proposal_queue_1.atomicWrite)(rejectedFile, body);
|
|
610
|
+
};
|
|
611
|
+
return {
|
|
612
|
+
files: plans.flatMap(source => [source.path, source.destination]).concat(rejections.length ? [rejectedFile] : []),
|
|
613
|
+
finish: () => {
|
|
614
|
+
if ((0, run_record_1.canonical)(run.read().sourceParticipants) !== (0, run_record_1.canonical)(state.sourceParticipants))
|
|
615
|
+
throw new Error('source-participants-conflict');
|
|
616
|
+
persistRejections(false);
|
|
617
|
+
for (const source of plans) {
|
|
618
|
+
const { root, destination, contentRoot, owner, layout } = allowed(source);
|
|
619
|
+
regular(source.path, root);
|
|
620
|
+
regular(destination, root);
|
|
621
|
+
if ((0, completion_state_2.sourcePathKey)(destination) !== (0, completion_state_2.sourcePathKey)(source.destination) || (source.layout && Object.keys(layout).some(key => typeof source.layout[key] !== 'string' || (0, completion_state_2.sourcePathKey)(layout[key]) !== (0, completion_state_2.sourcePathKey)(source.layout[key]))))
|
|
622
|
+
throw new Error('completion-sources-conflict');
|
|
623
|
+
const body = node_fs_1.default.existsSync(source.path) ? node_fs_1.default.readFileSync(source.path, 'utf8') : undefined;
|
|
624
|
+
if (body !== undefined) {
|
|
625
|
+
if ((0, proposal_queue_1.hashContent)(body) !== source.sha256)
|
|
626
|
+
throw new Error('analyzed-source-changed');
|
|
627
|
+
validateInput(body, source, root, contentRoot, owner);
|
|
628
|
+
}
|
|
629
|
+
if (!node_fs_1.default.existsSync(destination)) {
|
|
630
|
+
if (body === undefined)
|
|
631
|
+
throw new Error('analyzed-source-missing');
|
|
632
|
+
const result = stamp(body, source.synthesizedAt);
|
|
633
|
+
if ((0, proposal_queue_1.hashContent)(result) !== source.resultHash)
|
|
634
|
+
throw new Error('completion-sources-conflict');
|
|
635
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(destination), { recursive: true });
|
|
636
|
+
try {
|
|
637
|
+
node_fs_1.default.writeFileSync(destination, result, { flag: 'wx' });
|
|
638
|
+
}
|
|
639
|
+
catch (error) {
|
|
640
|
+
if (error.code !== 'EEXIST')
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
afterStage?.('source-write');
|
|
644
|
+
}
|
|
645
|
+
regular(destination, root);
|
|
646
|
+
const archivedBody = node_fs_1.default.readFileSync(destination, 'utf8');
|
|
647
|
+
if ((0, proposal_queue_1.hashContent)(archivedBody) !== source.resultHash)
|
|
648
|
+
throw new Error('source-archive-conflict');
|
|
649
|
+
validateInput(archivedBody, source, root, contentRoot, owner, destination);
|
|
650
|
+
afterStage?.('source-verified');
|
|
651
|
+
if (body !== undefined) {
|
|
652
|
+
const currentRunHome = options.resolveHome(run.owner);
|
|
653
|
+
(0, owner_identity_1.assertSameOwner)(run.owner, currentRunHome.owner);
|
|
654
|
+
const currentRunPaths = (0, artifact_paths_1.resolveArtifactPaths)((0, owner_identity_1.ownerArtifactRoot)(currentRunHome), currentRunHome.contentRoot);
|
|
655
|
+
if ((0, completion_state_2.sourcePathKey)((0, owner_identity_1.ownerArtifactRoot)(currentRunHome)) !== (0, completion_state_2.sourcePathKey)((0, owner_identity_1.ownerArtifactRoot)(runHome))
|
|
656
|
+
|| (0, completion_state_2.sourcePathKey)(currentRunHome.contentRoot) !== (0, completion_state_2.sourcePathKey)(runHome.contentRoot)
|
|
657
|
+
|| (0, completion_state_2.sourcePathKey)(node_path_1.default.join(currentRunPaths.evidence, 'sleep')) !== (0, completion_state_2.sourcePathKey)(run.completionDirectory)
|
|
658
|
+
|| (0, completion_state_2.sourcePathKey)(currentRunPaths.retrospectives) !== (0, completion_state_2.sourcePathKey)(node_path_1.default.dirname(retrospectiveFile)))
|
|
659
|
+
throw new Error('completion-artifact-paths-changed');
|
|
660
|
+
const current = allowed(source);
|
|
661
|
+
if ((0, completion_state_2.sourcePathKey)(current.destination) !== (0, completion_state_2.sourcePathKey)(destination))
|
|
662
|
+
throw new Error('completion-sources-conflict');
|
|
663
|
+
regular(source.path, root);
|
|
664
|
+
regular(destination, root);
|
|
665
|
+
if ((0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(destination, 'utf8')) !== source.resultHash)
|
|
666
|
+
throw new Error('source-archive-conflict');
|
|
667
|
+
if ((0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(source.path, 'utf8')) !== source.sha256)
|
|
668
|
+
throw new Error('analyzed-source-changed');
|
|
669
|
+
if ((0, run_record_1.canonical)(run.read().sourceParticipants) !== (0, run_record_1.canonical)(state.sourceParticipants))
|
|
670
|
+
throw new Error('source-participants-conflict');
|
|
671
|
+
node_fs_1.default.unlinkSync(source.path);
|
|
672
|
+
afterStage?.('source-unlink');
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
persistRejections(true);
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
/** Verify final delivery outside locks, then keep final artifacts stable during retirement. */
|
|
680
|
+
function completeRun(run, summary, homes, options, afterStage) {
|
|
681
|
+
// An interrupted assessment defers its own evidence while independent approved work can finish.
|
|
682
|
+
if (node_fs_1.default.existsSync(run.file)) {
|
|
683
|
+
const assessmentState = run.read();
|
|
684
|
+
const pending = assessmentState.pendingOutcomeAssessments ?? [];
|
|
685
|
+
if (assessmentState.checkpoint?.receipt.deferred && (assessmentState.outcomeAssessments?.length || pending.length))
|
|
686
|
+
options = { ...options, allowDeferred: true };
|
|
687
|
+
if (pending.length) {
|
|
688
|
+
const sources = [...(options.deferredSources ?? [])];
|
|
689
|
+
for (const assessment of pending)
|
|
690
|
+
for (const ref of assessment.sourceRefs) {
|
|
691
|
+
const home = options.resolveHome(ref.owner);
|
|
692
|
+
(0, owner_identity_1.assertSameOwner)(home.owner, ref.owner);
|
|
693
|
+
const file = (0, owner_identity_1.configuredArtifactPath)(home, node_path_1.default.join((0, owner_identity_1.ownerArtifactRoot)(home), ref.relativePath));
|
|
694
|
+
if (!sources.some(source => (0, completion_state_2.sourcePathKey)(source.path) === (0, completion_state_2.sourcePathKey)(file)))
|
|
695
|
+
sources.push({ path: file, owner: ref.owner, sha256: ref.sha256, reason: 'assessment-processing-pending' });
|
|
696
|
+
}
|
|
697
|
+
options = { ...options, allowDeferred: true, deferredSources: sources };
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
let deferred;
|
|
701
|
+
let deferredCandidate;
|
|
702
|
+
if (options.allowDeferred && node_fs_1.default.existsSync(run.file)) {
|
|
703
|
+
const state = run.read(), completed = new Set((0, run_record_1.completedRunActions)(state).map(action => action.actionId));
|
|
704
|
+
const actions = Object.values(state.actions).filter(action => !completed.has(action.actionId));
|
|
705
|
+
const sources = [...(options.deferredSources ?? [])];
|
|
706
|
+
for (const source of state.checkpoint?.receipt.deferred?.sources ?? []) {
|
|
707
|
+
if (source.reason === 'assessment-processing-pending' && !state.pendingOutcomeAssessments?.some(assessment => assessment.sourceRefs.some(ref => {
|
|
708
|
+
const home = options.resolveHome(ref.owner);
|
|
709
|
+
return (0, completion_state_2.sourcePathKey)(node_path_1.default.join((0, owner_identity_1.ownerArtifactRoot)(home), ref.relativePath)) === (0, completion_state_2.sourcePathKey)(source.path);
|
|
710
|
+
})))
|
|
711
|
+
continue;
|
|
712
|
+
if (!sources.some(item => (0, completion_state_2.sourcePathKey)(item.path) === (0, completion_state_2.sourcePathKey)(source.path))
|
|
713
|
+
&& !options.analyzedSources?.some(item => (0, completion_state_2.sourcePathKey)(item.path) === (0, completion_state_2.sourcePathKey)(source.path)))
|
|
714
|
+
sources.push(source);
|
|
715
|
+
}
|
|
716
|
+
const sourceHome = (file) => homes.find(home => {
|
|
717
|
+
const locations = (0, artifact_paths_1.resolveArtifactPaths)((0, owner_identity_1.ownerArtifactRoot)(home), home.contentRoot);
|
|
718
|
+
return [locations.raw, locations.retrospectives].some(directory => (0, completion_state_2.sourcePathKey)(node_path_1.default.dirname(file)) === (0, completion_state_2.sourcePathKey)(directory));
|
|
719
|
+
});
|
|
720
|
+
for (const action of actions)
|
|
721
|
+
for (const ref of action.sourceRefs) {
|
|
722
|
+
if (!node_path_1.default.isAbsolute(ref))
|
|
723
|
+
continue;
|
|
724
|
+
const home = sourceHome(ref);
|
|
725
|
+
if (home && !sources.some(source => (0, completion_state_2.sourcePathKey)(source.path) === (0, completion_state_2.sourcePathKey)(ref)))
|
|
726
|
+
sources.push({ path: ref, owner: home.owner, reason: 'action-processing-pending', ...(action.sourceHashes?.[ref] ? { sha256: action.sourceHashes[ref] } : {}) });
|
|
727
|
+
}
|
|
728
|
+
let reservationFailure;
|
|
729
|
+
try {
|
|
730
|
+
(0, completion_state_2.readSourceReservations)([...new Set([run.directory, ...options.runDirectories])], run.file);
|
|
731
|
+
}
|
|
732
|
+
catch (error) {
|
|
733
|
+
reservationFailure = error.message;
|
|
734
|
+
}
|
|
735
|
+
for (const source of options.analyzedSources ?? []) {
|
|
736
|
+
if (!source || !node_path_1.default.isAbsolute(source.path) || !/^[a-f0-9]{64}$/.test(source.sha256) || !['raw', 'retrospective'].includes(source.kind))
|
|
737
|
+
throw new Error('analyzed-sources-required');
|
|
738
|
+
if (state.processedSources?.some(item => (0, completion_state_2.sourcePathKey)(item.path) === (0, completion_state_2.sourcePathKey)(source.path)) || sources.some(item => (0, completion_state_2.sourcePathKey)(item.path) === (0, completion_state_2.sourcePathKey)(source.path)))
|
|
739
|
+
continue;
|
|
740
|
+
const home = sourceHome(source.path);
|
|
741
|
+
if (!home)
|
|
742
|
+
throw new Error('analyzed-source-outside-home');
|
|
743
|
+
let reason = reservationFailure;
|
|
744
|
+
if (!reason)
|
|
745
|
+
try {
|
|
746
|
+
(0, owner_identity_1.configuredArtifactPath)(home, source.path);
|
|
747
|
+
const body = node_fs_1.default.readFileSync(source.path, 'utf8');
|
|
748
|
+
if ((0, proposal_queue_1.hashContent)(body) !== source.sha256)
|
|
749
|
+
throw new Error('analyzed-source-changed');
|
|
750
|
+
const parsed = (0, learning_source_record_1.parseLearningSourceRecord)(body, source.path, (0, owner_identity_1.ownerArtifactRoot)(home), home.contentRoot);
|
|
751
|
+
if (parsed.status !== 'valid' || parsed.record.kind !== source.kind || !(0, completion_state_1.sourceAuthorAllowed)(state, parsed.record.author, home.owner) || parsed.record.state !== 'pending' || parsed.record.synthesized)
|
|
752
|
+
throw new Error('invalid-learning-source');
|
|
753
|
+
// References may use immutable identity, home-relative locator or
|
|
754
|
+
// the selected absolute path. None may archive an unfinished dependency.
|
|
755
|
+
const refs = new Set([parsed.record.usageIdentity, parsed.record.sourceId, parsed.record.usageSourceRef,
|
|
756
|
+
node_path_1.default.relative(home.contentRoot, source.path).replace(/\\/g, '/'), node_path_1.default.relative((0, owner_identity_1.ownerArtifactRoot)(home), source.path).replace(/\\/g, '/')]);
|
|
757
|
+
if (actions.some(action => action.sourceRefs.some(ref => refs.has(ref.replace(/\\/g, '/')) || (node_path_1.default.isAbsolute(ref) && (0, completion_state_2.sourcePathKey)(ref) === (0, completion_state_2.sourcePathKey)(source.path)))))
|
|
758
|
+
throw new Error('action-processing-pending');
|
|
759
|
+
}
|
|
760
|
+
catch (error) {
|
|
761
|
+
reason = error.code || error.message;
|
|
762
|
+
}
|
|
763
|
+
if (reason)
|
|
764
|
+
sources.push({ path: source.path, sha256: source.sha256, owner: home.owner, reason });
|
|
765
|
+
}
|
|
766
|
+
const candidate = { actions: actions.map(action => ({ actionId: action.actionId, revision: action.revision, digest: action.digest,
|
|
767
|
+
reason: !action.decision ? 'awaiting-review' : action.decision.action === 'approve' && action.application?.stage !== 'applied' ? 'application-pending' : 'source-or-dependency-pending' })), sources, corpusComplete: false };
|
|
768
|
+
(0, completion_state_2.validateDeferredWork)(candidate);
|
|
769
|
+
for (const source of sources) {
|
|
770
|
+
const home = sourceHome(source.path);
|
|
771
|
+
if (!home)
|
|
772
|
+
throw new Error('deferred-source-outside-home');
|
|
773
|
+
(0, owner_identity_1.assertSameOwner)(source.owner, home.owner);
|
|
774
|
+
(0, owner_identity_1.assertSameOwner)(home.owner, options.resolveHome(home.owner).owner);
|
|
775
|
+
}
|
|
776
|
+
const omitted = new Set(sources.map(source => (0, completion_state_2.sourcePathKey)(source.path)));
|
|
777
|
+
const inputs = [...(options.analyzedSources ?? [])].filter(source => !omitted.has((0, completion_state_2.sourcePathKey)(source.path)));
|
|
778
|
+
for (const source of state.processedSources ?? []) {
|
|
779
|
+
if (omitted.has((0, completion_state_2.sourcePathKey)(source.path)))
|
|
780
|
+
throw new Error('completed-source-cannot-be-deferred');
|
|
781
|
+
if (!inputs.some(input => (0, completion_state_2.sourcePathKey)(input.path) === (0, completion_state_2.sourcePathKey)(source.path)))
|
|
782
|
+
inputs.push({ path: source.path, sha256: source.sha256, kind: source.kind });
|
|
783
|
+
}
|
|
784
|
+
options = { ...options, analyzedSources: inputs, deferredSources: sources };
|
|
785
|
+
const current = (0, corpus_audit_1.collectLearningCorpus)(homes, [state.reviewer, ...(state.sourceParticipants?.map(grant => grant.author) || [])], options.readerRoots?.());
|
|
786
|
+
if (current.errors.length)
|
|
787
|
+
candidate.corpusErrors = current.errors;
|
|
788
|
+
deferredCandidate = candidate;
|
|
789
|
+
if (actions.length || sources.length || !current.complete || (state.corpusAuditRequired && !state.corpusAudit))
|
|
790
|
+
deferred = candidate;
|
|
791
|
+
}
|
|
792
|
+
const retrospective = options.retrospective;
|
|
793
|
+
const retrospectiveHome = options.resolveHome(run.owner);
|
|
794
|
+
(0, owner_identity_1.assertSameOwner)(run.owner, retrospectiveHome.owner);
|
|
795
|
+
const locations = (0, artifact_paths_1.resolveArtifactPaths)((0, owner_identity_1.ownerArtifactRoot)(retrospectiveHome), retrospectiveHome.contentRoot);
|
|
796
|
+
const retrospectiveDirectory = node_path_1.default.relative((0, owner_identity_1.ownerArtifactRoot)(retrospectiveHome), locations.retrospectives).replace(/\\/g, '/');
|
|
797
|
+
const legacyDirectory = node_path_1.default.relative(retrospectiveHome.contentRoot, locations.retrospectives).replace(/\\/g, '/');
|
|
798
|
+
const expectedPaths = [retrospectiveDirectory, legacyDirectory].filter(directory => directory.split('/').every(part => part && part !== '.' && part !== '..'))
|
|
799
|
+
.map(directory => (0, completion_state_2.cycleRetrospectivePath)(run.runId, run.reviewer, run.owner.level, directory));
|
|
800
|
+
if (!retrospective || !expectedPaths.includes(retrospective.relativePath)
|
|
801
|
+
|| !/^[a-f0-9]{64}$/.test(retrospective.sha256))
|
|
802
|
+
throw new Error('completion-retrospective-required');
|
|
803
|
+
if ((0, completion_state_2.sourcePathKey)(run.completionDirectory) !== (0, completion_state_2.sourcePathKey)(node_path_1.default.join(locations.evidence, 'sleep')))
|
|
804
|
+
throw new Error('completion-evidence-path-changed');
|
|
805
|
+
const retrospectiveFile = (0, owner_identity_1.configuredArtifactPath)(retrospectiveHome, node_path_1.default.join(locations.retrospectives, node_path_1.default.basename(retrospective.relativePath)));
|
|
806
|
+
// Completed outputs may subsequently be synthesized or expire under retention.
|
|
807
|
+
if (!node_fs_1.default.existsSync(run.file))
|
|
808
|
+
return run.complete(summary, () => [], undefined, undefined, retrospective);
|
|
809
|
+
const verifyRetrospective = () => {
|
|
810
|
+
// Meaning, attribution and template fidelity are reviewed by the agent.
|
|
811
|
+
// This guard binds the actual document, including on interruption retries.
|
|
812
|
+
const currentLocations = (0, artifact_paths_1.resolveArtifactPaths)((0, owner_identity_1.ownerArtifactRoot)(retrospectiveHome), retrospectiveHome.contentRoot);
|
|
813
|
+
if ((0, completion_state_2.sourcePathKey)(node_path_1.default.join(currentLocations.evidence, 'sleep')) !== (0, completion_state_2.sourcePathKey)(run.completionDirectory)
|
|
814
|
+
|| (0, completion_state_2.sourcePathKey)(currentLocations.retrospectives) !== (0, completion_state_2.sourcePathKey)(node_path_1.default.dirname(retrospectiveFile)))
|
|
815
|
+
throw new Error('completion-artifact-paths-changed');
|
|
816
|
+
(0, owner_identity_1.configuredArtifactPath)(retrospectiveHome, retrospectiveFile);
|
|
817
|
+
if (!node_fs_1.default.existsSync(retrospectiveFile) || node_fs_1.default.lstatSync(retrospectiveFile).isSymbolicLink())
|
|
818
|
+
throw new Error('completion-retrospective-missing');
|
|
819
|
+
const body = node_fs_1.default.readFileSync(retrospectiveFile, 'utf8');
|
|
820
|
+
if (!body.trim() || (0, proposal_queue_1.hashContent)(body) !== retrospective.sha256)
|
|
821
|
+
throw new Error('completion-retrospective-changed');
|
|
822
|
+
const parsed = (0, learning_source_record_1.parseLearningSourceRecord)(body, retrospectiveFile, (0, owner_identity_1.ownerArtifactRoot)(retrospectiveHome), retrospectiveHome.contentRoot);
|
|
823
|
+
if (parsed.status !== 'valid' || parsed.record.kind !== 'retrospective' || parsed.record.author !== run.reviewer || parsed.record.runId !== run.runId || parsed.record.synthesized)
|
|
824
|
+
throw new Error('completion-retrospective-invalid');
|
|
825
|
+
};
|
|
826
|
+
verifyRetrospective();
|
|
827
|
+
if (!verifyCorpusAudit(run, homes, options) && options.allowDeferred) {
|
|
828
|
+
deferred = deferredCandidate;
|
|
829
|
+
if (!deferred.corpusErrors?.length)
|
|
830
|
+
deferred.corpusErrors = [{ path: run.file, reason: 'corpus-coverage-deferred' }];
|
|
831
|
+
}
|
|
832
|
+
// Every participant locks the same sorted configured run directories while reserving.
|
|
833
|
+
const reservationLocks = options.analyzedSources?.length
|
|
834
|
+
? [...new Set([run.directory, ...options.runDirectories].map(directory => node_path_1.default.join(directory, 'source-completion')))].sort() : [];
|
|
835
|
+
const reserve = (index) => index < reservationLocks.length
|
|
836
|
+
? (0, proposal_queue_1.withFileLock)(reservationLocks[index], () => reserve(index + 1)) : sourceCompletion(run, homes, options, afterStage);
|
|
837
|
+
const sources = reserve(0);
|
|
838
|
+
const state = run.read();
|
|
839
|
+
const terminal = new Map();
|
|
840
|
+
const callerGuards = [];
|
|
841
|
+
for (const id of state.appliedOrder) {
|
|
842
|
+
const action = state.actions[id];
|
|
843
|
+
const steps = action.kind === 'learning' ? action.application.steps : [undefined];
|
|
844
|
+
for (const [index, step] of steps.entries()) {
|
|
845
|
+
const ref = step?.ref || action.target;
|
|
846
|
+
const target = resolved(ref, options);
|
|
847
|
+
terminal.set(target.file + ':' + (ref.entryId || ''), { action, step, index, ...target });
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
const verifyLocal = () => {
|
|
851
|
+
if (run.read().sequence !== state.sequence)
|
|
852
|
+
throw new Error('completion-run-changed');
|
|
853
|
+
for (const item of terminal.values()) {
|
|
854
|
+
const actual = item.step ? currentEntry(item.file, item.step.ref.entryId).entry : node_fs_1.default.existsSync(item.file) ? (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(item.file, 'utf8')) : null;
|
|
855
|
+
const expected = item.step ? item.step.after : item.action.delivery?.expectedHash;
|
|
856
|
+
if (actual !== expected)
|
|
857
|
+
throw new Error('completion-artifact-changed');
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
verifyLocal();
|
|
861
|
+
const finalEvidence = node_fs_1.default.existsSync(run.completionFile) ? node_fs_1.default.readFileSync(run.completionFile, 'utf8') : undefined;
|
|
862
|
+
const committed = finalEvidence && (0, proposal_queue_1.hashContent)(finalEvidence) === state.finalization?.evidenceHash
|
|
863
|
+
&& (0, run_record_1.decodeCompletion)(finalEvidence, { owner: run.owner, runId: run.runId }).finalizationState === 'complete';
|
|
864
|
+
for (const item of terminal.values()) {
|
|
865
|
+
if (!item.step) {
|
|
866
|
+
const proof = options.verifyCapability?.(item.action);
|
|
867
|
+
if (!proof?.published || !proof.reachable || proof.contentHash !== item.action.delivery?.expectedHash)
|
|
868
|
+
throw new Error('completion-publication-pending');
|
|
869
|
+
callerGuards.push(...capabilityGuards(item.action, proof, options));
|
|
870
|
+
}
|
|
871
|
+
else if (!committed && item.home.requiresPublish) {
|
|
872
|
+
const proof = options.publish?.(item.file, item.home, `${run.runId}-${item.action.actionId}-${item.action.revision}-${item.index}`, (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(item.file, 'utf8')), { entryId: item.step.ref.entryId, after: item.step.after });
|
|
873
|
+
if (!proof?.published)
|
|
874
|
+
throw new Error('completion-publication-pending');
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
const auditedFiles = state.corpusAudit?.snapshot.entries.map(entry => resolved(entry.ref, options).file) || [];
|
|
878
|
+
const files = [...new Set([...terminal.values()].map(item => item.file).concat(auditedFiles, callerGuards.map(guard => guard.file), retrospectiveFile, locations.configPath, sources.files))].sort();
|
|
879
|
+
const locked = (index) => index < files.length ? (0, proposal_queue_1.withFileLock)(files[index], () => locked(index + 1))
|
|
880
|
+
: run.complete(summary, () => completionInventory(run, homes), afterStage, finalizing => {
|
|
881
|
+
verifyRetrospective();
|
|
882
|
+
verifyCorpusAudit(run, homes, options);
|
|
883
|
+
// Finalization appends its own evidence intents; approved action data must stay fixed.
|
|
884
|
+
if ((0, run_record_1.canonical)(run.read().actions) !== (0, run_record_1.canonical)(state.actions))
|
|
885
|
+
throw new Error('completion-run-changed');
|
|
886
|
+
if ((0, run_record_1.canonical)(run.read().outcomeAssessments) !== (0, run_record_1.canonical)(state.outcomeAssessments)
|
|
887
|
+
|| (0, run_record_1.canonical)(run.read().pendingOutcomeAssessments) !== (0, run_record_1.canonical)(state.pendingOutcomeAssessments))
|
|
888
|
+
throw new Error('completion-run-changed');
|
|
889
|
+
for (const guard of callerGuards)
|
|
890
|
+
guard.verify();
|
|
891
|
+
for (const item of terminal.values()) {
|
|
892
|
+
const actual = item.step ? currentEntry(item.file, item.step.ref.entryId).entry : node_fs_1.default.existsSync(item.file) ? (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(item.file, 'utf8')) : null;
|
|
893
|
+
if (actual !== (item.step ? item.step.after : item.action.delivery?.expectedHash))
|
|
894
|
+
throw new Error('completion-artifact-changed');
|
|
895
|
+
}
|
|
896
|
+
if (finalizing && !deferred)
|
|
897
|
+
completionInventory(run, homes, true);
|
|
898
|
+
}, retrospective, sources.finish, deferred);
|
|
899
|
+
verifyLocal();
|
|
900
|
+
return locked(0);
|
|
901
|
+
}
|
|
902
|
+
/** Inspect every configured home. Only reviewed, hash-bound recovery files are deletion candidates. */
|
|
903
|
+
function completionInventory(run, homes, removeFamilies = false) {
|
|
904
|
+
const state = node_fs_1.default.existsSync(run.file) ? run.read() : (0, run_record_1.decodeCompletion)(node_fs_1.default.readFileSync(run.completionFile, 'utf8'), { runId: run.runId, owner: run.owner });
|
|
905
|
+
for (const home of homes)
|
|
906
|
+
(0, owner_identity_1.ownerArtifactPath)(home, 'learnings');
|
|
907
|
+
const known = new Map();
|
|
908
|
+
let recordedCleanup = state.cleanup;
|
|
909
|
+
if ('finalization' in state && state.finalization && node_fs_1.default.existsSync(run.completionFile)) {
|
|
910
|
+
const evidence = node_fs_1.default.readFileSync(run.completionFile, 'utf8');
|
|
911
|
+
if (![state.finalization.evidenceHash, state.finalization.previousEvidenceHash].includes((0, proposal_queue_1.hashContent)(evidence)))
|
|
912
|
+
throw new Error('completion-evidence-conflict');
|
|
913
|
+
if (evidence.includes('fraim-sleep-decisions')) {
|
|
914
|
+
const prior = (0, run_record_1.decodeCompletion)(evidence, { runId: run.runId, owner: run.owner });
|
|
915
|
+
recordedCleanup = [...prior.cleanup, ...recordedCleanup];
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
for (const item of recordedCleanup) {
|
|
919
|
+
if (!item.location)
|
|
920
|
+
throw new Error('cleanup-owner-unresolved');
|
|
921
|
+
const home = homes.find(home => (0, owner_identity_1.ownerKey)(home.owner) === (0, owner_identity_1.ownerKey)(item.location.owner));
|
|
922
|
+
if (!home)
|
|
923
|
+
throw new Error('cleanup-owner-unresolved');
|
|
924
|
+
const file = (0, owner_identity_1.ownerArtifactPath)(home, item.location.relativePath);
|
|
925
|
+
if (node_path_1.default.resolve(item.path) !== file)
|
|
926
|
+
throw new Error('cleanup-owner-unresolved');
|
|
927
|
+
known.set(file, item);
|
|
928
|
+
}
|
|
929
|
+
const result = new Map();
|
|
930
|
+
removeEmptyGenerated(homes, false);
|
|
931
|
+
const authorize = (file) => {
|
|
932
|
+
for (const home of homes) {
|
|
933
|
+
const relative = node_path_1.default.relative(home.contentRoot, file).replace(/\\/g, '/');
|
|
934
|
+
if (relative.startsWith('../') || relative === '..' || node_path_1.default.isAbsolute(relative))
|
|
935
|
+
continue;
|
|
936
|
+
if (!run_record_1.RECOVERY_DIRECTORIES.some(directory => relative.startsWith(`learnings/${directory}/`)))
|
|
937
|
+
continue;
|
|
938
|
+
return (0, run_record_1.bindCleanupItem)({ path: file, outcome: 'pending' }, home).path;
|
|
939
|
+
}
|
|
940
|
+
throw new Error('cleanup-owner-unresolved: recorded recovery is outside configured owner homes');
|
|
941
|
+
};
|
|
942
|
+
for (const [file, item] of known) {
|
|
943
|
+
if (item.outcome === 'retained') {
|
|
944
|
+
if (['raw-source', 'unrelated'].includes(item.reason || '')) {
|
|
945
|
+
const authorized = authorize(file);
|
|
946
|
+
if (!node_fs_1.default.lstatSync(authorized).isFile() || !item.sha256 || (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(authorized, 'utf8')) !== item.sha256)
|
|
947
|
+
throw new Error('cleanup-content-conflict');
|
|
948
|
+
}
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
const authorized = authorize(file);
|
|
952
|
+
try {
|
|
953
|
+
if (!node_fs_1.default.lstatSync(authorized).isFile())
|
|
954
|
+
throw new Error('cleanup-link-conflict');
|
|
955
|
+
if (!item.sha256 || (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(authorized, 'utf8')) !== item.sha256)
|
|
956
|
+
throw new Error('cleanup-content-conflict');
|
|
957
|
+
result.set(file, { ...item, path: authorized, outcome: 'pending' });
|
|
958
|
+
}
|
|
959
|
+
catch (error) {
|
|
960
|
+
if (error.code !== 'ENOENT')
|
|
961
|
+
throw error;
|
|
962
|
+
result.set(file, { ...item, path: authorized, outcome: 'absent' });
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
// Future runs reconcile only recovery explicitly bound in their journal. Old
|
|
966
|
+
// review directories remain untouched and cannot block a new input batch.
|
|
967
|
+
removeEmptyGenerated(homes, removeFamilies);
|
|
968
|
+
return [...result.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
969
|
+
}
|
|
970
|
+
function removeEmptyGenerated(homes, removeFamilies = true) {
|
|
971
|
+
for (const home of homes) {
|
|
972
|
+
// Validate the complete ancestor chain before any scaffold mutation.
|
|
973
|
+
(0, run_record_1.bindCleanupItem)({ path: node_path_1.default.join(home.contentRoot, 'learnings/review-evidence/.cleanup-path-check'), outcome: 'retained' }, home);
|
|
974
|
+
const learnings = (0, owner_identity_1.ownerArtifactPath)(home, 'learnings');
|
|
975
|
+
if (!node_fs_1.default.existsSync(learnings))
|
|
976
|
+
continue;
|
|
977
|
+
for (const name of node_fs_1.default.readdirSync(learnings)) {
|
|
978
|
+
if (!removeFamilies)
|
|
979
|
+
break;
|
|
980
|
+
if (!/-(preferences|mistake-patterns|validated-patterns|manager-coaching)(\.cold)?\.md$/.test(name))
|
|
981
|
+
continue;
|
|
982
|
+
const file = (0, owner_identity_1.ownerArtifactPath)(home, 'learnings/' + name);
|
|
983
|
+
if (node_fs_1.default.lstatSync(file).isSymbolicLink())
|
|
984
|
+
throw new Error('cleanup-link-conflict');
|
|
985
|
+
const body = node_fs_1.default.readFileSync(file, 'utf8');
|
|
986
|
+
if (!learningEntryBlocks(body).length && (0, entry_authoring_1.isLearningScaffolding)(body, name))
|
|
987
|
+
node_fs_1.default.unlinkSync(file);
|
|
988
|
+
}
|
|
989
|
+
for (const relative of run_record_1.RECOVERY_DIRECTORIES) {
|
|
990
|
+
const clear = (dir) => {
|
|
991
|
+
if (!node_fs_1.default.existsSync(dir))
|
|
992
|
+
return;
|
|
993
|
+
for (const item of node_fs_1.default.readdirSync(dir, { withFileTypes: true }))
|
|
994
|
+
if (item.isDirectory() && !item.isSymbolicLink())
|
|
995
|
+
clear(node_path_1.default.join(dir, item.name));
|
|
996
|
+
if (!node_fs_1.default.readdirSync(dir).length)
|
|
997
|
+
node_fs_1.default.rmdirSync(dir);
|
|
998
|
+
};
|
|
999
|
+
const checked = (0, run_record_1.bindCleanupItem)({ path: node_path_1.default.join(home.contentRoot, 'learnings', relative, '.cleanup-path-check'), outcome: 'retained' }, home).path;
|
|
1000
|
+
clear(node_path_1.default.dirname(checked));
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|