fraim 2.0.350 → 2.0.352

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.
@@ -15,6 +15,7 @@ const run_record_1 = require("./run-record");
15
15
  const owner_identity_1 = require("./owner-identity");
16
16
  const proposal_queue_1 = require("./proposal-queue");
17
17
  const entry_authoring_1 = require("./entry-authoring");
18
+ const run_publication_1 = require("./run-publication");
18
19
  const completion_state_2 = require("./completion-state");
19
20
  const artifact_paths_1 = require("../artifact-paths");
20
21
  const learning_source_record_1 = require("../learning-usage/learning-source-record");
@@ -50,8 +51,11 @@ function capabilityGuards(action, proof, options) {
50
51
  || typeof snapshot.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(snapshot.sha256);
51
52
  }))
52
53
  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');
54
+ if (snapshots.get(node_path_1.default.resolve(required[0]))?.sha256 !== action.delivery?.expectedHash) {
55
+ if (typeof proof.reviewedContent !== 'string' || (0, proposal_queue_1.hashContent)(proof.reviewedContent) !== action.delivery?.expectedHash
56
+ || !(0, run_publication_1.sameDeliveryText)(node_fs_1.default.readFileSync(required[0], 'utf8'), proof.reviewedContent))
57
+ throw new Error('capability-local-proof-conflict');
58
+ }
55
59
  return [...snapshots.values()].map(item => {
56
60
  const absent = item.realPath === null && item.sha256 === null;
57
61
  if (!absent && (typeof item.realPath !== 'string' || typeof item.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(item.sha256)))
@@ -346,8 +350,11 @@ function applyRunAction(run, actionId, revision, expectedSequence, options) {
346
350
  if (!delivered?.published || !delivered.reachable || delivered.contentHash !== action.delivery?.expectedHash)
347
351
  throw new Error('capability-publication-or-reachability-pending');
348
352
  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 }));
353
+ if (action.application?.stage !== 'applied' || (delivered.reviewedContent !== undefined && action.application.reviewedContent === undefined))
354
+ (0, proposal_queue_1.withFileLock)(run.file, () => run.progress(actionId, {
355
+ stage: 'applied', steps: [], publicationRef: delivered.reference,
356
+ ...(delivered.reviewedContent !== undefined ? { reviewedContent: delivered.reviewedContent } : {}),
357
+ }));
351
358
  });
352
359
  return run.read().actions[actionId];
353
360
  }
@@ -515,7 +522,8 @@ function sourceCompletion(run, homes, options, afterStage) {
515
522
  const prior = state.processedSources;
516
523
  const batch = (sources) => sources.map(source => ({ ...source, path: (0, completion_state_2.sourcePathKey)(source.path) })).sort((a, b) => a.path.localeCompare(b.path));
517
524
  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))))
525
+ && (!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)
526
+ && !options.deferredSources?.some(item => (0, completion_state_2.sourcePathKey)(source.path) === (0, completion_state_2.sourcePathKey)(item.path)))))
519
527
  throw new Error('completion-sources-conflict');
520
528
  const date = new Date().toISOString().slice(0, 10);
521
529
  const validateInput = (body, source, root, contentRoot, owner, archivePath) => {
@@ -528,8 +536,11 @@ function sourceCompletion(run, homes, options, afterStage) {
528
536
  };
529
537
  const plans = normalized.map(source => {
530
538
  const recorded = prior?.find(item => (0, completion_state_2.sourcePathKey)(item.path) === (0, completion_state_2.sourcePathKey)(source.path));
531
- if (recorded)
539
+ if (recorded) {
540
+ if (source.sha256 !== recorded.sha256 || source.kind !== recorded.kind)
541
+ throw new Error(`completion-sources-conflict: ${source.path}: original plan changed`);
532
542
  return recorded;
543
+ }
533
544
  const { root, destination, contentRoot, owner, layout } = allowed(source);
534
545
  regular(source.path, root);
535
546
  regular(destination, root);
@@ -539,8 +550,9 @@ function sourceCompletion(run, homes, options, afterStage) {
539
550
  validateInput(body, source, root, contentRoot, owner);
540
551
  return { ...source, destination, resultHash: (0, proposal_queue_1.hashContent)(stamp(body, date)), synthesizedAt: date, layout };
541
552
  });
542
- if ((0, run_record_1.canonical)(prior) !== (0, run_record_1.canonical)(plans)) {
543
- run.prepareSourceCompletion(plans, state.sequence, options.allowDeferred);
553
+ const reservationsAndPlans = [...(prior ?? []), ...plans.filter(source => !prior?.some(item => (0, completion_state_2.sourcePathKey)(item.path) === (0, completion_state_2.sourcePathKey)(source.path)))];
554
+ if ((0, run_record_1.canonical)(prior) !== (0, run_record_1.canonical)(reservationsAndPlans)) {
555
+ run.prepareSourceCompletion(reservationsAndPlans, state.sequence, options.allowDeferred);
544
556
  state = run.read();
545
557
  }
546
558
  afterStage?.('source-plan');
@@ -726,16 +738,21 @@ function completeRun(run, summary, homes, options, afterStage) {
726
738
  sources.push({ path: ref, owner: home.owner, reason: 'action-processing-pending', ...(action.sourceHashes?.[ref] ? { sha256: action.sourceHashes[ref] } : {}) });
727
739
  }
728
740
  let reservationFailure;
741
+ let reserved = [];
729
742
  try {
730
- (0, completion_state_2.readSourceReservations)([...new Set([run.directory, ...options.runDirectories])], run.file);
743
+ reserved = (0, completion_state_2.readSourceReservations)([...new Set([run.directory, ...options.runDirectories])], run.file);
731
744
  }
732
745
  catch (error) {
733
746
  reservationFailure = error.message;
734
747
  }
735
- for (const source of options.analyzedSources ?? []) {
748
+ const selected = [...(options.analyzedSources ?? [])];
749
+ for (const source of state.processedSources ?? [])
750
+ if (!selected.some(item => (0, completion_state_2.sourcePathKey)(item.path) === (0, completion_state_2.sourcePathKey)(source.path)))
751
+ selected.push({ path: source.path, sha256: source.sha256, kind: source.kind });
752
+ for (const source of selected) {
736
753
  if (!source || !node_path_1.default.isAbsolute(source.path) || !/^[a-f0-9]{64}$/.test(source.sha256) || !['raw', 'retrospective'].includes(source.kind))
737
754
  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)))
755
+ if (state.archivedSourcePaths?.includes((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
756
  continue;
740
757
  const home = sourceHome(source.path);
741
758
  if (!home)
@@ -744,6 +761,19 @@ function completeRun(run, summary, homes, options, afterStage) {
744
761
  if (!reason)
745
762
  try {
746
763
  (0, owner_identity_1.configuredArtifactPath)(home, source.path);
764
+ const recorded = state.processedSources?.find(item => (0, completion_state_2.sourcePathKey)(item.path) === (0, completion_state_2.sourcePathKey)(source.path));
765
+ if (recorded && (recorded.sha256 !== source.sha256 || recorded.kind !== source.kind))
766
+ throw new Error('original-source-plan-changed');
767
+ const locations = (0, artifact_paths_1.resolveArtifactPaths)((0, owner_identity_1.ownerArtifactRoot)(home), home.contentRoot);
768
+ const destination = (0, owner_identity_1.configuredArtifactPath)(home, node_path_1.default.join(source.kind === 'raw' ? locations.archiveRaw : locations.archiveRetrospectives, node_path_1.default.basename(source.path)));
769
+ if (reserved.includes((0, completion_state_2.sourcePathKey)(source.path)) || reserved.includes((0, completion_state_2.sourcePathKey)(destination)))
770
+ throw new Error('source-reserved-by-pending-run');
771
+ if (recorded && node_fs_1.default.existsSync(destination) && (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(destination, 'utf8')) !== recorded.resultHash)
772
+ throw new Error('source-archive-conflict');
773
+ // A verified copy with no pending original is an interrupted successful archive.
774
+ // The guarded finalizer revalidates its layout, identity and exact bytes.
775
+ if (recorded && !node_fs_1.default.existsSync(source.path) && node_fs_1.default.existsSync(destination))
776
+ continue;
747
777
  const body = node_fs_1.default.readFileSync(source.path, 'utf8');
748
778
  if ((0, proposal_queue_1.hashContent)(body) !== source.sha256)
749
779
  throw new Error('analyzed-source-changed');
@@ -776,8 +806,11 @@ function completeRun(run, summary, homes, options, afterStage) {
776
806
  const omitted = new Set(sources.map(source => (0, completion_state_2.sourcePathKey)(source.path)));
777
807
  const inputs = [...(options.analyzedSources ?? [])].filter(source => !omitted.has((0, completion_state_2.sourcePathKey)(source.path)));
778
808
  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');
809
+ if (omitted.has((0, completion_state_2.sourcePathKey)(source.path))) {
810
+ if (state.archivedSourcePaths?.includes((0, completion_state_2.sourcePathKey)(source.path)))
811
+ throw new Error(`completed-source-cannot-be-deferred: ${source.path}`);
812
+ continue;
813
+ }
781
814
  if (!inputs.some(input => (0, completion_state_2.sourcePathKey)(input.path) === (0, completion_state_2.sourcePathKey)(source.path)))
782
815
  inputs.push({ path: source.path, sha256: source.sha256, kind: source.kind });
783
816
  }
@@ -853,14 +886,16 @@ function completeRun(run, summary, homes, options, afterStage) {
853
886
  for (const item of terminal.values()) {
854
887
  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
888
  const expected = item.step ? item.step.after : item.action.delivery?.expectedHash;
856
- if (actual !== expected)
857
- throw new Error('completion-artifact-changed');
889
+ const original = item.action.application?.reviewedContent;
890
+ if (actual !== expected && (item.step || typeof original !== 'string' || (0, proposal_queue_1.hashContent)(original) !== expected
891
+ || !(0, run_publication_1.sameDeliveryText)(node_fs_1.default.readFileSync(item.file, 'utf8'), original)))
892
+ throw new Error(`completion-artifact-changed: ${item.file}`);
858
893
  }
859
894
  };
860
895
  verifyLocal();
861
896
  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';
897
+ const committed = finalEvidence && state.finalization?.receipt?.finalizationState === 'complete'
898
+ && (0, run_record_1.canonical)(run.readCompletion(finalEvidence)) === (0, run_record_1.canonical)(state.finalization.receipt);
864
899
  for (const item of terminal.values()) {
865
900
  if (!item.step) {
866
901
  const proof = options.verifyCapability?.(item.action);
@@ -890,8 +925,10 @@ function completeRun(run, summary, homes, options, afterStage) {
890
925
  guard.verify();
891
926
  for (const item of terminal.values()) {
892
927
  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');
928
+ if (item.step && actual !== item.step.after)
929
+ throw new Error(`completion-artifact-changed: ${item.file}`);
930
+ // Capability guards recheck exact local snapshots captured by the
931
+ // delivery verifier, including when approved text used other EOLs.
895
932
  }
896
933
  if (finalizing && !deferred)
897
934
  completionInventory(run, homes, true);
@@ -908,11 +945,12 @@ function completionInventory(run, homes, removeFamilies = false) {
908
945
  let recordedCleanup = state.cleanup;
909
946
  if ('finalization' in state && state.finalization && node_fs_1.default.existsSync(run.completionFile)) {
910
947
  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
948
  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];
949
+ const prior = run.readCompletion(evidence);
950
+ // Legacy finalizations have no journaled receipt. Their narrative cannot
951
+ // introduce cleanup authority beyond the original journal inventory.
952
+ if (state.finalization.receipt)
953
+ recordedCleanup = [...prior.cleanup, ...recordedCleanup];
916
954
  }
917
955
  }
918
956
  for (const item of recordedCleanup) {
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.sameDeliveryText = sameDeliveryText;
6
7
  exports.publishRunFile = publishRunFile;
7
8
  exports.verifyCapabilityDelivery = verifyCapabilityDelivery;
8
9
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -11,6 +12,10 @@ const node_child_process_1 = require("node:child_process");
11
12
  const proposal_queue_1 = require("./proposal-queue");
12
13
  const owner_identity_1 = require("./owner-identity");
13
14
  const run_record_1 = require("./run-record");
15
+ /** Delivery permits CRLF/LF conversion only; approval identities and local guards keep exact hashes. */
16
+ function sameDeliveryText(actual, reviewed) {
17
+ return actual.replace(/\r\n/g, '\n') === reviewed.replace(/\r\n/g, '\n');
18
+ }
14
19
  const { learningEntryBlocks } = require('./entry-format');
15
20
  function matchesLearningPublication(body, proof) {
16
21
  body = body.replace(/\r\n/g, '\n');
@@ -64,8 +69,8 @@ function verifySharedProjectPublication(projectPath, file, expectedHash, publica
64
69
  const effective = (0, node_child_process_1.spawnSync)('git', ['show', `${effectiveHead}:${relative}`], { cwd: projectPath, encoding: 'utf8', timeout: 30000, windowsHide: true });
65
70
  const current = node_fs_1.default.readFileSync(artifact.file, 'utf8');
66
71
  if (delivered.status !== 0 || effective.status !== 0 || (0, proposal_queue_1.hashContent)(current) !== artifact.expectedHash
67
- || (0, proposal_queue_1.hashContent)(delivered.stdout.replace(/\r\n/g, '\n')) !== (0, proposal_queue_1.hashContent)(current.replace(/\r\n/g, '\n'))
68
- || (0, proposal_queue_1.hashContent)(effective.stdout.replace(/\r\n/g, '\n')) !== (0, proposal_queue_1.hashContent)(current.replace(/\r\n/g, '\n')))
72
+ || !sameDeliveryText(delivered.stdout, current)
73
+ || !sameDeliveryText(effective.stdout, current))
69
74
  return { published: false, reference: pr.url };
70
75
  }
71
76
  return { published: true, reference: pr.url };
@@ -101,7 +106,7 @@ function publishRunFile(projectPath, file, home, operationKey, expectedHash, run
101
106
  const delivered = (0, node_child_process_1.spawnSync)('git', ['show', `${pr.mergeCommit.oid}:${packRelative}`], { cwd: home.root, encoding: 'utf8', timeout: 30000, windowsHide: true });
102
107
  const current = node_fs_1.default.readFileSync(file, 'utf8');
103
108
  const matches = (body) => entryProof ? matchesLearningPublication(body, entryProof)
104
- : (0, proposal_queue_1.hashContent)(body.replace(/\r\n/g, '\n')) === (0, proposal_queue_1.hashContent)(current.replace(/\r\n/g, '\n'));
109
+ : sameDeliveryText(body, current);
105
110
  if ((0, proposal_queue_1.hashContent)(current) !== expectedHash || fetched.status !== 0 || delivered.status !== 0 || !matches(delivered.stdout))
106
111
  return { published: false, reference: pr.url };
107
112
  // A merged snapshot proves this operation, but a later revert must
@@ -124,6 +129,7 @@ const [publisherPath,userConfigPath,file,relative,layer,branchSuffix,expectedHas
124
129
  const entryProof=JSON.parse(entryProofJson);
125
130
  const {learningEntryBlocks}=require(entryFormatPath);
126
131
  const matchesLearningPublication=${matchesLearningPublication.toString()};
132
+ const sameDeliveryText=${sameDeliveryText.toString()};
127
133
  const publisher=require(publisherPath);
128
134
  const readConfig=require(userConfigPath).readUserFraimConfig;
129
135
  const remote=(process.env.FRAIM_REMOTE_URL||'https://fraim.wellnessatwork.me').replace(/\\/$/,'');
@@ -133,7 +139,7 @@ async function confirmedCloud(){
133
139
  if(!response.ok)throw Error('pack-read-failed');
134
140
  const pack=await response.json();
135
141
  const matches=Array.isArray(pack.files)?pack.files.filter(item=>item.relativePath===relative):[];
136
- return {published:matches.length===1&&typeof matches[0].content==='string'&&(entryProof?matchesLearningPublication(matches[0].content,entryProof):hash(matches[0].content)===expectedHash),reference:remote+'/api/'+layer+'/pack#'+encodeURIComponent(relative)};
142
+ return {published:matches.length===1&&typeof matches[0].content==='string'&&(entryProof?matchesLearningPublication(matches[0].content,entryProof):sameDeliveryText(matches[0].content,fs.readFileSync(file,'utf8'))),reference:remote+'/api/'+layer+'/pack#'+encodeURIComponent(relative)};
137
143
  }
138
144
  (async()=>{
139
145
  if(hash(fs.readFileSync(file,'utf8'))!==expectedHash)throw Error('publication-content-conflict');
@@ -179,9 +185,20 @@ async function verifyCapabilityDelivery(action, input, options) {
179
185
  const target = (0, owner_identity_1.ownerArtifactPath)(home, action.target.relativePath);
180
186
  if (!/^[a-f0-9]{64}$/.test(input?.expectedHash || '') || !Array.isArray(input.callers) || !node_fs_1.default.existsSync(target))
181
187
  throw new Error('capability-verification-required');
182
- const contentHash = (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(target, 'utf8'));
183
- if (contentHash !== input.expectedHash)
184
- return { published: false, reference: target, contentHash, reachable: false };
188
+ const targetContent = node_fs_1.default.readFileSync(target, 'utf8');
189
+ const localHash = (0, proposal_queue_1.hashContent)(targetContent);
190
+ // Legacy approvals may bind mixed line endings. Supplied original bytes are
191
+ // evidence only: their exact hash must already be authorized by the review.
192
+ if (input.reviewedContent !== undefined && (typeof input.reviewedContent !== 'string'
193
+ || (0, proposal_queue_1.hashContent)(input.reviewedContent) !== input.expectedHash))
194
+ throw new Error('capability-reviewed-content-mismatch');
195
+ if (localHash !== input.expectedHash && (input.reviewedContent === undefined || !sameDeliveryText(targetContent, input.reviewedContent))) {
196
+ return { published: false, reference: target, contentHash: localHash, reachable: false };
197
+ }
198
+ const contentHash = input.expectedHash;
199
+ // Preserve success evidence for later newline conversion, including an exact
200
+ // initial delivery. These bytes already match the immutable approved digest.
201
+ const reviewEvidence = { reviewedContent: input.reviewedContent ?? targetContent };
185
202
  const load = (relative) => options.runtimeModule(options.projectPath, relative);
186
203
  if (reader) {
187
204
  const snapshots = new Map();
@@ -249,14 +266,14 @@ async function verifyCapabilityDelivery(action, input, options) {
249
266
  throw new Error('capability-local-content-changed');
250
267
  }
251
268
  };
252
- if (snapshots.get(target)?.sha256 !== contentHash)
269
+ if (snapshots.get(target)?.sha256 !== localHash)
253
270
  throw new Error('capability-local-content-changed');
254
271
  verifySnapshots();
255
272
  if (home.requiresPublish && (!options.runtimeModulePath || !input.operationKey))
256
273
  throw new Error('capability-publication-required');
257
- const publication = home.requiresPublish ? publishRunFile(options.projectPath, target, home, input.operationKey, contentHash, options.runtimeModulePath) : { published: true, reference: target };
274
+ const publication = home.requiresPublish ? publishRunFile(options.projectPath, target, home, input.operationKey, localHash, options.runtimeModulePath) : { published: true, reference: target };
258
275
  verifySnapshots();
259
- return { ...publication, contentHash, reachable: true, localFiles };
276
+ return { ...publication, contentHash, reachable: true, localFiles, ...reviewEvidence };
260
277
  }
261
278
  const { LocalRegistryResolver } = load('core/utils/local-registry-resolver');
262
279
  const { resolvePackHome } = load('cli/utils/pack-home');
@@ -271,7 +288,7 @@ async function verifyCapabilityDelivery(action, input, options) {
271
288
  const snapshot = (file, expectedContent) => {
272
289
  const absolute = node_path_1.default.resolve(file);
273
290
  const body = node_fs_1.default.readFileSync(absolute, 'utf8');
274
- if (expectedContent !== undefined && (0, proposal_queue_1.hashContent)(body) !== (0, proposal_queue_1.hashContent)(expectedContent))
291
+ if (expectedContent !== undefined && !sameDeliveryText(body, expectedContent))
275
292
  throw new Error('capability-local-content-changed');
276
293
  const current = { file: absolute, realPath: node_fs_1.default.realpathSync(absolute), sha256: (0, proposal_queue_1.hashContent)(body) };
277
294
  const prior = snapshots.get(absolute);
@@ -300,16 +317,18 @@ async function verifyCapabilityDelivery(action, input, options) {
300
317
  node_path_1.default.join(options.projectPath, 'fraim/ai-employee', normalized), node_path_1.default.join(options.projectPath, 'fraim/ai-manager', normalized),
301
318
  node_path_1.default.join(options.projectPath, 'fraim', normalized), node_path_1.default.join(options.projectPath, 'registry', normalized)];
302
319
  }
303
- const source = candidates.find(file => node_fs_1.default.existsSync(file) && node_fs_1.default.statSync(file).isFile() && (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(file, 'utf8')) === (0, proposal_queue_1.hashContent)(resolved.content));
320
+ const source = candidates.find(file => node_fs_1.default.existsSync(file) && node_fs_1.default.statSync(file).isFile() && sameDeliveryText(node_fs_1.default.readFileSync(file, 'utf8'), resolved.content));
304
321
  if (source)
305
322
  snapshot(source, resolved.content);
306
323
  };
307
- snapshot(target);
324
+ snapshot(target, targetContent);
325
+ if (snapshots.get(node_path_1.default.resolve(target))?.sha256 !== localHash)
326
+ throw new Error('capability-local-content-changed');
308
327
  const shared = action.target.owner.level === 'Project' && action.target.relativePath.startsWith('registry/');
309
328
  const targetPath = shared ? action.target.relativePath.slice('registry/'.length) : action.target.relativePath;
310
329
  const targetScope = shared ? undefined : action.target.owner.level.toLowerCase();
311
330
  const loadedTarget = await originalResolve(targetPath, { includeMetadata: false, stripMcpHeader: true });
312
- if (loadedTarget.scope !== targetScope || (0, proposal_queue_1.hashContent)(loadedTarget.content) !== contentHash)
331
+ if (loadedTarget.scope !== targetScope || !sameDeliveryText(loadedTarget.content, targetContent))
313
332
  return { published: false, reference: target, contentHash, reachable: false };
314
333
  let reachable = true;
315
334
  const sharedCallers = [];
@@ -321,7 +340,7 @@ async function verifyCapabilityDelivery(action, input, options) {
321
340
  const callerPath = sharedCaller ? caller.relativePath.slice('registry/'.length) : caller.relativePath;
322
341
  const loadedCaller = await originalResolve(callerPath, { includeMetadata: false, stripMcpHeader: true });
323
342
  if (!node_fs_1.default.existsSync(callerFile) || loadedCaller.scope !== (sharedCaller ? undefined : caller.owner.level.toLowerCase())
324
- || (0, proposal_queue_1.hashContent)(loadedCaller.content) !== (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(callerFile, 'utf8'))) {
343
+ || !sameDeliveryText(loadedCaller.content, node_fs_1.default.readFileSync(callerFile, 'utf8'))) {
325
344
  reachable = false;
326
345
  break;
327
346
  }
@@ -332,7 +351,7 @@ async function verifyCapabilityDelivery(action, input, options) {
332
351
  resolver.resolveFile = async (relative, resolveOptions) => {
333
352
  const resolved = await originalResolve(relative, resolveOptions);
334
353
  snapshotResolvedInclude(relative, resolved);
335
- if (relative === targetPath && resolved.scope === targetScope && (0, proposal_queue_1.hashContent)(resolved.content) === contentHash)
354
+ if (relative === targetPath && resolved.scope === targetScope && sameDeliveryText(resolved.content, targetContent))
336
355
  traversed = true;
337
356
  return resolved;
338
357
  };
@@ -343,7 +362,7 @@ async function verifyCapabilityDelivery(action, input, options) {
343
362
  }
344
363
  }
345
364
  resolver.resolveFile = originalResolve;
346
- if (!reachable || (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(target, 'utf8')) !== contentHash)
365
+ if (!reachable || (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(target, 'utf8')) !== localHash)
347
366
  return { published: false, reference: target, contentHash, reachable: false };
348
367
  const localFiles = [...snapshots.values()].sort((a, b) => a.file.localeCompare(b.file));
349
368
  const verifySnapshots = () => {
@@ -352,13 +371,13 @@ async function verifyCapabilityDelivery(action, input, options) {
352
371
  throw new Error('capability-local-content-changed');
353
372
  };
354
373
  if (shared) {
355
- const publication = verifySharedProjectPublication(options.projectPath, target, contentHash, input.publicationRef, sharedCallers);
374
+ const publication = verifySharedProjectPublication(options.projectPath, target, localHash, input.publicationRef, sharedCallers);
356
375
  verifySnapshots();
357
- return { ...publication, contentHash, reachable, localFiles };
376
+ return { ...publication, contentHash, reachable, localFiles, ...reviewEvidence };
358
377
  }
359
378
  if (home.requiresPublish && (!options.runtimeModulePath || !input.operationKey))
360
379
  throw new Error('capability-publication-required');
361
- const publication = home.requiresPublish ? publishRunFile(options.projectPath, target, home, input.operationKey, contentHash, options.runtimeModulePath) : { published: true, reference: target };
380
+ const publication = home.requiresPublish ? publishRunFile(options.projectPath, target, home, input.operationKey, localHash, options.runtimeModulePath) : { published: true, reference: target };
362
381
  verifySnapshots();
363
- return { ...publication, contentHash, reachable, localFiles };
382
+ return { ...publication, contentHash, reachable, localFiles, ...reviewEvidence };
364
383
  }
@@ -49,8 +49,8 @@ function bindCleanupItem(item, home) {
49
49
  return { ...item, path: file, location: { owner: home.owner, relativePath } };
50
50
  }
51
51
  function sameCleanupIdentity(a, b) {
52
- return node_path_1.default.resolve(a.path) === node_path_1.default.resolve(b.path) || !!(a.location && b.location
53
- && (0, completion_state_1.canonicalJournal)(a.location) === (0, completion_state_1.canonicalJournal)(b.location) && a.sha256 === b.sha256);
52
+ return node_path_1.default.resolve(a.path) === node_path_1.default.resolve(b.path) && a.sha256 === b.sha256
53
+ && (0, completion_state_1.canonicalJournal)(a.location) === (0, completion_state_1.canonicalJournal)(b.location) && a.reason === b.reason;
54
54
  }
55
55
  function validateDeliveryReader(reader) { validateReader(reader, false); }
56
56
  function validateReader(reader, historical) {
@@ -134,6 +134,47 @@ function reviewedInput(input) {
134
134
  result[key] = structuredClone(input[key]);
135
135
  return result;
136
136
  }
137
+ /** New authored text only: runtime owner/source bindings and historical replay keep their existing contract. */
138
+ function assertPortableAuthoredText(value, previous) {
139
+ if (value === previous)
140
+ return;
141
+ if (typeof value === 'string') {
142
+ // Absolute API routes and filesystem paths share a syntax. Only an explicit
143
+ // HTTP-method context disambiguates a route; bare local-looking paths must
144
+ // use an owner-relative reference. No filesystem directory allowlist.
145
+ const text = value.replace(/https?:\/\/[^\s<>"'`]+/gi, url => {
146
+ try {
147
+ return new URL(url).hostname ? 'URL' : url;
148
+ }
149
+ catch {
150
+ return url;
151
+ }
152
+ }).replace(/\b(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|CONNECT|TRACE)[ \t]+([`"']?)(\/(?!\/)[^\s<>"'`]+)\1/g, 'HTTP route')
153
+ .replace(/<\/[A-Za-z][A-Za-z0-9:._-]*\s*>/g, 'closing tag')
154
+ .replace(/(<[A-Za-z][A-Za-z0-9:._-]*(?:\s+[^<>]*?)?)\/>/g, '$1>');
155
+ if (/\bfile:(?=\S)/i.test(text)
156
+ || /(?:\$(?:HOME|USERPROFILE)|\$\{(?:HOME|USERPROFILE)\}|%USERPROFILE%|\$env:USERPROFILE)[/\x5c]/i.test(text)
157
+ || /(?:^|[^\p{L}\p{N}_./\x5c-])(?:[A-Za-z]:[/\x5c]|\x5c{2}|\/\/(?=\S)|~(?:[/\x5c]|[\p{L}\p{N}_.-]+[/\x5c])|\/(?![\s/*]))/u.test(text)) {
158
+ throw new Error('machine-local-path-in-authored-text');
159
+ }
160
+ }
161
+ else if (Array.isArray(value)) {
162
+ value.forEach((item, index) => assertPortableAuthoredText(item, Array.isArray(previous) ? previous[index] : undefined));
163
+ }
164
+ else if (value && typeof value === 'object') {
165
+ for (const [key, item] of Object.entries(value)) {
166
+ assertPortableAuthoredText(key);
167
+ assertPortableAuthoredText(item, previous && typeof previous === 'object' ? previous[key] : undefined);
168
+ }
169
+ }
170
+ }
171
+ function assertPortableActionText(input, previous) {
172
+ // sourceRefs/sourceHashes are physical IO bindings in the existing journal
173
+ // schema. Authored entry trace references are part of content and are checked.
174
+ for (const key of ['claimKey', 'evidenceFingerprint', 'content', 'specification', 'verification']) {
175
+ assertPortableAuthoredText(input[key], previous?.[key]);
176
+ }
177
+ }
137
178
  function validateAction(action, historical = false) {
138
179
  if (!['learning', 'capability'].includes(action.kind) || !['insert', 'update', 'move', 'remove', 'deliver', 'reconcile'].includes(action.operation))
139
180
  throw new Error('invalid-action');
@@ -206,6 +247,9 @@ function validateAction(action, historical = false) {
206
247
  function validateApplication(action, application) {
207
248
  if (!application || !['prepared', 'written', 'published', 'applied'].includes(application.stage) || !Array.isArray(application.steps))
208
249
  throw new Error('invalid-application');
250
+ if (application.reviewedContent !== undefined && (action.kind !== 'capability' || typeof application.reviewedContent !== 'string'
251
+ || (0, proposal_queue_1.hashContent)(application.reviewedContent) !== action.delivery?.expectedHash))
252
+ throw new Error('capability-reviewed-content-mismatch');
209
253
  const refs = action.kind === 'learning' ? [action.target, ...(action.operation === 'move' ? [action.source] : action.copies?.map(copy => copy.ref) || [])] : [];
210
254
  if (application.steps.length !== refs.length)
211
255
  throw new Error('invalid-application-targets');
@@ -343,7 +387,9 @@ function replay(file) {
343
387
  if (receipt.decisions.length !== completed.length || receipt.decisions.some(item => !completed.some(action => action.actionId === item.actionId
344
388
  && (0, completion_state_1.canonicalJournal)(action.decision) === (0, completion_state_1.canonicalJournal)(item.decision) && (0, completion_state_1.canonicalJournal)(action.target) === (0, completion_state_1.canonicalJournal)(item.target))))
345
389
  throw new Error('checkpoint-action-conflict');
346
- if ((0, completion_state_1.canonicalJournal)(receipt.processedSources) !== (0, completion_state_1.canonicalJournal)(state.processedSources))
390
+ const omitted = new Set(receipt.deferred.sources.map(source => (0, completion_state_1.sourcePathKey)(source.path)));
391
+ if (state.archivedSourcePaths?.some(file => omitted.has(file))
392
+ || (0, completion_state_1.canonicalJournal)(receipt.processedSources) !== (0, completion_state_1.canonicalJournal)(state.processedSources?.filter(source => !omitted.has((0, completion_state_1.sourcePathKey)(source.path)))))
347
393
  throw new Error('completion-sources-conflict');
348
394
  if ((0, completion_state_1.canonicalJournal)(receipt.outcomeAssessments) !== (0, completion_state_1.canonicalJournal)(state.outcomeAssessments))
349
395
  throw new Error('completion-assessments-conflict');
@@ -362,6 +408,9 @@ function replay(file) {
362
408
  }
363
409
  else
364
410
  throw new Error('unknown-journal-event');
411
+ if (['checkpoint', 'finalization'].includes(type) && data.receipt?.finalizationState === 'complete') {
412
+ state.archivedSourcePaths = [...new Set([...(state.archivedSourcePaths ?? []), ...(data.receipt.processedSources ?? []).map((source) => (0, completion_state_1.sourcePathKey)(source.path))])];
413
+ }
365
414
  state.sequence = event.sequence;
366
415
  }
367
416
  if (!state)
@@ -419,6 +468,26 @@ class RunRecord {
419
468
  this.assertCompletionDirectory(state);
420
469
  return state;
421
470
  }
471
+ /** Narrative is editable; the journal owns the receipt and its interrupted replacement. */
472
+ readCompletion(body) {
473
+ const receipt = (0, completion_state_1.decodeCompletion)(body, { runId: this.runId, owner: this.owner });
474
+ if (!node_fs_1.default.existsSync(this.file))
475
+ return receipt;
476
+ const { events } = replay(this.file);
477
+ const bindings = events.filter(event => ['checkpoint', 'finalization'].includes(event.payload.type));
478
+ const latest = bindings.at(-1)?.payload.data;
479
+ if (latest?.receipt) {
480
+ // Older checkpoints already stored their authoritative receipt. Read the
481
+ // preceding event only for a crash between replacement intent and rename.
482
+ const previous = latest.previousReceipt ?? bindings.at(-2)?.payload.data.receipt;
483
+ if (![latest.receipt, previous].some(value => value && (0, completion_state_1.canonicalJournal)(value) === (0, completion_state_1.canonicalJournal)(receipt))) {
484
+ throw new Error(`completion-receipt-conflict: ${this.completionFile}: structured receipt differs from recorded decisions or source mappings`);
485
+ }
486
+ }
487
+ // Legacy finalization events recorded no receipt. The full-completion path
488
+ // reconstructs decisions from the journal and independently inventories cleanup.
489
+ return receipt;
490
+ }
422
491
  assertCompletionDirectory(state) {
423
492
  if ((0, completion_state_1.sourcePathKey)(state.completionDirectory ?? this.directory) !== (0, completion_state_1.sourcePathKey)(this.completionDirectory))
424
493
  throw new Error('completion-evidence-path-changed');
@@ -472,6 +541,7 @@ class RunRecord {
472
541
  throw new Error('run-context-mismatch');
473
542
  if ([...(state.outcomeAssessments ?? []), ...(state.pendingOutcomeAssessments ?? [])].some(item => item.digest === record.digest))
474
543
  return;
544
+ assertPortableAuthoredText([record.incidentId, record.rationale]);
475
545
  this.append('assessment-prepared', { record, sourcePaths });
476
546
  });
477
547
  }
@@ -494,6 +564,7 @@ class RunRecord {
494
564
  const prior = Object.values(state.actions).find(a => a.claimKey === input.claimKey && a.evidenceFingerprint === input.evidenceFingerprint && (0, owner_identity_1.ownerKey)(a.target.owner) === (0, owner_identity_1.ownerKey)(input.target.owner));
495
565
  if (prior)
496
566
  return prior;
567
+ assertPortableActionText(input);
497
568
  for (const name of node_fs_1.default.readdirSync(this.directory).filter(n => n.endsWith('-completion.md'))) {
498
569
  const history = node_fs_1.default.readFileSync(node_path_1.default.join(this.directory, name), 'utf8');
499
570
  let completed;
@@ -523,6 +594,7 @@ class RunRecord {
523
594
  throw new Error('action-completed');
524
595
  if (prior.application)
525
596
  throw new Error('application-started');
597
+ assertPortableActionText(input, prior);
526
598
  if (prior.content && input.content && (0, completion_state_1.canonicalJournal)(prior.content.trace) !== (0, completion_state_1.canonicalJournal)(input.content.trace))
527
599
  throw new Error('immutable-history');
528
600
  if (prior.target.entryId !== input.target.entryId)
@@ -554,6 +626,7 @@ class RunRecord {
554
626
  throw new Error('action-completed');
555
627
  if (action.application || decision.reviewer !== state.reviewer || typeof decision.reason !== 'string' || !decision.reason.trim() || !['approve', 'reject'].includes(decision.action) || (decision.revisitCondition !== undefined && (decision.action !== 'reject' || typeof decision.revisitCondition !== 'string' || !decision.revisitCondition.trim())) || !validDecisionProvenance(decision))
556
628
  throw new Error('invalid-decision');
629
+ assertPortableAuthoredText([decision.reason, decision.revisitCondition, decision.predecessorId]);
557
630
  this.append('decision', { actionId, decision: { ...decision, revision, digest: action.digest, at: new Date().toISOString() } });
558
631
  return this.read().actions[actionId];
559
632
  });
@@ -562,6 +635,7 @@ class RunRecord {
562
635
  return this.mutate(expectedSequence, state => {
563
636
  if (reviewer !== state.reviewer || !reason.trim() || !tuples.length || new Set(tuples.map(t => t.actionId)).size !== tuples.length)
564
637
  throw new Error('invalid-batch-decision');
638
+ assertPortableAuthoredText(reason);
565
639
  for (const tuple of tuples) {
566
640
  const action = state.actions[tuple.actionId];
567
641
  if (!action || action.application || action.revision !== tuple.revision || action.digest !== tuple.digest)
@@ -613,6 +687,7 @@ class RunRecord {
613
687
  this.append('application', { actionId, digest: action.digest, application });
614
688
  }
615
689
  recordCleanup(item, expectedSequence) {
690
+ assertPortableAuthoredText(item.reason);
616
691
  // The normal run location supplies an unambiguous same-owner home for
617
692
  // direct registrations. Cross-owner reconciliations bind their resolved home explicitly.
618
693
  if (!item.location) {
@@ -648,6 +723,7 @@ class RunRecord {
648
723
  }),
649
724
  };
650
725
  validateReconciliation(reconciliation, state);
726
+ assertPortableAuthoredText([input.rationale, input.preservedDecisions]);
651
727
  this.append('reconciliation', { reconciliation, cleanup });
652
728
  return this.read();
653
729
  });
@@ -680,6 +756,7 @@ class RunRecord {
680
756
  return { actionId: id, revision: action.revision, digest: action.digest };
681
757
  });
682
758
  (0, corpus_audit_1.validateCorpusAudit)(audit, state.reviewer);
759
+ assertPortableAuthoredText(audit.dispositions.map(item => item.rationale));
683
760
  this.append('corpus-audit', audit);
684
761
  return this.read();
685
762
  });
@@ -710,7 +787,7 @@ class RunRecord {
710
787
  const result = (0, completion_state_1.decodeCompletion)(node_fs_1.default.readFileSync(this.completionFile, 'utf8'), { runId: this.runId, owner: this.owner });
711
788
  if (result.finalizationState !== 'complete')
712
789
  throw new Error('incomplete-finalization');
713
- if ((0, completion_state_1.canonicalJournal)(result.retrospective) !== (0, completion_state_1.canonicalJournal)(retrospective))
790
+ if (result.retrospective?.relativePath !== retrospective?.relativePath)
714
791
  throw new Error('completion-retrospective-conflict');
715
792
  verifyApplied?.(true);
716
793
  finishedInventory();
@@ -721,9 +798,11 @@ class RunRecord {
721
798
  throw new Error('assessment-processing-pending');
722
799
  if (!summary?.trim() || summary.includes('fraim-sleep-decisions'))
723
800
  throw new Error('invalid-completion-summary');
801
+ assertPortableAuthoredText(summary);
724
802
  const actions = deferred ? completedRunActions(state) : Object.values(state.actions);
725
803
  if (deferred) {
726
804
  (0, completion_state_1.validateDeferredWork)(deferred);
805
+ assertPortableAuthoredText([...deferred.actions, ...deferred.sources, ...(deferred.corpusErrors ?? [])].map(item => item.reason));
727
806
  const ids = new Set(actions.map(action => action.actionId));
728
807
  if ((0, completion_state_1.canonicalJournal)(deferred.actions.map(item => [item.actionId, item.revision, item.digest])) !== (0, completion_state_1.canonicalJournal)(Object.values(state.actions).filter(action => !ids.has(action.actionId)).map(action => [action.actionId, action.revision, action.digest])))
729
808
  throw new Error('checkpoint-action-conflict');
@@ -751,7 +830,7 @@ class RunRecord {
751
830
  decisions: actions.map(a => ({ actionId: a.actionId, revision: a.revision, digest: a.digest, owner: a.target.owner, target: a.target, claimKey: a.claimKey, evidenceFingerprint: a.evidenceFingerprint, sourceRefs: a.sourceRefs, decision: a.decision, dependencies: a.dependencies.map(id => ({ actionId: id, outcome: state.actions[id].decision?.action === 'approve' ? 'applied' : 'rejected' })), ...(a.application?.publicationRef ? { publicationRef: a.application.publicationRef } : {}) })),
752
831
  sourceDispositions: actions.flatMap(a => a.sourceRefs.map(sourceRef => ({ sourceRef, sourceHash: a.sourceHashes[sourceRef], outcome: a.decision.action === 'approve' ? 'applied' : 'rejected', actionId: a.actionId }))),
753
832
  cleanup: snapshot,
754
- ...(state.processedSources ? { processedSources: state.processedSources } : {}),
833
+ ...(state.processedSources ? { processedSources: state.processedSources.filter(source => !deferred?.sources.some(item => (0, completion_state_1.sourcePathKey)(item.path) === (0, completion_state_1.sourcePathKey)(source.path))) } : {}),
755
834
  ...(state.sourceParticipants !== undefined ? { sourceParticipants: state.sourceParticipants } : {}),
756
835
  ...(state.outcomeAssessments?.length ? { outcomeAssessments: state.outcomeAssessments } : {}),
757
836
  ...(retrospective ? { retrospective } : {}),
@@ -767,6 +846,8 @@ class RunRecord {
767
846
  // Submission and feedback already own this path before the completion receipt exists.
768
847
  const existingEvidence = node_fs_1.default.existsSync(this.completionFile) ? node_fs_1.default.readFileSync(this.completionFile, 'utf8') : undefined;
769
848
  const hasReceipt = existingEvidence?.includes('fraim-sleep-decisions');
849
+ if (!hasReceipt && (state.checkpoint || state.finalization))
850
+ throw new Error(`completion-receipt-conflict: ${this.completionFile}: recorded receipt is missing`);
770
851
  if (existingEvidence && !hasReceipt) {
771
852
  const header = existingEvidence.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
772
853
  if (!header || (header[1].match(/^artifactKind:/gm) || []).length !== 1 || !/^artifactKind: work-evidence\s*$/m.test(header[1])
@@ -775,46 +856,73 @@ class RunRecord {
775
856
  narrative = existingEvidence + `\n\n## Sleep completion: ${this.runId}\n\n${summary}\n\n## Decisions and source disposition\n\n`;
776
857
  }
777
858
  let trailingHistory = '\n';
859
+ let observedEvidence = existingEvidence;
778
860
  const writeEvidence = () => {
861
+ const currentBody = node_fs_1.default.existsSync(this.completionFile) ? node_fs_1.default.readFileSync(this.completionFile, 'utf8') : undefined;
862
+ if (currentBody !== observedEvidence) {
863
+ if (!currentBody?.includes('fraim-sleep-decisions'))
864
+ throw new Error(`completion-receipt-conflict: ${this.completionFile}: receipt removed during completion`);
865
+ this.readCompletion(currentBody);
866
+ narrative = currentBody.slice(0, currentBody.indexOf('<!-- fraim-sleep-decisions:v1 -->'));
867
+ trailingHistory = currentBody.slice(currentBody.indexOf('<!-- /fraim-sleep-decisions -->') + '<!-- /fraim-sleep-decisions -->'.length);
868
+ }
779
869
  if (actions.length)
780
870
  narrative = narrative.replace(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/, header => header.replace(/^containsFeedback: false[ \t]*(?=\r?$)/m, 'containsFeedback: true'));
781
871
  const encoded = JSON.stringify(result, null, 2).split('<').join(String.raw `\u003c`);
782
872
  const body = narrative + `<!-- fraim-sleep-decisions:v1 -->\n\x60\x60\x60json\n${encoded}\n\x60\x60\x60\n<!-- /fraim-sleep-decisions -->` + trailingHistory;
783
873
  (0, completion_state_1.decodeCompletion)(body, { runId: this.runId, owner: this.owner });
784
- const previousEvidenceHash = node_fs_1.default.existsSync(this.completionFile) ? (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(this.completionFile, 'utf8')) : undefined;
874
+ const previousBody = node_fs_1.default.existsSync(this.completionFile) ? node_fs_1.default.readFileSync(this.completionFile, 'utf8') : undefined;
875
+ const previousEvidenceHash = previousBody === undefined ? undefined : (0, proposal_queue_1.hashContent)(previousBody);
876
+ const previousReceipt = previousBody?.includes('fraim-sleep-decisions') ? this.readCompletion(previousBody) : undefined;
785
877
  // A replacement intent names both recoverable states. A crash before
786
878
  // or after the atomic rename can resume without accepting a third state.
787
- if (deferred)
788
- this.append('checkpoint', { evidenceHash: (0, proposal_queue_1.hashContent)(body), previousEvidenceHash, receipt: result });
789
- else if (previousEvidenceHash)
790
- this.append('finalization', { evidenceHash: (0, proposal_queue_1.hashContent)(body), previousEvidenceHash });
879
+ const event = deferred ? 'checkpoint' : 'finalization';
880
+ const binding = { evidenceHash: (0, proposal_queue_1.hashContent)(body), previousEvidenceHash, receipt: result, previousReceipt };
881
+ if (previousReceipt)
882
+ this.append(event, binding);
791
883
  (0, proposal_queue_1.atomicWrite)(this.completionFile, body);
884
+ observedEvidence = body;
792
885
  const verified = node_fs_1.default.readFileSync(this.completionFile, 'utf8');
793
886
  if ((0, proposal_queue_1.hashContent)(verified) !== (0, proposal_queue_1.hashContent)(body))
794
887
  throw new Error('completion-write-conflict');
795
888
  (0, completion_state_1.decodeCompletion)(verified, { runId: this.runId, owner: this.owner });
796
- if (!deferred && !previousEvidenceHash)
797
- this.append('finalization', { evidenceHash: (0, proposal_queue_1.hashContent)(body) });
889
+ if (!previousReceipt)
890
+ this.append(event, binding);
798
891
  };
799
892
  if (node_fs_1.default.existsSync(this.completionFile) && hasReceipt) {
800
893
  const body = node_fs_1.default.readFileSync(this.completionFile, 'utf8');
801
- const prior = (0, completion_state_1.decodeCompletion)(body, { runId: this.runId, owner: this.owner });
802
- if (!deferred && !prior.deferred && prior.finalizationState === 'complete' && (0, completion_state_1.canonicalJournal)(prior.retrospective) !== (0, completion_state_1.canonicalJournal)(retrospective))
894
+ const prior = this.readCompletion(body);
895
+ if (!deferred && !prior.deferred && prior.finalizationState === 'complete' && prior.retrospective?.relativePath !== retrospective?.relativePath)
803
896
  throw new Error('completion-retrospective-conflict');
804
- if (!deferred && state.finalization && ![state.finalization.evidenceHash, state.finalization.previousEvidenceHash].includes((0, proposal_queue_1.hashContent)(body)))
805
- throw new Error('completion-evidence-conflict');
897
+ if (deferred && !state.checkpoint && !state.finalization?.receipt) {
898
+ // A legacy full receipt has no structured journal binding. Prove its
899
+ // original full state before partitioning an interrupted source batch.
900
+ const inventory = inspect();
901
+ if (prior.cleanup.some(item => !inventory.some(current => sameCleanupIdentity(item, current)))
902
+ || inventory.some(item => !prior.cleanup.some(current => sameCleanupIdentity(item, current)))
903
+ || prior.cleanup.some(item => ['deleted', 'absent'].includes(item.outcome) && inventory.some(current => sameCleanupIdentity(item, current) && current.outcome === 'pending')))
904
+ throw new Error('cleanup-inventory-missing');
905
+ const full = { ...result, finalizationState: 'pending', deferred: undefined, cleanup: prior.cleanup,
906
+ processedSources: state.processedSources, reconciliations: state.reconciliations.length ? state.reconciliations : undefined,
907
+ corpusAudit: state.corpusAudit, reconciliationHistory: reconciliationHistory.length ? reconciliationHistory : undefined,
908
+ deferredHistory: state.deferredHistory?.length ? state.deferredHistory : undefined,
909
+ deferredAttempts: state.deferredAttempts?.length ? state.deferredAttempts : undefined };
910
+ if (prior.finalizationState !== 'pending' || (0, completion_state_1.canonicalJournal)(prior) !== (0, completion_state_1.canonicalJournal)(full))
911
+ throw new Error(`completion-decisions-conflict: ${this.completionFile}: legacy receipt differs from original full journal state`);
912
+ }
806
913
  if (deferred || prior.deferred) {
807
- if (!state.checkpoint || ![state.checkpoint.evidenceHash, state.checkpoint.previousEvidenceHash].includes((0, proposal_queue_1.hashContent)(body))
808
- || prior.decisions.some(item => !result.decisions.some(current => (0, completion_state_1.canonicalJournal)(current) === (0, completion_state_1.canonicalJournal)(item)))
809
- || prior.processedSources?.some(item => !result.processedSources?.some(current => (0, completion_state_1.canonicalJournal)(current) === (0, completion_state_1.canonicalJournal)(item))))
914
+ if (prior.decisions.some(item => !result.decisions.some(current => (0, completion_state_1.canonicalJournal)(current) === (0, completion_state_1.canonicalJournal)(item)))
915
+ || prior.processedSources?.some(item => !result.processedSources?.some(current => (0, completion_state_1.canonicalJournal)(current) === (0, completion_state_1.canonicalJournal)(item))
916
+ && (prior.finalizationState === 'complete' || !deferred?.sources.some(source => (0, completion_state_1.sourcePathKey)(source.path) === (0, completion_state_1.sourcePathKey)(item.path)))))
810
917
  throw new Error('completion-decisions-conflict');
811
918
  }
812
- else if ((0, completion_state_1.canonicalJournal)(prior.decisions) !== (0, completion_state_1.canonicalJournal)(result.decisions) || (0, completion_state_1.canonicalJournal)(prior.sourceDispositions) !== (0, completion_state_1.canonicalJournal)(result.sourceDispositions) || (0, completion_state_1.canonicalJournal)(prior.reconciliations) !== (0, completion_state_1.canonicalJournal)(result.reconciliations) || (0, completion_state_1.canonicalJournal)(prior.processedSources) !== (0, completion_state_1.canonicalJournal)(result.processedSources) || (0, completion_state_1.canonicalJournal)(prior.corpusAudit) !== (0, completion_state_1.canonicalJournal)(result.corpusAudit) || (0, completion_state_1.canonicalJournal)(prior.reconciliationHistory) !== (0, completion_state_1.canonicalJournal)(result.reconciliationHistory) || (0, completion_state_1.canonicalJournal)(prior.outcomeAssessments) !== (0, completion_state_1.canonicalJournal)(result.outcomeAssessments))
813
- throw new Error('completion-decisions-conflict');
814
- if (deferred && prior.finalizationState === 'complete' && (0, completion_state_1.canonicalJournal)({ ...prior, finalizationState: 'pending' }) === (0, completion_state_1.canonicalJournal)(result)) {
919
+ else if (['decisions', 'sourceDispositions', 'reconciliations', 'processedSources', 'corpusAudit', 'reconciliationHistory', 'outcomeAssessments', 'sourceParticipants', 'deferredHistory', 'deferredAttempts']
920
+ .some(key => (0, completion_state_1.canonicalJournal)(prior[key]) !== (0, completion_state_1.canonicalJournal)(result[key])))
921
+ throw new Error(`completion-decisions-conflict: ${this.completionFile}: receipt differs from journaled decisions or source history`);
922
+ if (deferred && state.checkpoint && prior.finalizationState === 'complete' && (0, completion_state_1.canonicalJournal)({ ...prior, finalizationState: 'pending' }) === (0, completion_state_1.canonicalJournal)(result)) {
815
923
  finishSources?.();
816
924
  verifyApplied?.();
817
- this.append('deferred-attempt', { at: new Date().toISOString(), evidenceHash: (0, proposal_queue_1.hashContent)(body), reason: 'verified-subset-unchanged' });
925
+ this.append('deferred-attempt', { at: new Date().toISOString(), evidenceHash: state.checkpoint.evidenceHash, reason: 'verified-subset-unchanged' });
818
926
  return prior;
819
927
  }
820
928
  // While pending, the agent may update process evidence and the
@@ -824,26 +932,28 @@ class RunRecord {
824
932
  if (prior.deferred && (!deferred || (0, completion_state_1.canonicalJournal)({ ...prior, finalizationState: 'pending' }) !== (0, completion_state_1.canonicalJournal)(result)))
825
933
  narrative += `\n## Recovery completion\n\n${summary}\n\n`;
826
934
  trailingHistory = body.slice(body.indexOf('<!-- /fraim-sleep-decisions -->') + '<!-- /fraim-sleep-decisions -->'.length);
827
- if (!deferred && !prior.deferred && prior.finalizationState === 'complete' && !snapshot.some(item => item.outcome === 'pending')) {
828
- finishedInventory();
829
- finishSources?.();
830
- verifyApplied?.(true);
831
- node_fs_1.default.unlinkSync(this.file);
832
- return prior;
833
- }
834
- if (!deferred && !prior.deferred && prior.finalizationState === 'complete' && snapshot.some(item => item.outcome === 'pending'))
835
- throw new Error('cleanup-content-reappeared');
836
935
  // The fresh inventory authorizes every remaining deletion. Never
837
936
  // execute a path merely because it appeared in prior evidence.
937
+ if (!deferred && !prior.deferred && snapshot.some(current => !prior.cleanup.some(item => sameCleanupIdentity(item, current))))
938
+ throw new Error(`cleanup-inventory-missing: ${this.completionFile}: receipt omitted recorded cleanup`);
838
939
  for (const item of deferred ? [] : prior.cleanup) {
839
940
  const found = snapshot.find(current => sameCleanupIdentity(item, current));
840
941
  if (!found)
841
942
  throw new Error('cleanup-inventory-missing');
842
943
  if ((item.outcome === 'deleted' || item.outcome === 'absent') && found.outcome === 'pending')
843
944
  throw new Error('cleanup-content-reappeared');
844
- if (found.outcome === 'absent' && item.outcome === 'deleted')
945
+ if (state.finalization?.receipt && found.outcome === 'absent' && item.outcome === 'deleted')
845
946
  found.outcome = 'deleted';
846
947
  }
948
+ if (!deferred && !prior.deferred && state.finalization?.receipt && prior.finalizationState === 'complete' && !snapshot.some(item => item.outcome === 'pending') && (0, completion_state_1.canonicalJournal)(prior.retrospective) === (0, completion_state_1.canonicalJournal)(retrospective)) {
949
+ finishedInventory();
950
+ finishSources?.();
951
+ verifyApplied?.(true);
952
+ node_fs_1.default.unlinkSync(this.file);
953
+ return prior;
954
+ }
955
+ if (!deferred && !prior.deferred && prior.finalizationState === 'complete' && snapshot.some(item => item.outcome === 'pending'))
956
+ throw new Error('cleanup-content-reappeared');
847
957
  }
848
958
  writeEvidence();
849
959
  afterStage?.('evidence');
@@ -878,9 +988,8 @@ class RunRecord {
878
988
  result.finalizationState = 'complete';
879
989
  writeEvidence();
880
990
  afterStage?.('complete-evidence');
881
- const binding = deferred ? this.read().checkpoint : this.read().finalization;
882
- if ((0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(this.completionFile, 'utf8')) !== binding?.evidenceHash)
883
- throw new Error('completion-evidence-conflict');
991
+ if ((0, completion_state_1.canonicalJournal)(this.readCompletion(node_fs_1.default.readFileSync(this.completionFile, 'utf8'))) !== (0, completion_state_1.canonicalJournal)(result))
992
+ throw new Error(`completion-receipt-conflict: ${this.completionFile}: completed receipt changed`);
884
993
  // Complete attests this receipt's terminal subset. Only full coverage permits retiring run recovery.
885
994
  if (!deferred) {
886
995
  finishedInventory();
@@ -161,7 +161,7 @@ async function runQueueCommand(argv) {
161
161
  const coordinationFile = node_path_1.default.join(journalDirectory(homes.find(item => item.level === 'Manager')), 'owner-application');
162
162
  (0, run_application_1.reserveRunAction)(run, id, revision, expectedSequence, { resolveHome, runDirectories, coordinationFile, homes, readerRoots: () => resolveReaderCorpusRoots(projectPath) });
163
163
  const applySequence = run.read().sequence;
164
- const capability = proposed?.kind === 'capability' ? await (0, run_publication_1.verifyCapabilityDelivery)(proposed, { ...input, operationKey: `${runId}-${id}-${revision}` }, { projectPath, resolveHome, runtimeModule: runtime_1.runtimeModuleStatic, runtimeModulePath: runtime_1.runtimeModulePathStatic }) : undefined;
164
+ const capability = proposed?.kind === 'capability' ? await (0, run_publication_1.verifyCapabilityDelivery)(proposed, { reviewedContent: proposed.application?.reviewedContent, ...input, operationKey: `${runId}-${id}-${revision}` }, { projectPath, resolveHome, runtimeModule: runtime_1.runtimeModuleStatic, runtimeModulePath: runtime_1.runtimeModulePathStatic }) : undefined;
165
165
  const capabilities = new Map();
166
166
  if (capability)
167
167
  capabilities.set(id, capability);
@@ -169,7 +169,7 @@ async function runQueueCommand(argv) {
169
169
  const dependency = state.actions[dependencyId];
170
170
  if (dependency?.kind !== 'capability')
171
171
  continue;
172
- capabilities.set(dependencyId, await (0, run_publication_1.verifyCapabilityDelivery)(dependency, { publicationRef: dependency.application?.publicationRef, operationKey: `${runId}-${dependencyId}-${dependency.revision}` }, { projectPath, resolveHome, runtimeModule: runtime_1.runtimeModuleStatic, runtimeModulePath: runtime_1.runtimeModulePathStatic }));
172
+ capabilities.set(dependencyId, await (0, run_publication_1.verifyCapabilityDelivery)(dependency, { publicationRef: dependency.application?.publicationRef, reviewedContent: dependency.application?.reviewedContent, operationKey: `${runId}-${dependencyId}-${dependency.revision}` }, { projectPath, resolveHome, runtimeModule: runtime_1.runtimeModuleStatic, runtimeModulePath: runtime_1.runtimeModulePathStatic }));
173
173
  }
174
174
  return (0, run_application_1.applyRunAction)(run, id, revision, applySequence, {
175
175
  resolveHome, runDirectories, coordinationFile, homes, readerRoots: () => resolveReaderCorpusRoots(projectPath),
@@ -185,7 +185,7 @@ async function runQueueCommand(argv) {
185
185
  if (node_fs_1.default.existsSync(run.file))
186
186
  for (const candidate of Object.values(run.read().actions)) {
187
187
  if (candidate.kind === 'capability' && candidate.application?.stage === 'applied')
188
- capabilities.set(candidate.actionId, await (0, run_publication_1.verifyCapabilityDelivery)(candidate, { publicationRef: candidate.application.publicationRef, operationKey: `${runId}-${candidate.actionId}-${candidate.revision}` }, { projectPath, resolveHome, runtimeModule: runtime_1.runtimeModuleStatic, runtimeModulePath: runtime_1.runtimeModulePathStatic }));
188
+ capabilities.set(candidate.actionId, await (0, run_publication_1.verifyCapabilityDelivery)(candidate, { publicationRef: candidate.application.publicationRef, reviewedContent: candidate.application.reviewedContent, operationKey: `${runId}-${candidate.actionId}-${candidate.revision}` }, { projectPath, resolveHome, runtimeModule: runtime_1.runtimeModuleStatic, runtimeModulePath: runtime_1.runtimeModulePathStatic }));
189
189
  }
190
190
  return (0, run_application_1.completeRun)(run, input?.summary, homes, { resolveHome, homes, readerRoots: () => resolveReaderCorpusRoots(projectPath), runDirectories: homes.map(journalDirectory), retrospective: input?.retrospective, analyzedSources: input.analyzedSources,
191
191
  allowDeferred: input?.allowDeferred === true, deferredSources: input?.deferredSources,
@@ -232,9 +232,26 @@ function runPackage(plan, cacheDir) {
232
232
  const plan = resolvePackage();
233
233
  const versionCacheDir = path.join(CACHE_ROOT, plan.packageName + '-' + plan.version.replace(/[^a-zA-Z0-9._-]/g, '_'));
234
234
  pruneCaches(versionCacheDir, Date.now());
235
+
236
+ /**
237
+ * True when npx materialized the package into this cache. A non-zero exit from a package
238
+ * that DID install is the CLI's own result, not a cache problem, and must be returned verbatim.
239
+ */
240
+ function packageMaterialized(cacheDir) {
241
+ try {
242
+ const npxDir = path.join(cacheDir, '_npx');
243
+ return fs.readdirSync(npxDir).some(function (entry) {
244
+ return fs.existsSync(path.join(npxDir, entry, 'node_modules', plan.packageName));
245
+ });
246
+ } catch (error) {
247
+ return false;
248
+ }
249
+ }
250
+
235
251
  let status = runPackage(plan, versionCacheDir);
236
252
 
237
- if (status !== 0 && process.platform === 'win32') {
253
+ // Retry ONLY when npx could not materialize the package: the corrupt-cache case this exists for.
254
+ if (status !== 0 && process.platform === 'win32' && !packageMaterialized(versionCacheDir)) {
238
255
  const retryCache = path.join(CACHE_ROOT, 'retry-' + Date.now() + '-' + process.pid);
239
256
  console.error('[fraim-mcp-launcher] Package execution failed; retrying once with a fresh npm cache.');
240
257
  status = runPackage(plan, retryCache);
@@ -70,6 +70,8 @@ const script_sync_utils_1 = require("../utils/script-sync-utils");
70
70
  const USER_LEVEL_RUNTIME_DEPS = {
71
71
  'node-edge-tts': '*', // used by scripts/author-audio.js
72
72
  'adm-zip': '^0.6.0', // used by scripts/communication/*.js (DOCX review layer) — matches root/packages/* manifests
73
+ 'fast-glob': '^3.3.3', // used by scripts/exec-with-timeout.ts (Issue #1839)
74
+ 'tree-kill': '^1.2.2', // used by scripts/exec-with-timeout.ts (Issue #1839)
73
75
  };
74
76
  /**
75
77
  * Ensure the user-level FRAIM directory structure exists.
@@ -227,6 +227,7 @@ function buildPathWithManagedAgentBins(basePath) {
227
227
  // launches already inherit the registry-composed PATH, so this returns null immediately on
228
228
  // win32 rather than attempting a login-shell equivalent that doesn't exist.
229
229
  const LOGIN_SHELL_PATH_TIMEOUT_MS = 3000;
230
+ const LOGIN_SHELL_PATH_CACHE_TTL_MS = 60_000;
230
231
  function defaultResolveLoginShellPath() {
231
232
  if (process.platform === 'win32')
232
233
  return null;
@@ -279,18 +280,20 @@ let loginShellPathResolverOverride = null;
279
280
  */
280
281
  function __setLoginShellPathResolverForTests(resolver) {
281
282
  loginShellPathResolverOverride = resolver;
282
- cachedLoginShellPath = undefined;
283
- cachedNpmGlobalBinDirsForRecovery.clear();
283
+ __clearLoginShellPathCacheForTests();
284
284
  }
285
285
  let cachedLoginShellPath;
286
+ let cachedLoginShellPathAtMs = 0;
286
287
  /**
287
- * Resolves and memoizes the PATH the user's own login shell would produce. Computed at most
288
- * once per process (AC-A5: bounded, fixed cost) — this feeds every agent-resolution call, so
289
- * recomputing per call would multiply one shell spawn by the number of agents probed.
288
+ * Resolves the user's login PATH at most once per minute, including failed reads. A
289
+ * running Hub can discover later installations without spawning a shell per agent probe.
290
290
  */
291
291
  function resolveLoginShellPathCached() {
292
- if (cachedLoginShellPath !== undefined)
292
+ const nowMs = Date.now();
293
+ const ageMs = nowMs - cachedLoginShellPathAtMs;
294
+ if (cachedLoginShellPath !== undefined && ageMs >= 0 && ageMs < LOGIN_SHELL_PATH_CACHE_TTL_MS) {
293
295
  return cachedLoginShellPath;
296
+ }
294
297
  const resolver = loginShellPathResolverOverride ?? defaultResolveLoginShellPath;
295
298
  // AC-A5: a resolver failing must degrade to "no recovery", never propagate. The default
296
299
  // resolver already guards its own spawnSync internally; this also protects against an
@@ -301,13 +304,20 @@ function resolveLoginShellPathCached() {
301
304
  catch {
302
305
  cachedLoginShellPath = null;
303
306
  }
307
+ cachedLoginShellPathAtMs = nowMs;
308
+ // npm's configured global prefix can change even when the recovered PATH does not.
309
+ // Refresh both tiers together so an old prefix cannot outlive login-PATH recovery.
310
+ cachedNpmGlobalBinDirsForRecovery.clear();
304
311
  return cachedLoginShellPath;
305
312
  }
306
313
  /** Test seam only. Clears the memoized login-shell PATH so the next call recomputes. */
307
314
  function __clearLoginShellPathCacheForTests() {
308
315
  cachedLoginShellPath = undefined;
316
+ cachedLoginShellPathAtMs = 0;
317
+ cachedNpmGlobalBinDirsForRecovery.clear();
309
318
  }
310
319
  const cachedNpmGlobalBinDirsForRecovery = new Map();
320
+ const NPM_GLOBAL_RECOVERY_CACHE_MAX_ENTRIES = 64;
311
321
  /**
312
322
  * Memoized wrapper around resolveNpmGlobalBinDirs(), scoped to buildRecoveredAgentPath()'s
313
323
  * own use only — NOT a change to resolveNpmGlobalBinDirs() itself, which fraim doctor and the
@@ -321,8 +331,8 @@ const cachedNpmGlobalBinDirsForRecovery = new Map();
321
331
  * and because each call sits inside a Promise executor that runs synchronously up to its own
322
332
  * spawnSync, the "concurrent" probes actually serialized behind it — up to 5 agents × 5s of
323
333
  * blocking spawnSync is exactly the event-loop-freezing regression issue #1010 eliminated,
324
- * reintroduced one layer deeper. Memoizing once per process (like resolveLoginShellPathCached
325
- * above) restores the bound this repo's own #1010 postmortem requires.
334
+ * reintroduced one layer deeper. Memoizing between login-PATH refreshes keeps repeated
335
+ * probes in the same round from duplicating that work.
326
336
  *
327
337
  * Keyed by `basePath` (not a single scalar): `resolveManagedCommand()` also routes through this
328
338
  * cache now, and unlike `detectEmployeesAsync()`'s repeated same-ambient-PATH probing, its
@@ -330,7 +340,8 @@ const cachedNpmGlobalBinDirsForRecovery = new Map();
330
340
  * isolated tests that swap `FRAIM_USER_DIR`/`PATH` per test case). A single scalar cache would
331
341
  * let the FIRST basePath's answer silently leak into every later, differently-scoped lookup —
332
342
  * concretely, a real machine's actual npm-global `codex` shadowing a test's own isolated managed
333
- * shim. Per-basePath keys keep the "once per distinct PATH" bound without that cross-contamination.
343
+ * shim. Per-basePath keys avoid that cross-contamination; a size limit bounds memory when
344
+ * callers supply many distinct PATHs before the next refresh.
334
345
  */
335
346
  function resolveNpmGlobalBinDirsCached(basePath) {
336
347
  const cached = cachedNpmGlobalBinDirsForRecovery.get(basePath);
@@ -343,6 +354,11 @@ function resolveNpmGlobalBinDirsCached(basePath) {
343
354
  catch {
344
355
  dirs = [];
345
356
  }
357
+ if (cachedNpmGlobalBinDirsForRecovery.size >= NPM_GLOBAL_RECOVERY_CACHE_MAX_ENTRIES) {
358
+ const oldestKey = cachedNpmGlobalBinDirsForRecovery.keys().next().value;
359
+ if (oldestKey !== undefined)
360
+ cachedNpmGlobalBinDirsForRecovery.delete(oldestKey);
361
+ }
346
362
  cachedNpmGlobalBinDirsForRecovery.set(basePath, dirs);
347
363
  return dirs;
348
364
  }
@@ -47,7 +47,6 @@ const REPO_LEARNINGS_REL = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPat
47
47
  * would keep passing if this default ever changed.
48
48
  */
49
49
  exports.DEFAULT_THRESHOLD = 3.0;
50
- const AGING_HORIZON_DAYS = 7;
51
50
  const MAX_ENTRIES_SCANNED = 200;
52
51
  const L0_SLEEP_ON_LEARNINGS_PROMPT_MIN = 5;
53
52
  // ── Single source of truth for the learning-entry format contract (#533) ──────
@@ -306,11 +305,10 @@ function getScoreThreshold(workspaceRoot) {
306
305
  return exports.DEFAULT_THRESHOLD;
307
306
  }
308
307
  /**
309
- * Effective score for an L1 learning entry. The aging-risk count below uses
310
- * this same decay model with `now` shifted forward.
308
+ * Effective score for an L1 learning entry. Score is display-only and does
309
+ * not gate delivery; the tier file is the authoritative delivery gate.
311
310
  *
312
- * @param now Optional override for "now" (for forward-looking aging-risk
313
- * calculations). Defaults to the current wall clock.
311
+ * @param now Optional override for "now". Defaults to the current wall clock.
314
312
  */
315
313
  function computeEffectiveScore(severity, lastSeenDate, recurrences, fileType, now = new Date()) {
316
314
  const baseScore = severity === 'P-CRITICAL' ? 10 : severity === 'P-HIGH' ? 8 : severity === 'P-MED' ? 5 : 3;
@@ -330,72 +328,6 @@ function computeEffectiveScore(severity, lastSeenDate, recurrences, fileType, no
330
328
  const recurrenceBoost = Math.log2(Math.max(1, recurrences) + 1);
331
329
  return baseScore * decay * recurrenceBoost;
332
330
  }
333
- function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-patterns') {
334
- const empty = { active: 0, dormant: 0, agingRisk: 0 };
335
- if (!(0, fs_1.existsSync)(filePath))
336
- return empty;
337
- let content;
338
- try {
339
- content = (0, fs_1.readFileSync)(filePath, 'utf8');
340
- }
341
- catch {
342
- return empty;
343
- }
344
- const lines = content.split(/\r?\n/);
345
- const now = new Date();
346
- const horizon = new Date(now.getTime() + AGING_HORIZON_DAYS * 86_400_000);
347
- let active = 0;
348
- let dormant = 0;
349
- let agingRisk = 0;
350
- let scanned = 0;
351
- let inEntry = false;
352
- let severity = null;
353
- let lastSeen = '';
354
- let recurrences = 1;
355
- const flush = () => {
356
- if (!severity)
357
- return;
358
- scanned++;
359
- const today = computeEffectiveScore(severity, lastSeen, recurrences, fileType, now);
360
- if (today >= threshold) {
361
- active++;
362
- if (lastSeen) {
363
- const future = computeEffectiveScore(severity, lastSeen, recurrences, fileType, horizon);
364
- if (future < threshold)
365
- agingRisk++;
366
- }
367
- }
368
- else {
369
- dormant++;
370
- }
371
- };
372
- for (const line of lines) {
373
- if (scanned >= MAX_ENTRIES_SCANNED)
374
- break;
375
- const headerMatch = line.match((0, learning_entry_format_1.learningEntryHeadingRegex)());
376
- if (headerMatch) {
377
- flush();
378
- inEntry = true;
379
- severity = headerMatch[1];
380
- lastSeen = '';
381
- recurrences = 1;
382
- continue;
383
- }
384
- if (!inEntry)
385
- continue;
386
- const lastSeenMatch = line.match(/^\*\*Last seen\*\*:\s*(.+)/);
387
- if (lastSeenMatch) {
388
- lastSeen = lastSeenMatch[1].trim();
389
- continue;
390
- }
391
- const recurrenceMatch = line.match(/^\*\*Recurrences\*\*:\s*(\d+)/);
392
- if (recurrenceMatch) {
393
- recurrences = parseInt(recurrenceMatch[1], 10);
394
- }
395
- }
396
- flush();
397
- return { active, dormant, agingRisk };
398
- }
399
331
  /** Historical counters are only read for the human library view. */
400
332
  function resolveUsageStats() {
401
333
  try {
@@ -597,10 +529,10 @@ function mergeLearningEntriesAcrossTiers(tiers) {
597
529
  return tiers.flatMap(tier => tier.entries);
598
530
  }
599
531
  const CONTEXT_CATEGORIES = {
600
- mistake: { ft: 'mistake-patterns', gated: true, label: '(entries above score threshold)' },
601
- pref: { ft: 'preferences', gated: false, label: '(all entries)' },
602
- coach: { ft: 'manager-coaching', gated: false, label: '(manager-facing; all entries)' },
603
- validated: { ft: 'validated-patterns', gated: true, label: '(entries above score threshold)' },
532
+ mistake: { ft: 'mistake-patterns', label: '(all entries)' },
533
+ pref: { ft: 'preferences', label: '(all entries)' },
534
+ coach: { ft: 'manager-coaching', label: '(manager-facing; all entries)' },
535
+ validated: { ft: 'validated-patterns', label: '(all entries)' },
604
536
  };
605
537
  /** Resolve the files listed in injected context, in delivery order. */
606
538
  function resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain) {
@@ -646,7 +578,6 @@ function resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJo
646
578
  path: tier.path,
647
579
  displayPath: tier.displayPath,
648
580
  fileType: meta.ft,
649
- gated: meta.gated,
650
581
  label: meta.label,
651
582
  isCoach: key === 'coach',
652
583
  level: tier.level ?? fallbackLevel,
@@ -669,42 +600,14 @@ function resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJo
669
600
  const l1 = collect({ global: l1GlobalCats, domain: l1DomainCats }, (ft, dom) => resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, dom ? `${resolvedUserId}-${dom}-${ft}.md` : `${resolvedUserId}-${ft}.md`), 'manager');
670
601
  return { l2, l1, multiTier, coachPresent };
671
602
  }
672
- /**
673
- * Report how many entries have been demoted, without naming the file they live in.
674
- *
675
- * Issue #861 keeps demoted entries out of the injected context, and #1103 keeps it
676
- * that way after trying the alternative. Naming the `.cold.md` path here was
677
- * considered and reverted: the path states a family (`…-mistake-patterns.cold.md`)
678
- * and not a subject, so an instruction to "read it if this job touches its
679
- * subject" is one the agent has no basis to follow. It degrades to reading the
680
- * file every job, which is the token cost #861 removed, or never, which makes the
681
- * line noise.
682
- *
683
- * Recovery for a demoted entry is a synthesis-time concern, not a runtime one.
684
- * `sleep-on-learnings` reads both tiers when deciding whether a signal is a
685
- * recurrence, so an entry that becomes relevant again is found, updated and
686
- * promoted there. See that job's gather-signals and analyze phases.
687
- */
688
- function renderDemotedLine(listed, label) {
689
- const total = listed.reduce((sum, f) => sum + f.dormant, 0);
690
- if (total === 0)
691
- return '';
692
- return total === 1
693
- ? `Demoted: 1 ${label} pattern has gone quiet and is not listed above.\n`
694
- : `Demoted: ${total} ${label} patterns have gone quiet and are not listed above.\n`;
695
- }
696
603
  function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
697
604
  const roots = getLearningRoots(workspaceRoot);
698
605
  const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
699
- const threshold = getScoreThreshold(workspaceRoot);
700
606
  const resolved = resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain);
701
607
  const multiTier = resolved.multiTier;
702
608
  const toListed = (f) => ({
703
609
  displayPath: f.displayPath,
704
610
  label: f.label,
705
- dormant: f.gated
706
- ? scanMistakePatternFile(f.path, threshold, f.fileType).dormant
707
- : 0,
708
611
  isCoach: f.isCoach,
709
612
  });
710
613
  const l2Files = resolved.l2.map(toListed);
@@ -726,14 +629,12 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
726
629
  section += '### L2 - Org patterns\n';
727
630
  for (const f of l2Files)
728
631
  section += `\`${f.displayPath}\` ${f.label}\n`;
729
- section += renderDemotedLine(l2Files, 'org');
730
632
  section += '\n';
731
633
  }
732
634
  if (hasL1) {
733
635
  section += '### L1 - Your patterns\n';
734
636
  for (const f of l1Files)
735
637
  section += `\`${f.displayPath}\` ${f.label}\n`;
736
- section += renderDemotedLine(l1Files, 'personal');
737
638
  section += '\n';
738
639
  }
739
640
  if (l0CoachingCount > 0 || l0RetroCount > 0) {
@@ -1409,13 +1310,11 @@ function listDomainLearningFiles(dir, prefix, fileType) {
1409
1310
  }
1410
1311
  // Parse the `## [P-…] Title` entries (and their body prose) out of one learning
1411
1312
  // file. Score metadata (Last seen / Recurrences) is captured to compute the
1412
- // effective decayed score and the injected flag; those bookkeeping lines are
1413
- // still excluded from the rendered body.
1313
+ // effective decayed score for display; those bookkeeping lines are excluded from
1314
+ // the rendered body.
1414
1315
  //
1415
- // threshold: entries with score >= threshold (or in a non-gated category) are
1416
- // marked injected:true. Callers that don't need scoring pass Infinity so
1417
- // all entries are injected.
1418
- function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = exports.DEFAULT_THRESHOLD, usageStats) {
1316
+ // `injected` reflects tier standing (hot = true, cold = false); score is display-only.
1317
+ function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, usageStats) {
1419
1318
  if (!(0, fs_1.existsSync)(filePath))
1420
1319
  return [];
1421
1320
  let content;
@@ -1426,7 +1325,7 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1426
1325
  return [];
1427
1326
  }
1428
1327
  const fileType = exports.CATEGORY_TO_FILETYPE[category];
1429
- // preferences and manager-coaching are threshold-free — always injected.
1328
+ // mistake-patterns and validated-patterns carry a display score; other categories do not.
1430
1329
  const isScoreGated = fileType === 'mistake-patterns' || fileType === 'validated-patterns';
1431
1330
  const out = [];
1432
1331
  let current = null;
@@ -1443,12 +1342,11 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1443
1342
  if (curLastSeen !== null) {
1444
1343
  const s = computeEffectiveScore(current.severity, curLastSeen, curRecurrences, fileType, new Date());
1445
1344
  current.score = s;
1446
- current.injected = s >= threshold;
1345
+ current.injected = tier !== 'cold';
1447
1346
  }
1448
1347
  else {
1449
1348
  current.score = null;
1450
- // No date metadata: treat as fresh / always inject.
1451
- current.injected = true;
1349
+ current.injected = tier !== 'cold';
1452
1350
  }
1453
1351
  }
1454
1352
  else {
@@ -1620,7 +1518,6 @@ function readRejectedProposals(workspaceRoot, userId) {
1620
1518
  }
1621
1519
  function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine') {
1622
1520
  const roots = getLearningRoots(workspaceRoot);
1623
- const threshold = getScoreThreshold(workspaceRoot);
1624
1521
  const usageStats = resolveUsageStats();
1625
1522
  // Only the human library reads these observations. An unavailable store must
1626
1523
  // fail this read, so a client can retain known history rather than invent zeroes.
@@ -1657,7 +1554,7 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
1657
1554
  // ResolvedLearningTier uses 'manager' to mean machine-level personal; map it to 'machine'.
1658
1555
  const tierLevel = (t) => t.level === 'manager' ? 'machine' : t.level;
1659
1556
  const parseTiers = (tiers, cat, tierVal, domainVal) => mergeLearningEntriesAcrossTiers(tiers.map((t) => ({
1660
- entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold, usageStats)
1557
+ entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, usageStats)
1661
1558
  .map(entry => decorateAssessment(entry, t.path)),
1662
1559
  })));
1663
1560
  if (scope === 'org') {
@@ -1679,7 +1576,7 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
1679
1576
  seenDomainFile.add(fileName);
1680
1577
  const domainTiers = resolveOrgLearningFileTiers(roots.repoLearningsBase, fileName);
1681
1578
  if (domainTiers.length === 0) {
1682
- out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, threshold, usageStats)
1579
+ out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, usageStats)
1683
1580
  .map(entry => decorateAssessment(entry, (0, path_1.join)(base, fileName))));
1684
1581
  }
1685
1582
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.350",
3
+ "version": "2.0.352",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {