@entrorove/skill 0.1.71 → 0.1.78

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.
Files changed (72) hide show
  1. package/CAPABILITY-MAP.md +5 -0
  2. package/CHANGELOG.md +47 -0
  3. package/DESIGN-GATES.md +5 -9
  4. package/DESIGN-STAGED-ACCEPTANCE.md +103 -0
  5. package/IDE-EXECUTION.md +25 -4
  6. package/REUSE-DISCIPLINE.md +3 -3
  7. package/SKILL.md +15 -4
  8. package/cli.mjs +8 -4
  9. package/cloud-mutation-authority.mjs +8 -2
  10. package/cloud-task-client.mjs +10 -0
  11. package/cloud-watch-client.mjs +8 -2
  12. package/design-browser-checks.mjs +18 -5
  13. package/design-browser-pages.mjs +96 -0
  14. package/design-browser-state.mjs +1 -0
  15. package/design-browser.mjs +36 -40
  16. package/design-contracts.mjs +4 -1
  17. package/design-declaration.mjs +3 -0
  18. package/design-frameworks.mjs +2 -1
  19. package/design-gate.mjs +3 -0
  20. package/design-operations.mjs +4 -1
  21. package/design-policy.mjs +12 -4
  22. package/design-release.mjs +6 -0
  23. package/design-research-network.mjs +82 -0
  24. package/design-research.mjs +8 -25
  25. package/design-runner.mjs +3 -0
  26. package/design-stage-contracts.mjs +12 -0
  27. package/design-stage.mjs +78 -0
  28. package/design-storage.mjs +63 -0
  29. package/fs-request-trace.mjs +67 -0
  30. package/local-contracts.mjs +1 -1
  31. package/local-discipline-repository.mjs +4 -2
  32. package/local-invocations.mjs +6 -0
  33. package/local-mutation-files.mjs +13 -5
  34. package/local-probe.mjs +20 -7
  35. package/local-recovery-budget.mjs +31 -0
  36. package/local-scope-repository.mjs +18 -3
  37. package/local-scope-signature.mjs +2 -2
  38. package/local-source-execution.mjs +47 -0
  39. package/manifest.json +1 -1
  40. package/mutation-challenge-refresh.mjs +3 -0
  41. package/mutation-client.mjs +17 -5
  42. package/native-client.mjs +4 -1
  43. package/package.json +19 -2
  44. package/release-source-groups.mjs +7 -0
  45. package/release-state.json +70 -53
  46. package/release.mjs +9 -3
  47. package/releases.json +42 -0
  48. package/remote-capability-cache.mjs +20 -0
  49. package/remote-client.mjs +25 -5
  50. package/remote-transport.mjs +14 -0
  51. package/scope-amendment-client.mjs +90 -22
  52. package/scope-amendment-observer.mjs +9 -3
  53. package/scope-amendment-reference.mjs +22 -4
  54. package/scope-amendment-remote.mjs +20 -6
  55. package/scope-amendment-retirement.mjs +4 -2
  56. package/scope-amendment-state.mjs +10 -6
  57. package/scope-context-client.mjs +24 -8
  58. package/scope-context-journal.mjs +13 -6
  59. package/scope-context-remote.mjs +20 -9
  60. package/scope-context-state.mjs +4 -1
  61. package/scope-context-wire.mjs +9 -1
  62. package/scope-evidence-local.mjs +52 -0
  63. package/scope-evidence-remote.mjs +87 -0
  64. package/scope-evidence-wire.mjs +120 -0
  65. package/scope-runner-client.mjs +92 -35
  66. package/scope-runner-journal.mjs +109 -0
  67. package/scope-write-observation.d.mts +4 -0
  68. package/scope-write-observation.mjs +43 -0
  69. package/service-prerequisite.mjs +87 -0
  70. package/task-entry-client.mjs +3 -0
  71. package/workflow-observation-client.mjs +2 -0
  72. package/workflow-observation-remote.mjs +13 -5
@@ -0,0 +1,78 @@
1
+ import { designFail } from './design-policy.mjs';
2
+ import { browserStepHandoffs } from './design-storage.mjs';
3
+
4
+ export function validateDesignStage(policy) {
5
+ const stage = policy.rules.stage;
6
+ if (!stage) return;
7
+ const active = new Set(stage.pageIds), passive = new Set(stage.readOnlyPages.map(item => item.pageId));
8
+ if (active.size !== stage.pageIds.length || passive.size !== stage.readOnlyPages.length
9
+ || stage.requirementIds.some(id => !policy.site.requirementIds.includes(id))
10
+ || policy.site.pages.some(page => Number(active.has(page.id)) + Number(passive.has(page.id)) !== 1)
11
+ || [...active, ...passive].some(id => !policy.site.pages.some(page => page.id === id))) {
12
+ designFail('STAGE', 'A stage retains the whole site and partitions every original page into executable or source-bound read-only context');
13
+ }
14
+ for (const id of stage.edgeIds) {
15
+ const edge = policy.site.edges.find(item => item.id === id);
16
+ if (!edge || !active.has(edge.from) || !active.has(edge.to)) designFail('STAGE', 'Every executable stage edge must retain its original endpoints');
17
+ }
18
+ const sources = new Map();
19
+ for (const page of stage.readOnlyPages) for (const source of page.sources) {
20
+ if (!policy.rules.verificationPaths.includes(source.path)
21
+ || sources.has(source.path) && sources.get(source.path) !== source.sha256
22
+ || page.sources.filter(item => item.path === source.path).length !== 1) {
23
+ designFail('STAGE', 'Read-only context requires exact existing source hashes in the unchanged verification range');
24
+ }
25
+ sources.set(source.path, source.sha256);
26
+ }
27
+ }
28
+ export const executableDesignPages = policy => policy.rules.stage?.pageIds ?? policy.site.pages.map(page => page.id);
29
+ export const executableDesignEdges = policy => policy.rules.stage?.edgeIds ?? policy.site.edges.map(edge => edge.id);
30
+ export function designAcceptanceScope(policy) {
31
+ return { mode: policy.rules.stage ? 'stage' : 'whole-site', ...(policy.rules.stage ? { stageId: policy.rules.stage.id } : {}),
32
+ pageIds: executableDesignPages(policy), edgeIds: executableDesignEdges(policy),
33
+ readOnlyPageIds: (policy.rules.stage?.readOnlyPages ?? []).map(page => page.pageId),
34
+ claimsWholeBusinessCompletion: false };
35
+ }
36
+ export async function assertDesignStageSources(input, policy) {
37
+ const sources = new Map((policy.rules.stage?.readOnlyPages ?? []).flatMap(page => page.sources.map(source => [source.path, source.sha256])));
38
+ if (!sources.size) return;
39
+ const { repositoryIntegrityFile } = await import('./local-discipline-repository.mjs');
40
+ for (const [name, expected] of sources) {
41
+ const actual = await repositoryIntegrityFile(input.repositoryRoot, name);
42
+ if (actual.sha256 !== expected) designFail('STAGE_SOURCE_CHANGED', 'A frozen read-only context source changed; preserve this result and obtain a truthful new baseline');
43
+ }
44
+ }
45
+ export function assertDesignStageWrite(policy, name) {
46
+ const expected = (policy.rules.stage?.readOnlyPages ?? []).flatMap(page => page.sources).find(source => source.path === name);
47
+ if (expected) designFail('STAGE_READ_ONLY', 'This source is frozen as read-only context in the original approved stage');
48
+ }
49
+ const sensitive = /token|secret|password|passphrase|credential|authorization|cookie|session.?id|private.?key|api.?key|^(?:auth|roles?|permissions?|authenticated|isAdmin|isLoggedIn|accessLevel)$/i;
50
+ function safeHandoffValue(value) {
51
+ if (value === null) return true;
52
+ if (/-----BEGIN .*PRIVATE KEY|\bBearer\s+|https?:\/\/[^\s/]+:[^\s/]+@/i.test(value)) return false;
53
+ let parsed; try { parsed = JSON.parse(value); } catch { return !/[?&](?:token|key|secret|password|signature)=/i.test(value); }
54
+ const pending = [parsed]; let nodes = 0;
55
+ while (pending.length) {
56
+ if (++nodes > 1024) return false;
57
+ const node = pending.pop();
58
+ if (node && typeof node === 'object') for (const [key, item] of Object.entries(node)) {
59
+ if (sensitive.test(key)) return false;
60
+ pending.push(item);
61
+ }
62
+ else if (typeof node === 'string' && /\bBearer\s+|[?&](?:token|secret|password|signature)=/i.test(node)) return false;
63
+ }
64
+ return true;
65
+ }
66
+ export function validateDesignHandoffs(step, edge) {
67
+ const handoffs = browserStepHandoffs(step, edge);
68
+ if (!handoffs.length) return;
69
+ if (!['navigate', 'open'].includes(edge.kind) || step.action === 'fill' || step.mutations.length
70
+ || step.storageHandoffs?.length && step.readBack
71
+ || step.readBack && (step.readBack.jsonPointer || step.readBack.kind !== 'local-storage')
72
+ || new Set(handoffs.map(item => `${item.area}\0${item.key}`)).size !== handoffs.length
73
+ || handoffs.some(item => sensitive.test(item.key)
74
+ || (item.method === 'removeItem') !== (item.after === null)
75
+ || !safeHandoffValue(item.before) || !safeHandoffValue(item.after))) {
76
+ designFail('HANDOFF', 'Only an explicit navigation/open action may declare exact noncredential storage handoffs; no HTTP commit or success authority is granted');
77
+ }
78
+ }
@@ -0,0 +1,63 @@
1
+ import { canonical } from './local-integrity.mjs';
2
+
3
+ export function browserStepHandoffs(step, edge) {
4
+ if (step.storageHandoffs?.length) return step.storageHandoffs;
5
+ if (!['navigate', 'open'].includes(edge.kind) || step.readBack?.kind !== 'local-storage') return [];
6
+ const proof = step.readBack;
7
+ return [{ area: 'local-storage', key: proof.key, method: proof.after === null ? 'removeItem' : 'setItem', count: 1,
8
+ before: proof.before === null ? null : canonical(proof.before), after: proof.after === null ? null : canonical(proof.after), format: 'json' }];
9
+ }
10
+ function normalized(value, format) {
11
+ if (value === null || format !== 'json') return value;
12
+ try { return canonical(JSON.parse(value)); } catch { return undefined; }
13
+ }
14
+ async function storageSnapshot(page) {
15
+ return page.evaluate(() => {
16
+ let total = 0;
17
+ const read = store => {
18
+ if (store.length > 256) throw new Error('storage observation limit');
19
+ return Object.fromEntries(Array.from({ length: store.length }, (_, index) => {
20
+ const key = store.key(index), value = store.getItem(key); total += key.length + value.length;
21
+ if (total > 1_048_576) throw new Error('storage observation limit');
22
+ return [key, value];
23
+ }));
24
+ };
25
+ return { 'local-storage': read(localStorage), 'session-storage': read(sessionStorage) };
26
+ });
27
+ }
28
+ export function browserStorage(base, parents) {
29
+ let before = new Map(), actor;
30
+ const empty = () => ({ 'local-storage': {}, 'session-storage': {} });
31
+ return {
32
+ async begin(context, page) {
33
+ actor = page; before = new Map();
34
+ for (const current of context.pages()) if (new URL(current.url()).origin === base.origin) before.set(current, await storageSnapshot(current));
35
+ },
36
+ async check(context, current, handoffs, writes, commits, failures) {
37
+ if (commits) return;
38
+ const same = (value, expected, format) => normalized(value, format) === normalized(expected, format);
39
+ if (writes.length !== handoffs.length || handoffs.some(expected => writes.filter(event =>
40
+ [actor, current].includes(event.page) && event.area === expected.area && event.key === expected.key && event.method === expected.method
41
+ && same(event.before, expected.before, expected.format) && same(event.after, expected.after, expected.format)).length !== 1)) {
42
+ failures.push('unexpected-browser-storage-write');
43
+ }
44
+ for (const page of context.pages()) {
45
+ if (new URL(page.url()).origin !== base.origin) continue;
46
+ const original = before.get(page) ?? before.get(parents.get(page)) ?? empty(), after = await storageSnapshot(page);
47
+ for (const area of ['local-storage', 'session-storage']) {
48
+ const expected = { ...original[area] };
49
+ const applies = area === 'local-storage' || page === actor || page === current && !before.has(page);
50
+ for (const change of handoffs.filter(value => applies && value.area === area)) {
51
+ const value = Object.hasOwn(expected, change.key) ? expected[change.key] : null;
52
+ if (!same(value, change.before, change.format)) failures.push('handoff-before-state');
53
+ if (change.after === null) delete expected[change.key]; else expected[change.key] = change.after;
54
+ }
55
+ if (Object.keys(after[area]).length !== Object.keys(expected).length || Object.keys(expected).some(key => {
56
+ const declaration = handoffs.find(value => applies && value.area === area && value.key === key);
57
+ return !Object.hasOwn(after[area], key) || !same(after[area][key], expected[key], declaration?.format);
58
+ })) failures.push('handoff-storage-state');
59
+ }
60
+ }
61
+ }
62
+ };
63
+ }
@@ -0,0 +1,67 @@
1
+ import { createHook } from 'node:async_hooks';
2
+ import { performance } from 'node:perf_hooks';
3
+ import { fileURLToPath } from 'node:url';
4
+ import path from 'node:path';
5
+
6
+ const sdkRoot = path.dirname(fileURLToPath(import.meta.url));
7
+ function captureFsStack(exclude) {
8
+ const value = {}; Error.captureStackTrace(value, exclude); return value;
9
+ }
10
+ export function fsTraceStack(stack, root = sdkRoot) {
11
+ const frames = [];
12
+ for (const line of String(stack ?? '').split('\n').slice(1, 40)) {
13
+ const match = /(?:\()?((?:file:\/\/\/|node:)[^()\s]+):(\d+):(\d+)\)?$/.exec(line.trim());
14
+ if (!match) continue;
15
+ const [, source, row, column] = match; let module;
16
+ if (source.startsWith('node:')) module = source;
17
+ else {
18
+ let filename; try { filename = fileURLToPath(source); } catch { continue; }
19
+ const relative = path.relative(root, filename);
20
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || !/^[A-Za-z0-9_./-]+\.mjs$/.test(relative)) continue;
21
+ if (relative === 'fs-request-trace.mjs') continue;
22
+ module = relative;
23
+ }
24
+ frames.push({ module, line: Number(row), column: Number(column) });
25
+ if (frames.length >= 12) break;
26
+ }
27
+ return frames;
28
+ }
29
+ export function createFsRequestTrace({ hookFactory = createHook, now = () => performance.now(),
30
+ repeat = setInterval, cancel = clearInterval, write = value => process.stderr.write(`${JSON.stringify(value)}\n`),
31
+ stack = captureFsStack, thresholdMs = 15_000, maximumPending = 128 } = {}) {
32
+ if (!Number.isSafeInteger(thresholdMs) || thresholdMs < 1 || !Number.isSafeInteger(maximumPending)
33
+ || maximumPending < 1 || maximumPending > 128) throw new Error('Invalid bounded FS trace settings');
34
+ const pending = new Map(); let dropped = 0, reported = 0, enabled = true;
35
+ const emit = value => { try { write({ schemaVersion: 'entrorove.fs-request-trace/1.0', metadataOnly: true, ...value }); } catch {} };
36
+ const hook = hookFactory({
37
+ init: function fsInit(asyncId, type, triggerAsyncId) {
38
+ if ((type !== 'FSREQPROMISE' && type !== 'FSREQCALLBACK') || !enabled) return;
39
+ if (pending.size >= maximumPending) { dropped++; return; }
40
+ try { pending.set(asyncId, { asyncId, triggerAsyncId, resourceType: type, created: now(), stack: stack(fsInit), reported: false }); }
41
+ catch { dropped++; }
42
+ },
43
+ destroy(asyncId) { pending.delete(asyncId); },
44
+ promiseResolve(asyncId) { pending.delete(asyncId); },
45
+ });
46
+ const check = () => {
47
+ for (const request of pending.values()) {
48
+ const elapsedMs = Math.max(0, Math.floor(now() - request.created));
49
+ if (request.reported || elapsedMs < thresholdMs) continue;
50
+ request.reported = true; reported++;
51
+ let creationStack = [];
52
+ try { creationStack = fsTraceStack(typeof request.stack === 'string' ? request.stack : request.stack?.stack); } catch {}
53
+ request.stack = null;
54
+ emit({ kind: 'fs-request-stall', resourceType: request.resourceType, asyncId: request.asyncId,
55
+ triggerAsyncId: request.triggerAsyncId, elapsedMs, pendingCount: pending.size, dropped,
56
+ creationStack, creationStackAvailable: creationStack.length > 0, cancelsRequest: false, grantsExecutionAuthority: false });
57
+ }
58
+ };
59
+ hook.enable(); const timer = repeat(check, Math.min(1000, thresholdMs)); timer.unref?.();
60
+ const stop = () => {
61
+ if (enabled) { enabled = false; hook.disable(); cancel(timer); pending.clear(); }
62
+ return { enabled, pendingCount: pending.size, dropped, reported };
63
+ };
64
+ stop.inspect = () => ({ enabled, pendingCount: pending.size, dropped, reported });
65
+ return stop;
66
+ }
67
+ export const startFsTrace = () => createFsRequestTrace();
@@ -101,7 +101,7 @@ const scopeInteraction = object({ type: enumeration(['confirm', 'choice', 'multi
101
101
  const scopeTask = object({ goal: { ...text, pattern: '\\S', maxLength: 12000 }, workType: enumeration(['implementation', 'documentation', 'calculation', 'merge', 'review', 'decision', 'diagnosis']),
102
102
  difficulty: enumeration(['low', 'medium', 'high']), risk: enumeration(['low', 'medium', 'high']), crossModule: { type: 'boolean' }, parallel: { type: 'boolean' }, needParallel: { type: 'boolean' },
103
103
  unclear: { type: 'boolean' }, architectureContract: { type: 'boolean' }, explicitAimlockRequested: { type: 'boolean' }, explicitEntroroveRequested: { type: 'boolean' }, userSpecifiedSkills: array(text),
104
- requirements: { ...array(scopeRequirement, 1), maxItems: 500 }, phase: enumeration(['plan', 'coordinate', 'architecture', 'verify', 'merge', 'decision']), baselineRef: text, interaction: scopeInteraction }, ['goal', 'workType', 'difficulty', 'risk']);
104
+ requirements: { ...array(scopeRequirement, 1), maxItems: 500 }, authorization: record, phase: enumeration(['plan', 'coordinate', 'architecture', 'verify', 'merge', 'decision']), baselineRef: text, interaction: scopeInteraction }, ['goal', 'workType', 'difficulty', 'risk']);
105
105
  const probe = { ...step, paths: probePaths, targetPaths: probePaths, task: scopeTask, baselineRef: text, snapshotId: identifier,
106
106
  targetSymbols: array(text, 1), contextMapPath: relativePath, importContexts, contextRevisionDigest: sha256,
107
107
  contextEvidence: { ...array(object({ path: relativePath, sha256 }, ['path', 'sha256'])), maxItems: 200 }, signerConfigPath: nullable(text),
@@ -100,8 +100,10 @@ export async function disciplineRepositoryObservation(input, root, { analyze = f
100
100
  let graph;
101
101
  try { graph = roots.length ? await importGraph(root, roots, input, disciplineAnalysisLimits) : { edges: [], dynamicSources: [], complete: true }; }
102
102
  catch (error) {
103
- if (!['ENTROROVE_IMPORT_UNRESOLVED', 'ENTROROVE_IMPORT_INVALID', 'ENTROROVE_IMPORT_UNSUPPORTED', 'ENTROROVE_SCOPE_LIMIT', 'ENTROROVE_BUDGET_SCOPE'].includes(error.code)) throw error;
104
- graph = { edges: [], dynamicSources: [], complete: false }; unresolved.push({ reason: error.code, message: error.message });
103
+ if (!['ENTROROVE_IMPORT_UNRESOLVED', 'ENTROROVE_IMPORT_INVALID', 'ENTROROVE_IMPORT_UNSUPPORTED', 'ENTROROVE_IMPORT_ADDRESS_UNSUPPORTED', 'ENTROROVE_SCOPE_LIMIT', 'ENTROROVE_BUDGET_SCOPE'].includes(error.code)) throw error;
104
+ const address = error.code === 'ENTROROVE_IMPORT_ADDRESS_UNSUPPORTED'
105
+ ? { path: error.sourcePath, sha256: error.sourceSha256, selectorDigest: error.selectorDigest } : {};
106
+ graph = { edges: [], dynamicSources: [], complete: false }; unresolved.push({ ...address, reason: error.code, message: error.message });
105
107
  }
106
108
  for (const [relative, content] of overrides) if (semanticSource(relative) && /\.(?:[cm]?[jt]sx?|vue)$/i.test(relative)) {
107
109
  try {
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { withStateLock } from './local-state-lock.mjs';
6
+ import { servicePrerequisite } from './service-prerequisite.mjs';
6
7
 
7
8
  const SHA = /^[a-f0-9]{64}$/;
8
9
  const UUID = /^[a-f0-9-]{36}$/;
@@ -66,6 +67,11 @@ export async function appendInvocation(value, home = os.homedir()) {
66
67
  || !['remote', 'local'].includes(value.kind)) throw new Error('Invalid invocation ledger metadata');
67
68
  const record = { schemaVersion: 'entrorove.invocation/1.0', eventId: randomUUID(), at: new Date().toISOString(),
68
69
  kind: value.kind, operation: value.operation, requestId: value.requestId, event: value.event };
70
+ if (value.servicePrerequisite !== undefined) {
71
+ const prerequisite = servicePrerequisite({ status: 403 }, value.servicePrerequisite);
72
+ if (value.event !== 'rejected' || !prerequisite) throw new Error('Invalid service prerequisite rejection receipt');
73
+ record.servicePrerequisite = prerequisite;
74
+ }
69
75
  if (value.durationMs !== undefined) {
70
76
  if (!Number.isSafeInteger(value.durationMs) || value.durationMs < 0) throw new Error('Invalid local invocation duration');
71
77
  record.durationMs = value.durationMs;
@@ -1,12 +1,20 @@
1
- import { constants, promises as fs } from 'node:fs';
1
+ import nodeFs, { constants, promises as fs } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { setImmediate as yieldMetadata14 } from 'node:timers/promises';
5
6
  import { canonical, sha256 } from './local-integrity.mjs';
6
7
  import { readBudgetedProjectFile } from './local-budget-reads.mjs';
7
8
 
8
9
  const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
9
10
  export const mutationFail14 = (code, message) => { throw Object.assign(new Error(message), { code }); };
11
+ let metadataObservations14 = 0;
12
+ // Observe metadata afresh without waiting for a queued filesystem completion.
13
+ // Yield between bounded groups so timers and signals still run during scans.
14
+ async function lstatMetadata14(target) {
15
+ if (++metadataObservations14 % 128 === 0) await yieldMetadata14();
16
+ return nodeFs.lstatSync(target);
17
+ }
10
18
  export function mutationId14(value, field) {
11
19
  if (typeof value !== 'string' || !ID.test(value)) mutationFail14('ENTROROVE_ID_INVALID', `${field} must be a safe identifier`);
12
20
  return value;
@@ -46,11 +54,11 @@ export function mutationInside14(root, target, field = 'path') {
46
54
  async function inspectPath(target, { allowMissing = false, final = 'file', code = 'ENTROROVE_FILE_INVALID' } = {}) {
47
55
  const resolved = absolute(target, 'filesystem path');
48
56
  const base = path.parse(resolved).root; const parts = path.relative(base, resolved).split(path.sep).filter(Boolean);
49
- let current = base; const ancestors = [{ path: base, stat: await fs.lstat(base) }];
57
+ let current = base; const ancestors = [{ path: base, stat: await lstatMetadata14(base) }];
50
58
  if (!parts.length) return { target: resolved, stat: ancestors[0].stat, exists: true, ancestors };
51
59
  for (const [index, part] of parts.entries()) {
52
60
  current = path.join(current, part); let stat;
53
- try { stat = await fs.lstat(current); } catch (error) {
61
+ try { stat = await lstatMetadata14(current); } catch (error) {
54
62
  if (error.code === 'ENOENT' && allowMissing) return { target: resolved, exists: false, stat: null, ancestors };
55
63
  throw error;
56
64
  }
@@ -71,7 +79,7 @@ async function canonicalDirectoryPath(value, { allowMissing = false, code }) {
71
79
  for (const [index, part] of parts.entries()) {
72
80
  const candidate = path.join(current, part);
73
81
  let stat;
74
- try { stat = await fs.lstat(candidate); } catch (error) {
82
+ try { stat = await lstatMetadata14(candidate); } catch (error) {
75
83
  if (error.code === 'ENOENT' && allowMissing) return path.join(current, ...parts.slice(index));
76
84
  throw error;
77
85
  }
@@ -90,7 +98,7 @@ async function canonicalDirectoryPath(value, { allowMissing = false, code }) {
90
98
  }
91
99
  async function assertAncestors(item, code = 'ENTROROVE_STATE_CHANGED') {
92
100
  for (const ancestor of item.ancestors) {
93
- const stat = await fs.lstat(ancestor.path);
101
+ const stat = await lstatMetadata14(ancestor.path);
94
102
  if (stat.isSymbolicLink() || !stat.isDirectory() || !sameNode(stat, ancestor.stat)) {
95
103
  mutationFail14(code, 'mutation path ancestors changed during filesystem access');
96
104
  }
package/local-probe.mjs CHANGED
@@ -50,7 +50,7 @@ async function handoffReadBudget(input) {
50
50
  const loaded = await loadBudget(await budgetLocation(binding));
51
51
  return loaded.state.enforced && loaded.state.grant?.purpose === 'handoff-read';
52
52
  }
53
- async function probeHandoffCandidate(input, root, candidate) {
53
+ async function probeHandoffCandidate(input, root, candidate, signingOptions) {
54
54
  scopeBinding(input);
55
55
  const native = await import('./local-task-handoff-read-authority.mjs');
56
56
  const readHome = await native.assertTaskHandoffReadHome({ ...input, taskId: candidate.receiverTaskId, ownerId: candidate.ownerId });
@@ -67,13 +67,26 @@ async function probeHandoffCandidate(input, root, candidate) {
67
67
  preservedSnapshotDigest: null, preservedSnapshotId: null, currentSnapshotFresh: true,
68
68
  receiverOrigin: { flowId: candidate.flowId, taskId: candidate.receiverTaskId,
69
69
  scopeDigest: candidate.receiverOriginalScopeDigest, checkpointDigest: candidate.receiverCheckpointDigest,
70
- acceptanceReceiptId: candidate.receiverAcceptanceReceiptId, sourceCopiesVerified: false } });
70
+ acceptanceReceiptId: candidate.receiverAcceptanceReceiptId, sourceCopiesVerified: false } }, signingOptions);
71
71
  }
72
- export async function probe(input) {
72
+ function probeSigningOptions(options) {
73
+ if (!options || typeof options !== 'object' || Array.isArray(options)
74
+ || Object.keys(options).some(key => !['maximumExpiresAt', 'beforeSign'].includes(key))
75
+ || options.maximumExpiresAt !== undefined && (!Number.isSafeInteger(options.maximumExpiresAt) || options.maximumExpiresAt < 1)
76
+ || options.beforeSign !== undefined && typeof options.beforeSign !== 'function') {
77
+ scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Internal probe signing options require a finite deadline or an original bound challenge callback');
78
+ }
79
+ return { maximumExpiresAt: options.maximumExpiresAt, beforeSign: options.beforeSign };
80
+ }
81
+ export async function probe(input, options = {}) {
82
+ const signingOptions = probeSigningOptions(options);
73
83
  rejectScopePrivileges(input);
84
+ if (signingOptions.beforeSign && Object.hasOwn(input, 'handoffCandidate')) {
85
+ scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Temporary handoff candidates cannot renew an ordinary scope challenge');
86
+ }
74
87
  const root = await scopeRoot(input.repositoryRoot);
75
88
  const candidate = await verifiedScopeCandidate(input, root);
76
- if (candidate && await handoffReadBudget(input)) return probeHandoffCandidate(input, root, candidate);
89
+ if (candidate && await handoffReadBudget(input)) return probeHandoffCandidate(input, root, candidate, signingOptions);
77
90
  const hasBinding = ['flowId', 'stepId', 'snapshotId'].some(field => input[field] !== undefined);
78
91
  const retained = hasBinding ? await retainedScope(input, root, true) : null;
79
92
  const fresh = !!candidate || (!!retained && retained.stepId !== input.stepId);
@@ -97,15 +110,15 @@ export async function probe(input) {
97
110
  classification: { ...result.classification, mode: candidate ? result.classification.mode : retained.mode }, requiredMode: result.classification.mode,
98
111
  originalBinding: { flowId: retained.flowId, stepId: retained.stepId, snapshotId: retained.originalSnapshot.snapshotId },
99
112
  snapshotDigest: result.facts.snapshotEvidence.snapshotDigest, preservedSnapshotDigest: retained.originalSnapshot.digest,
100
- preservedSnapshotId: retained.originalSnapshot.snapshotId, currentSnapshotFresh: true });
113
+ preservedSnapshotId: retained.originalSnapshot.snapshotId, currentSnapshotFresh: true }, signingOptions);
101
114
  }
102
115
  const saved = await initializeScope(input, result.repositoryRoot, result.facts, result.classification);
103
116
  return attestScopeFacts(input, { ...result, revision: saved.state.revision, statePath: saved.statePath,
104
117
  classification: { ...result.classification, mode: saved.state.mode }, requiredMode: result.classification.mode,
105
118
  snapshotDigest: result.facts.snapshotEvidence.snapshotDigest, preservedSnapshotDigest: saved.state.originalSnapshot.digest,
106
- preservedSnapshotId: saved.state.originalSnapshot.snapshotId, currentSnapshotFresh: true });
119
+ preservedSnapshotId: saved.state.originalSnapshot.snapshotId, currentSnapshotFresh: true }, signingOptions);
107
120
  }
108
- return attestScopeFacts(input, result);
121
+ return attestScopeFacts(input, result, signingOptions);
109
122
  }
110
123
  function observationReassessment(input, result) {
111
124
  const previousMode = input.currentMode === undefined ? result.classification.mode : input.currentMode;
@@ -0,0 +1,31 @@
1
+ // Durable per-operation limits. These are recovery limits, not an execution
2
+ // permission, and callers cannot enlarge them through request payloads.
3
+ const limits = Object.freeze({ attempt: 2, signing: 2, status: 3, prerequisite: 2 });
4
+ const fail = recovery => Object.assign(new Error('Automatic recovery limit reached; preserve the original task and pending evidence, and report the concrete failure before another attempt'),
5
+ { code: 'ENTROROVE_RECOVERY_EXHAUSTED', recovery: structuredClone(recovery), automaticRetryAllowed: false });
6
+ export function consumeRecovery(state, kind, eventId) {
7
+ if (!Object.hasOwn(limits, kind)) throw new Error('Unknown recovery boundary');
8
+ const recovery = Object.hasOwn(state, 'recovery') ? state.recovery : { schemaVersion: 'entrorove.recovery-budget/1.0', counts: {} };
9
+ if (!recovery || recovery.schemaVersion !== 'entrorove.recovery-budget/1.0' || !recovery.counts || Array.isArray(recovery.counts)
10
+ || Object.keys(recovery).some(key => !['schemaVersion', 'counts', 'events'].includes(key))
11
+ || Object.entries(recovery.counts).some(([key, value]) => !Object.hasOwn(limits, key)
12
+ || !Number.isSafeInteger(value) || value < 0 || value > limits[key])) {
13
+ throw Object.assign(new Error('Original recovery accounting is invalid'), { code: 'ENTROROVE_RECOVERY_STATE' });
14
+ }
15
+ if (recovery.events && (Array.isArray(recovery.events) || Object.entries(recovery.events).some(([key, value]) =>
16
+ !Object.hasOwn(limits, key) || typeof value !== 'string' || !value || value.length > 256 || !recovery.counts[key]))) {
17
+ throw Object.assign(new Error('Original recovery event is invalid'), { code: 'ENTROROVE_RECOVERY_STATE' });
18
+ }
19
+ if (eventId !== undefined && (typeof eventId !== 'string' || !eventId || eventId.length > 256)) throw new Error('Invalid recovery event');
20
+ if (eventId && recovery.events?.[kind] === eventId) return structuredClone(recovery);
21
+ if ((recovery.counts[kind] ?? 0) >= limits[kind]) throw fail(recovery);
22
+ recovery.counts[kind] = (recovery.counts[kind] ?? 0) + 1;
23
+ if (eventId) recovery.events = { ...recovery.events, [kind]: eventId };
24
+ state.recovery = recovery;
25
+ return structuredClone(recovery);
26
+ }
27
+ export function recoveryFailureFields(error) {
28
+ return error?.code === 'ENTROROVE_RECOVERY_EXHAUSTED' || error?.code === 'ENTROROVE_RECOVERY_STATE'
29
+ ? { code: error.code, message: error.message, automaticRetryAllowed: false,
30
+ ...(error.recovery ? { recovery: error.recovery } : {}) } : {};
31
+ }
@@ -21,6 +21,7 @@ import { uniConditionalVariants } from './local-scope-uni-conditional.mjs';
21
21
  import { createUniBuildInspection } from './local-scope-uni-build.mjs';
22
22
  import { createScopeBuildIO } from './local-scope-build-io.mjs';
23
23
  import { observeBuildFacts } from './local-scope-build-facts.mjs';
24
+ import { scopeWriteFiles, scopeWriteObservation } from './scope-write-observation.mjs';
24
25
 
25
26
  const execute = promisify(execFile);
26
27
  const inspectionFreshness = new WeakMap();
@@ -247,7 +248,11 @@ function declarationModules(content, from) {
247
248
  }
248
249
  async function resolveImport(root, from, specifier, workspace, input, aliases, configPath, declarations) {
249
250
  if (specifier.startsWith('/') || /^[A-Za-z]:/.test(specifier) || specifier.includes('\\') || /[\u0000-\u001f\u007f]/.test(specifier)) {
250
- scopeFail('ENTROROVE_PATH_UNSAFE', 'Source module addresses require safe repository-relative or declared package selectors');
251
+ // This is a source expression, not permission to probe an absolute target.
252
+ // Strict dependency inspection still rejects it; whole-repository usage
253
+ // observation can retain this specific semantic uncertainty without I/O.
254
+ throw Object.assign(new Error('Source module address has no supported local projection; no target was inspected'),
255
+ { code: 'ENTROROVE_IMPORT_ADDRESS_UNSUPPORTED', sourcePath: from, selectorDigest: sha256(specifier) });
251
256
  }
252
257
  if (/^(?:https?:|file:|data:)/i.test(specifier)) scopeFail('ENTROROVE_IMPORT_UNSUPPORTED', 'remote source imports cannot be inspected as local facts');
253
258
  if (declarations.has(specifier)) return null;
@@ -367,7 +372,10 @@ export async function importGraph(root, originals, input = { repositoryRoot: roo
367
372
  catch (error) {
368
373
  const missing = error.code === 'ENTROROVE_IMPORT_UNRESOLVED' && specifier.startsWith('.')
369
374
  ? error.missingCandidates?.filter(candidate => pendingCreates.has(candidate)) : null;
370
- if (error.code !== 'ENTROROVE_IMPORT_UNRESOLVED') throw error;
375
+ if (error.code !== 'ENTROROVE_IMPORT_UNRESOLVED') {
376
+ if (error.code === 'ENTROROVE_IMPORT_ADDRESS_UNSUPPORTED') error.sourceSha256 = read.sha256;
377
+ throw error;
378
+ }
371
379
  unresolvedImports.push({ path: relative, specifier, reason: missing?.length ? 'approved-create-not-present' : 'source-import-unresolved',
372
380
  candidatePaths: missing?.length ? missing : error.missingCandidates ?? [] });
373
381
  dynamic = true;
@@ -435,6 +443,13 @@ export async function collectScopeFacts(root, requested, baselineRef, knownFiles
435
443
  const verifyGraph = inspectionFreshness.get(graph), observedMetadata = graph.workspaceMetadata.map(file => ({ path: file.path, sha256: file.sha256 }));
436
444
  inspectionFreshness.set(graph, async () => { await verifyGraph(); await stableScope(root, observedMetadata, input, scopeBuildSourceRead); });
437
445
  const observedGit = await observeGitFacts(root, files, baselineRef, input); const history = observedGit.facts;
446
+ let writeScope;
447
+ if (input.scopeWritePaths !== undefined) {
448
+ const writable = scopeWriteFiles(files, input.scopeWritePaths);
449
+ const observed = await observeGitFacts(root, writable, baselineRef, input);
450
+ if (['available', 'head', 'baseline'].some(field => observed.facts[field] !== history[field])) scopeFail('ENTROROVE_SCOPE_CHANGED', 'Write observation lost the full scope Git identity');
451
+ writeScope = scopeWriteObservation(writable, observed, knownFiles);
452
+ }
438
453
  const modules = [...new Map(graph.nodes.map(node => [node.boundary.path, node.boundary])).values()].sort((a, b) => a.path.localeCompare(b.path));
439
454
  await stableScope(root, files, input);
440
455
  await stableScope(root, modules.filter(module => module.manifestPath).map(module => ({ path: module.manifestPath, sha256: module.manifestDigest })), input, scopeBuildSourceRead);
@@ -445,7 +460,7 @@ export async function collectScopeFacts(root, requested, baselineRef, knownFiles
445
460
  : sourceLines + files.filter(file => !file.exists).reduce((sum, file) => sum + (knownFiles.find(previous => previous.path === file.path)?.lines ?? 0), 0);
446
461
  return { targetPaths: [...new Set(requested)].sort(), fileCount: files.length, files, bytes, sourceLines, tokenEstimate: Math.ceil(bytes / 4), modules,
447
462
  crossModule: modules.length > 1, risk: observedScopeRisk(files), importGraph: graph, git: history,
448
- estimatedChangedLines };
463
+ estimatedChangedLines, ...(writeScope ? { writeScope } : {}) };
449
464
  }
450
465
  async function contextEvidence(root, graph, input) {
451
466
  if (input.contextEvidence === undefined) return;
@@ -37,8 +37,8 @@ function binding(input, result) {
37
37
  }
38
38
  async function signingChallenge(input, beforeSign, maximumExpiresAt) {
39
39
  if (beforeSign === undefined) return { input, maximumExpiresAt };
40
- if (typeof beforeSign !== 'function' || !input.contextRevisionDigest || Object.hasOwn(input, 'handoffCandidate')) {
41
- scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Challenge renewal is restricted to an original context observation');
40
+ if (typeof beforeSign !== 'function' || Object.hasOwn(input, 'handoffCandidate')) {
41
+ scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Challenge renewal requires an original bound scope observation');
42
42
  }
43
43
  const fresh = await beforeSign();
44
44
  if (!fresh || Object.keys(fresh).sort().join(',') !== 'challenge,maximumExpiresAt'
@@ -0,0 +1,47 @@
1
+ import { prerequisiteFields } from './service-prerequisite.mjs';
2
+ export { startFsTrace } from './fs-request-trace.mjs';
3
+
4
+ const flag = '--local-source';
5
+ const commands = new Set(['invoke', 'native', 'scope-connect', 'scope-context', 'scope-amend', 'mutation-connect',
6
+ 'mutation-settle', 'workflow-observe-step', 'cloud-task', 'cloud-status', 'cloud-watch', 'feedback', 'status',
7
+ 'flows', 'usage', 'billing', 'recurring', 'host-message', 'queue', 'local', 'review']);
8
+ const documents = ['SKILL.md', 'CAPABILITY-MAP.md', 'AGREEMENT.md', 'manifest.json', 'IDE-EXECUTION.md',
9
+ 'REUSE-DISCIPLINE.md', 'DELIVERY-BOUNDARIES.md', 'DESIGN-GATES.md'];
10
+ const fail = message => { throw Object.assign(new Error(message), { code: 'ENTROROVE_LOCAL_SOURCE_IDENTITY' }); };
11
+ let announced = false;
12
+
13
+ export function localSourceArguments(args) {
14
+ const count = args.filter(value => value === flag).length;
15
+ const tracing = args.filter(value => value === '--trace-fs').length;
16
+ if (!count && !tracing) return { args, localSource: false };
17
+ if (!count || tracing > 1 || tracing && args.at(-2) !== '--trace-fs') fail('--trace-fs requires explicit --local-source and must immediately precede it');
18
+ if (count !== 1 || args.at(-1) !== flag || !commands.has(args[0])) {
19
+ fail('Use --local-source exactly once, as the final argument of an SDK operation; installation and account configuration are excluded');
20
+ }
21
+ return { args: args.slice(0, tracing ? -2 : -1), localSource: true, ...(tracing ? { traceFs: true } : {}) };
22
+ }
23
+ export function verifyLocalSourceInstall(target, managed, bundle) {
24
+ if (!managed || typeof managed !== 'object' || managed.version !== bundle?.manifest?.version
25
+ || !/^[a-f0-9]{64}$/.test(managed.packageDigest ?? '') || !/^[a-f0-9]{64}$/.test(bundle.packageDigest ?? '')
26
+ || Object.keys(managed.files ?? {}).sort().join('\0') !== [...documents].sort().join('\0')
27
+ || documents.some(name => managed.files[name] !== bundle.hashes[name])) {
28
+ fail('Local unpublished source requires the existing pristine managed eight documents at the same version; preserve the installation and repair this mismatch explicitly');
29
+ }
30
+ return { schemaVersion: 'entrorove.local-source-execution/1.0', status: 'verified', mode: 'local-unpublished-source',
31
+ declaredVersion: bundle.manifest.version, managedDirectory: target, sourceDigest: bundle.packageDigest,
32
+ managedPackageDigest: managed.packageDigest, published: false, managedUnchanged: true,
33
+ grantsExecutionAuthority: false, managedInstallationPresent: true, updated: false };
34
+ }
35
+ export function localSourceInstall(target, managed, bundle, { explicit, hostId, hooks } = {}) {
36
+ if (explicit || hostId !== undefined || hooks) fail('Local source execution cannot install, replace, clean up or configure hooks');
37
+ const verified = verifyLocalSourceInstall(target, managed, bundle);
38
+ if (!announced) { process.stdout.write(`${JSON.stringify(verified)}\n`); announced = true; }
39
+ return verified;
40
+ }
41
+ export function cliErrorText(error) {
42
+ const prerequisite = prerequisiteFields(error);
43
+ return prerequisite.servicePrerequisite ? JSON.stringify({ status: 'blocked', code: error.code,
44
+ message: error.message, ...prerequisite, ...(error.requestId ? { requestId: error.requestId } : {}),
45
+ ...(error.outcomeUnknown ? { outcomeUnknown: true, repeatPost: false } : {}) })
46
+ : error instanceof SyntaxError ? 'Invalid JSON data' : error.message;
47
+ }
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": "entrorove.skill-manifest/1.0",
3
3
  "name": "entrorove",
4
- "version": "0.1.71",
4
+ "version": "0.1.78",
5
5
  "endpoint": "https://entrorove.com",
6
6
  "requestSchemaVersion": "entrorove.skill.request/2.0",
7
7
  "supportedRequestSchemaVersions": [
@@ -1,6 +1,7 @@
1
1
  import { canonical, sha256 } from './local-integrity.mjs';
2
2
  import { buildInvocation } from './remote-client.mjs';
3
3
  import { mutationFail14 } from './local-mutation-files.mjs';
4
+ import { consumeRecovery } from './local-recovery-budget.mjs';
4
5
 
5
6
  const fail = message => mutationFail14('ENTROROVE_MUTATION_CHALLENGE_REFRESH', message);
6
7
  export async function refreshExpiredMutationChallenge(context, callbacks) {
@@ -36,6 +37,8 @@ export async function refreshExpiredMutationChallenge(context, callbacks) {
36
37
  await save(location, state);
37
38
  }
38
39
  if (canonical(state.refreshOriginal.challenged.challenge) !== canonical(old)) fail('Original archived challenge binding changed');
40
+ consumeRecovery(state, 'signing', old.challengeId);
41
+ await save(location, state);
39
42
  const ref = { challengeId: old.challengeId, challengeDigest: sha256(canonical(old)) };
40
43
  const input = { ...Object.fromEntries(fields.map(field => [field, identity[field]])),
41
44
  ...(identity.previousPassId ? { previousPassId: identity.previousPassId } : {}),
@@ -21,6 +21,8 @@ import { readMutationConnection as readConnection } from './mutation-connection-
21
21
  import { readMutationEnvironmentObservation } from './local-mutation-environment-observation.mjs';
22
22
  import { refreshExpiredMutationChallenge } from './mutation-challenge-refresh.mjs';
23
23
  import { scopeContextObservation, scopeContextSnapshotInput } from './scope-context-observation.mjs';
24
+ import { prerequisiteError, retainPrerequisite, retryKnownPrerequisite, prerequisiteFields } from './service-prerequisite.mjs';
25
+ import { consumeRecovery, recoveryFailureFields } from './local-recovery-budget.mjs';
24
26
 
25
27
  const fields = ['taskId', 'flowId', 'stepId', 'leaseId', 'generation', 'runnerId', 'paths'];
26
28
  const fail = message => mutationFail14('ENTROROVE_MUTATION_CONNECTION_BINDING', message);
@@ -44,13 +46,16 @@ async function recoverRemote(pending, dependencies) {
44
46
  const token = await dependencies.readCredential(dependencies.home), origin = trustedOrigin(dependencies.apiOrigin());
45
47
  const response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, { headers: { authorization: `Bearer ${token}`, ...ideClientHeaders() } });
46
48
  const result = await parseTransportResponse(response, 'original mutation connection status');
49
+ const prerequisite = prerequisiteError(response, result, { requestId: pending.requestId });
50
+ if (prerequisite) throw Object.assign(unknown(pending.requestId), { servicePrerequisite: prerequisite.servicePrerequisite });
47
51
  if (!response.ok || result?.output?.requestId !== pending.requestId || ['running', 'pending'].includes(result.output.status)) throw unknown(pending.requestId);
48
52
  result.feedback ??= await queryAutomaticFeedback(pending.requestId, dependencies);
49
53
  verifyAutomaticFeedback(result.feedback, pending.requestId, pending.requestId);
50
54
  result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
51
55
  await appendInvocation({ kind: 'remote', operation: 'status', requestId: pending.requestId, event: 'status', usage: result.usage ?? [] }, dependencies.home);
52
56
  return { response, result };
53
- } catch { throw unknown(pending.requestId); }
57
+ } catch (error) { throw Object.assign(unknown(pending.requestId),
58
+ error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {}); }
54
59
  }
55
60
  async function knownNotSent(error, pending, home) {
56
61
  if (error?.requestId && error.requestId !== pending.requestId || error?.retryableBeforeTransmission === true) return true;
@@ -62,22 +67,27 @@ async function knownNotSent(error, pending, home) {
62
67
  async function remote(stage, operation, input, state, location, dependencies) {
63
68
  const requestId = buildInvocation(operation, input).requestId;
64
69
  let response, result;
65
- if (state.pending) {
70
+ const existing = Boolean(state.pending);
71
+ if (existing) {
66
72
  if (state.pending.stage !== stage || state.pending.operation !== operation || canonical(state.pending.input) !== canonical(input)) {
67
73
  fail('Recover the original mutation request before another operation or input');
68
74
  }
75
+ }
76
+ if (existing && !await retryKnownPrerequisite(state, location, save)) {
77
+ consumeRecovery(state, 'status'); await save(location, state);
69
78
  ({ response, result } = await recoverRemote(state.pending, dependencies));
70
79
  } else {
71
- state.pending = { stage, operation, input, requestId };
80
+ if (!existing) state.pending = { stage, operation, input, requestId };
72
81
  try { await save(location, state); } catch { throw notSent(requestId); }
73
82
  try { ({ response, result } = await invokeRemoteResult({ operation, input }, dependencies)); }
74
83
  catch (error) {
84
+ if (await retainPrerequisite(error, state, location, save)) throw error;
75
85
  if (await knownNotSent(error, state.pending, dependencies.home)) {
76
86
  delete state.pending;
77
87
  try { await save(location, state); } catch { throw notSent(requestId); }
78
88
  throw notSent(requestId);
79
89
  }
80
- throw unknown(requestId);
90
+ throw Object.assign(unknown(requestId), error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {});
81
91
  }
82
92
  }
83
93
  delete state.pending;
@@ -294,6 +304,7 @@ async function runMutationClient(operation, repositoryRoot, input, dependencies,
294
304
  if (error?.outcomeUnknown === true && requestId) return {
295
305
  schemaVersion: 'entrorove.mutation-connection/1.0', status: 'blocked', flowId: input?.flowId, requestId,
296
306
  code: 'ENTROROVE_MUTATION_CONNECTION_OUTCOME_UNKNOWN', outcomeUnknown: true, recoverable: true,
307
+ ...prerequisiteFields(error),
297
308
  message: `Original mutation request outcome is unknown; rerun ${operation} with the same input to recover only its original GET, without repeating its POST`,
298
309
  nextAction: { operation, requestId, method: 'GET', reuseOriginalInput: true, repeatPost: false },
299
310
  };
@@ -301,11 +312,12 @@ async function runMutationClient(operation, repositoryRoot, input, dependencies,
301
312
  const knownBeforeTransmission = preflight || error?.knownBeforeTransmission === true;
302
313
  return { schemaVersion: 'entrorove.mutation-connection/1.0', status: 'blocked', flowId: input?.flowId,
303
314
  code: typed ? error.code : 'ENTROROVE_MUTATION_CONNECTION_LOCAL_PREREQUISITE',
315
+ ...prerequisiteFields(error),
304
316
  message: typed ? error.message : 'Mutation connection stopped at a local prerequisite; preserve its journal and repair before reusing the original input',
305
317
  ...(requestId ? { requestId } : {}), ...(knownBeforeTransmission ? { knownBeforeTransmission: true } : {}),
306
318
  recovery: { operation, reuseOriginalInput: true, preserveJournal: true,
307
319
  ...(knownBeforeTransmission ? { remoteAttemptedThisRun: false } : {}) },
308
- ...(error?.output ? { output: error.output } : {}) };
320
+ ...(error?.output ? { output: error.output } : {}), ...recoveryFailureFields(error) };
309
321
  }
310
322
  }
311
323
  export const runMutationConnect = (repositoryRoot, input, dependencies) => runMutationClient('mutation-connect', repositoryRoot, input, dependencies, connectMutation);