@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,109 @@
1
+ import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { canonical, sha256 } from './local-integrity.mjs';
4
+ import { readMutationJson14, atomicMutationJson14, mutationFile14 } from './local-mutation-files.mjs';
5
+ import { evidenceFail } from './scope-evidence-wire.mjs';
6
+ import { scopeFail } from './local-scope-files.mjs';
7
+ import { consumeRecovery } from './local-recovery-budget.mjs';
8
+
9
+ export async function saveScopeRunnerJournal(location, state) {
10
+ if (Buffer.byteLength(canonical(state)) + 1 > 8_000_000) evidenceFail('Scope connection journal exceeds its unchanged bounded control limit');
11
+ await atomicMutationJson14(location.file, state, { privateFrom: location.stateRoot, replace: true });
12
+ }
13
+ export async function scopeRunnerJournal(location, identity) {
14
+ let state;
15
+ try {
16
+ const item = await mutationFile14(location.file);
17
+ if (item.stat.size > 8_000_000 || item.stat.nlink !== 1) evidenceFail('Scope connection journal is invalid');
18
+ state = await readMutationJson14(location.file);
19
+ if (state.schemaVersion !== 'entrorove.scope-connection-journal/1.0' || canonical(state.identity) !== canonical(identity)) {
20
+ evidenceFail('Scope connection journal replaced its original task, repository or complete input');
21
+ }
22
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
23
+ if (state && reusableExpiredScopeSnapshot(state, identity)) {
24
+ const archivedDigest = await archiveScopeRunnerAttempt(location, state);
25
+ consumeRecovery(state, 'attempt', state.attemptId);
26
+ state.signingRecovery = { reason: 'known-local-signature-expiry', archivedDigest, snapshotId: state.snapshotId,
27
+ requiresSnapshotVerification: true, grantsExecutionAuthority: false, recoveredAt: new Date().toISOString() };
28
+ delete state.rejection;
29
+ await saveScopeRunnerJournal(location, state);
30
+ } else if (state && !state.pending && !state.evidencePending && (state.completed || state.rejection)) {
31
+ await archiveScopeRunnerAttempt(location, state);
32
+ const recovery = state.completed ? undefined : consumeRecovery(state, 'attempt', state.attemptId);
33
+ state = { schemaVersion: 'entrorove.scope-connection-journal/1.0', identity, attemptId: randomUUID(),
34
+ ...(recovery ? { recovery } : {}) };
35
+ await saveScopeRunnerJournal(location, state);
36
+ }
37
+ if (!state) {
38
+ state = { schemaVersion: 'entrorove.scope-connection-journal/1.0', identity, attemptId: randomUUID() };
39
+ await saveScopeRunnerJournal(location, state);
40
+ }
41
+ return state;
42
+ }
43
+
44
+ export async function archiveScopeRunnerAttempt(location, state) {
45
+ const checksum = sha256(canonical(state));
46
+ const archive = path.join(location.directory, `${state.attemptId}-${checksum}.json`);
47
+ try { if (canonical(await readMutationJson14(archive)) !== canonical(state)) evidenceFail('Scope connection history changed'); }
48
+ catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(archive, state, { privateFrom: location.stateRoot }); }
49
+ return checksum;
50
+ }
51
+ export function validateScopeRunnerChallenge(value, original, identity, binding, { allowExpired = false } = {}) {
52
+ const immutable = challenge => Object.fromEntries(Object.entries(challenge ?? {}).filter(([field]) => !['challenge', 'expiresAt'].includes(field)));
53
+ const id = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/, hash = /^[a-f0-9]{64}$/;
54
+ if (!value || !original || canonical(immutable(value)) !== canonical(immutable(original))
55
+ || value.flowId !== identity.flowId || value.runnerId !== binding.runnerId || value.receiverId !== binding.runnerId
56
+ || value.repositoryId !== binding.repositoryId || canonical(value.targetPaths) !== canonical(identity.targetPaths)
57
+ || ['stepId', 'ownerId', 'challenge'].some(field => !id.test(value[field] ?? ''))
58
+ || ['taskDigest', 'chainDigest'].some(field => !hash.test(value[field] ?? ''))
59
+ || !Number.isSafeInteger(value.expectedRevision) || value.expectedRevision < 0
60
+ || !value.task || typeof value.task !== 'object' || Array.isArray(value.task)) {
61
+ scopeFail('ENTROROVE_SCOPE_BINDING', 'Fresh scope challenge replaced its original task, signer, step, paths or revision');
62
+ }
63
+ const expiresAt = Date.parse(value.expiresAt);
64
+ if (!Number.isFinite(expiresAt) || !allowExpired && (expiresAt <= Date.now() || expiresAt > Date.now() + 90_000)) {
65
+ scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Actual scope signature requires its current bounded original challenge');
66
+ }
67
+ return value;
68
+ }
69
+ function noScopeSubmission(state) {
70
+ return !['pending', 'evidencePending', 'evidenceDescriptor', 'evidencePrepared', 'evidenceSignature',
71
+ 'attestation', 'attestationRef', 'evidenceResult', 'accepted'].some(field => Object.hasOwn(state, field));
72
+ }
73
+ function reusableExpiredScopeSnapshot(state, identity) {
74
+ if (!noScopeSubmission(state) || state.completed || state.snapshotStarted !== true
75
+ || typeof state.snapshotId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(state.snapshotId)
76
+ || state.rejection?.code !== 'ENTROROVE_SCOPE_ATTESTATION_BINDING'
77
+ || state.rejection.message !== 'candidate challenge expired before its actual scope signature'
78
+ || state.rejection.requestId || state.rejection.output || state.registered?.registered !== true) return false;
79
+ const enrollment = state.prepared?.enrollment, original = state.challenged?.challenge;
80
+ if (!enrollment || !original || enrollment.flowId !== identity.flowId || state.registered.runnerId !== enrollment.runnerId
81
+ || state.registered.repositoryId !== enrollment.repositoryId
82
+ || canonical(state.registered.targetPaths) !== canonical(identity.targetPaths)
83
+ || ['ownerId', 'taskDigest', 'chainDigest', 'runnerId', 'repositoryId'].some(field => enrollment[field] !== original[field])
84
+ || canonical(enrollment.targetPaths) !== canonical(identity.targetPaths)) return false;
85
+ try { validateScopeRunnerChallenge((state.signingChallenge ?? state.challenged).challenge, original, identity, enrollment, { allowExpired: true }); }
86
+ catch { return false; }
87
+ return Date.parse((state.signingChallenge ?? state.challenged).challenge.expiresAt) <= Date.now();
88
+ }
89
+ export async function renewScopeRunnerChallenge(identity, location, state, original, remote) {
90
+ if (!['evidencePending', 'evidenceDescriptor', 'evidencePrepared', 'attestationRef', 'attestation', 'accepted']
91
+ .every(field => !Object.hasOwn(state, field)) || canonical(state.identity) !== canonical(identity)) {
92
+ scopeFail('ENTROROVE_SCOPE_BINDING', 'Recover the original signed evidence before another scope signing challenge');
93
+ }
94
+ if (state.pending && (state.pending.stage !== 'signingChallenge' || !state.signingAttemptId)) {
95
+ scopeFail('ENTROROVE_SCOPE_BINDING', 'Recover only the original pending request before challenge renewal');
96
+ }
97
+ if (!state.pending && state.signingChallenge && Date.parse(state.signingChallenge.challenge?.expiresAt) > Date.now()) {
98
+ return validateScopeRunnerChallenge(state.signingChallenge.challenge, original, identity, location.binding);
99
+ }
100
+ if (!state.pending && (!state.signingAttemptId || state.signingChallenge)) {
101
+ await archiveScopeRunnerAttempt(location, state);
102
+ consumeRecovery(state, 'signing', state.signingAttemptId ?? state.attemptId);
103
+ delete state.signingChallenge; state.signingAttemptId = randomUUID();
104
+ await saveScopeRunnerJournal(location, state);
105
+ }
106
+ const issued = await remote('signingChallenge', 'scope-runner-challenge', { flowId: identity.flowId,
107
+ receiverId: location.binding.runnerId, attemptId: state.signingAttemptId });
108
+ return validateScopeRunnerChallenge(issued.challenge, original, identity, location.binding);
109
+ }
@@ -0,0 +1,4 @@
1
+ type File = { path: string; bytes: number; lines: number }
2
+ export function projectScopeWriteFacts<T extends { files: File[]; recentChangedLines: number }>(
3
+ facts: T, signedFacts: Record<string, any>, roles: Array<{ path: string; access: 'write' | 'read-only' | 'create' }>
4
+ ): T
@@ -0,0 +1,43 @@
1
+ const version = 'entrorove.scope-write-observation/1.0';
2
+ const fields = ['schemaVersion', 'paths', 'fileCount', 'bytes', 'sourceLines', 'tokenEstimate', 'git', 'estimatedChangedLines'];
3
+ function fail() {
4
+ throw Object.assign(new Error('Write observations require the exact approved roles and independently signed source counts'),
5
+ { code: 'ENTROROVE_SCOPE_WRITE_OBSERVATION' });
6
+ }
7
+ const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
8
+ export function scopeWriteFiles(files, requested) {
9
+ if (!Array.isArray(requested) || !requested.length || requested.length > files.length
10
+ || requested.some(value => typeof value !== 'string') || new Set(requested).size !== requested.length) fail();
11
+ const selected = files.filter(file => requested.includes(file.path));
12
+ if (selected.length !== requested.length) fail();
13
+ return selected;
14
+ }
15
+ export function scopeEstimatedChanges(files, observation, knownFiles) {
16
+ const git = observation.facts;
17
+ return git.available && git.head ? observation.currentDiffPresent ? git.currentChangedLines : git.recentChangedLines
18
+ : files.reduce((total, file) => total + file.lines + (file.exists === false
19
+ ? knownFiles.find(previous => previous.path === file.path)?.lines ?? 0 : 0), 0);
20
+ }
21
+ export function scopeWriteObservation(files, observation, knownFiles) {
22
+ const bytes = files.reduce((total, file) => total + file.bytes, 0);
23
+ return { schemaVersion: version, paths: files.map(file => file.path).sort(), fileCount: files.length, bytes,
24
+ sourceLines: files.reduce((total, file) => total + file.lines, 0), tokenEstimate: Math.ceil(bytes / 4),
25
+ git: observation.facts, estimatedChangedLines: scopeEstimatedChanges(files, observation, knownFiles) };
26
+ }
27
+ // Only call after verifying the original S receipt and its exact approved role set.
28
+ // Older signed receipts retain their conservative full-scope change estimate.
29
+ export function projectScopeWriteFacts(facts, signedFacts, roles) {
30
+ const files = scopeWriteFiles(facts.files, roles.filter(file => file.access !== 'read-only').map(file => file.path));
31
+ const observed = signedFacts.writeScope;
32
+ if (observed === undefined) return { ...facts, files };
33
+ if (!observed || typeof observed !== 'object' || !same(Object.keys(observed).sort(), [...fields].sort())
34
+ || observed.schemaVersion !== version || !same(observed.paths, files.map(file => file.path).sort())
35
+ || !['fileCount', 'bytes', 'sourceLines', 'tokenEstimate', 'estimatedChangedLines']
36
+ .every(field => Number.isSafeInteger(observed[field]) && observed[field] >= 0)
37
+ || observed.fileCount !== files.length || observed.bytes !== files.reduce((sum, file) => sum + file.bytes, 0)
38
+ || observed.sourceLines !== files.reduce((sum, file) => sum + file.lines, 0)
39
+ || observed.tokenEstimate !== Math.ceil(observed.bytes / 4)
40
+ || !observed.git || !signedFacts.git
41
+ || ['available', 'head', 'baseline'].some(field => observed.git[field] !== signedFacts.git[field])) fail();
42
+ return { ...facts, files, recentChangedLines: observed.estimatedChangedLines };
43
+ }
@@ -0,0 +1,87 @@
1
+ import { canonical } from './local-integrity.mjs';
2
+ import { consumeRecovery } from './local-recovery-budget.mjs';
3
+
4
+ const fields = ['schemaVersion', 'error', 'code', 'version', 'requiredSetDigest', 'confirmationUrl',
5
+ 'grantsExecutionAuthority', 'recoverable', 'preAdmission', 'knownBeforeEffects'];
6
+ const bound = 4096;
7
+ export function servicePrerequisite(response, value) {
8
+ if (response?.status !== 403 || !value || typeof value !== 'object' || Array.isArray(value)
9
+ || Object.keys(value).sort().join('\0') !== [...fields].sort().join('\0')
10
+ || value.schemaVersion !== 'entrorove.account-agreement-required/1.0'
11
+ || value.error !== 'account-agreement-required' || value.code !== 'ENTROROVE_ACCOUNT_AGREEMENT_REQUIRED'
12
+ || !/^\d{4}-\d{2}-\d{2}\.[1-9][0-9]{0,5}$/.test(value.version ?? '')
13
+ || !/^[a-f0-9]{64}$/.test(value.requiredSetDigest ?? '') || value.confirmationUrl !== '/?legal=confirm'
14
+ || value.grantsExecutionAuthority !== false || value.recoverable !== true
15
+ || typeof value.preAdmission !== 'boolean' || value.preAdmission !== value.knownBeforeEffects
16
+ || Buffer.byteLength(JSON.stringify(value)) > bound) return null;
17
+ return Object.fromEntries(fields.map(field => [field, value[field]]));
18
+ }
19
+ export function prerequisiteError(response, value, binding = {}) {
20
+ const prerequisite = servicePrerequisite(response, value);
21
+ if (!prerequisite) return null;
22
+ return Object.assign(new Error('The account must explicitly confirm the current service agreement on Entrorove before retrying the original request'),
23
+ { code: prerequisite.code, servicePrerequisite: prerequisite, ...binding,
24
+ knownBeforeEffects: binding.outcomeUnknown === true ? false : prerequisite.knownBeforeEffects,
25
+ grantsExecutionAuthority: false, recoverable: true });
26
+ }
27
+ export function prerequisiteFields(error) {
28
+ const value = servicePrerequisite({ status: 403 }, error?.servicePrerequisite);
29
+ return value ? { servicePrerequisite: value, grantsExecutionAuthority: false, recoverable: true,
30
+ interaction: { kind: 'confirm-account-agreement', confirmationUrl: value.confirmationUrl,
31
+ version: value.version, requiredSetDigest: value.requiredSetDigest },
32
+ ...(error.knownBeforeTransmission ? { knownBeforeTransmission: true } : {}),
33
+ ...(error.knownBeforeEffects ? { knownBeforeEffects: true } : {}) } : {};
34
+ }
35
+ export function discoveryPrerequisite(error, requestId) {
36
+ if (!servicePrerequisite({ status: 403 }, error?.servicePrerequisite)) return error;
37
+ Object.assign(error, { sourceRequestId: error.requestId, requestId, knownBeforeTransmission: true });
38
+ delete error.outcomeUnknown; return error;
39
+ }
40
+ export async function retainPrerequisite(error, state, location, save, field = 'pending') {
41
+ const pending = state[field], value = servicePrerequisite({ status: 403 }, error?.servicePrerequisite);
42
+ const identity = pending?.requestId ?? pending?.uploadId;
43
+ if (!pending || !value || !(error.knownBeforeTransmission || error.knownBeforeEffects)
44
+ || (error.requestId ?? error.uploadId) !== identity) return false;
45
+ const { servicePrerequisite: _previous, ...original } = pending;
46
+ const receipt = { originalId: identity, original: structuredClone(original), response: value, httpStatus: 403,
47
+ ...(error.sourceRequestId ? { sourceRequestId: error.sourceRequestId } : {}),
48
+ ...(error.knownBeforeTransmission ? { originalNotSent: true } : {}) };
49
+ pending.servicePrerequisite = receipt;
50
+ state.servicePrerequisiteHistory = [...(state.servicePrerequisiteHistory ?? []), receipt];
51
+ await save(location, state); return true;
52
+ }
53
+ export async function retryKnownPrerequisite(state, location, save, field = 'pending') {
54
+ const pending = state[field], receipt = pending?.servicePrerequisite;
55
+ if (!receipt) return false;
56
+ const value = servicePrerequisite({ status: receipt.httpStatus }, receipt.response);
57
+ const { servicePrerequisite: _receipt, ...original } = pending;
58
+ if (!value || receipt.originalId !== (pending.requestId ?? pending.uploadId)
59
+ || canonical(receipt.original) !== canonical(original)
60
+ || !(receipt.originalNotSent === true || value.preAdmission === true && value.knownBeforeEffects === true)) {
61
+ throw new Error('Original service prerequisite receipt is invalid; preserve its journal without retransmission');
62
+ }
63
+ // Persist a new unresolved attempt before sending. If that attempt loses its
64
+ // response, later calls must use GET even though an earlier attempt was denied.
65
+ consumeRecovery(state, 'prerequisite');
66
+ delete pending.servicePrerequisite; await save(location, state); return true;
67
+ }
68
+ export async function fetchPrerequisiteError(response, binding = {}) {
69
+ if (response.status !== 403 || !response.headers.get('content-type')?.startsWith('application/json')) return null;
70
+ const reader = response.body?.getReader(); if (!reader) return null;
71
+ const chunks = []; let size = 0;
72
+ try {
73
+ while (true) {
74
+ const { done, value } = await reader.read(); if (done) break;
75
+ size += value.byteLength; if (size > bound) return null; chunks.push(Buffer.from(value));
76
+ }
77
+ return prerequisiteError(response, JSON.parse(Buffer.concat(chunks).toString('utf8')), binding);
78
+ } catch { return null; } finally { await reader.cancel().catch(() => {}); }
79
+ }
80
+ export function streamPrerequisiteError(value, binding = {}) {
81
+ if (!['ENTROROVE_ACCOUNT_AGREEMENT_REQUIRED', 'account-agreement-required'].includes(value?.code)
82
+ || value.recoverable !== true || value.grantsExecutionAuthority !== false) return null;
83
+ const prerequisite = servicePrerequisite({ status: 403 }, value.prerequisite);
84
+ if (!prerequisite || prerequisite.preAdmission || prerequisite.knownBeforeEffects) return null;
85
+ // This is a running stream notification, not a proof about prior effects.
86
+ return prerequisiteError({ status: 403 }, prerequisite, { ...binding, outcomeUnknown: true });
87
+ }
@@ -1,6 +1,7 @@
1
1
  import { requestTransport, parseTransportResponse, trustedOrigin } from './remote-transport.mjs';
2
2
  import { runCloudTask } from './cloud-task-client.mjs';
3
3
  import { displayIdeEvent, ideClientHeaders, ideTaskIdentity } from './ide-presentation.mjs';
4
+ import { prerequisiteError } from './service-prerequisite.mjs';
4
5
 
5
6
  /** New IDE tasks use the authenticated account preference; resumes keep their original route. */
6
7
  export async function routeTaskEntry(data, token, dependencies) {
@@ -20,6 +21,8 @@ export async function routeTaskEntry(data, token, dependencies) {
20
21
  headers: { authorization: `Bearer ${token}`,...ideClientHeaders() },timeoutMs: 15000,
21
22
  });
22
23
  const preference = parseTransportResponse(response, 'cloud-sandbox preference');
24
+ const prerequisite = prerequisiteError(response, preference, { knownBeforeTransmission: true });
25
+ if (prerequisite) throw prerequisite;
23
26
  if (!response.ok || typeof preference?.enabled !== 'boolean') {
24
27
  throw new Error(`Cloud sandbox preference unavailable: HTTP ${response.status}. Task entry stopped.`);
25
28
  }
@@ -1,3 +1,4 @@
1
+ import { prerequisiteFields } from './service-prerequisite.mjs';
1
2
  import { bootstrapCloudMutationAuthority } from './cloud-mutation-authority.mjs';
2
3
  import { observationInput } from './workflow-observation-wire.mjs';
3
4
 
@@ -31,6 +32,7 @@ function blocked(error, input) {
31
32
  && Number.isSafeInteger(cause.rejection.generation) && cause.rejection.generation > 0
32
33
  && ['member-observation-admit', 'member-observation-report'].includes(cause.rejection.operation);
33
34
  return { schemaVersion: 'entrorove.local-observation-connection/1.0', status: 'blocked',
35
+ ...prerequisiteFields(cause),
34
36
  ...(safeId(input?.flowId) ? { flowId: input.flowId } : {}), independentValidation: 'pending', code,
35
37
  message: messages[code] ?? 'Local observation could not continue; its original command and result remain preserved',
36
38
  ...(recoverable ? { prerequisite: { status: 'blocked', code, recoverable: true, ...(reason ? { reason } : {}),
@@ -6,6 +6,7 @@ import { acknowledgeAutomaticFeedback, queryAutomaticFeedback, verifyAutomaticFe
6
6
  import { saveObservationState } from './workflow-observation-state.mjs';
7
7
  import { observationFail } from './workflow-observation-wire.mjs';
8
8
  import { readInvocations } from './local-invocations.mjs';
9
+ import { prerequisiteError, retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
9
10
 
10
11
  const unknown = requestId => Object.assign(new Error('Original local observation request outcome is unknown; recover its GET without repeating execution'),
11
12
  { code: 'ENTROROVE_LOCAL_OBSERVATION_UNKNOWN', outcomeUnknown: true, requestId });
@@ -25,12 +26,15 @@ async function recover(pending, dependencies) {
25
26
  response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, { headers: {
26
27
  authorization: `Bearer ${token}`, ...ideClientHeaders() } });
27
28
  result = parseTransportResponse(response, 'original local observation status');
29
+ const prerequisite = prerequisiteError(response, result, { requestId: pending.requestId });
30
+ if (prerequisite) throw Object.assign(unknown(pending.requestId), { servicePrerequisite: prerequisite.servicePrerequisite });
28
31
  if (result?.output?.requestId !== pending.requestId || !response.ok && response.status >= 500
29
32
  || ['pending', 'running'].includes(result.output.status)) throw unknown(pending.requestId);
30
33
  result.feedback ??= await queryAutomaticFeedback(pending.requestId, dependencies);
31
34
  verifyAutomaticFeedback(result.feedback, pending.requestId, pending.requestId);
32
35
  result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
33
- } catch { throw unknown(pending.requestId); }
36
+ } catch (error) { throw Object.assign(unknown(pending.requestId),
37
+ error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {}); }
34
38
  return { response, result };
35
39
  }
36
40
  function prerequisite(output) {
@@ -66,21 +70,25 @@ function terminalRejection(result, pending) {
66
70
  export async function observationRemote(stage, operation, input, state, location, dependencies) {
67
71
  const requestId = buildInvocation(operation, input).requestId;
68
72
  let response, result;
69
- if (state.pending) {
73
+ const existing = Boolean(state.pending);
74
+ if (existing) {
70
75
  if (state.pending.stage !== stage || state.pending.operation !== operation || state.pending.requestId !== requestId
71
76
  || canonical(state.pending.input) !== canonical(input)) observationFail('Original observation request must finish before another operation');
77
+ }
78
+ if (existing && !await retryKnownPrerequisite(state, location, saveObservationState)) {
72
79
  ({ response, result } = await recover(state.pending, dependencies));
73
80
  } else {
74
- state.pending = { stage, operation, input, requestId };
81
+ if (!existing) state.pending = { stage, operation, input, requestId };
75
82
  try { await saveObservationState(location, state); } catch { throw notSent(requestId); }
76
83
  try { ({ response, result } = await invokeRemoteResult({ operation, input }, dependencies)); }
77
- catch {
84
+ catch (error) {
85
+ if (await retainPrerequisite(error, state, location, saveObservationState)) throw error;
78
86
  if (await knownNotSent(state.pending, dependencies.home)) {
79
87
  delete state.pending;
80
88
  try { await saveObservationState(location, state); } catch { throw notSent(requestId); }
81
89
  throw notSent(requestId);
82
90
  }
83
- throw unknown(requestId);
91
+ throw Object.assign(unknown(requestId), error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {});
84
92
  }
85
93
  }
86
94
  const statuses = ['reported', 'succeeded', ...(operation === 'member-observation-report' ? ['local-accepted'] : [])];