@entrorove/skill 0.1.73 → 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 (65) hide show
  1. package/CAPABILITY-MAP.md +5 -0
  2. package/CHANGELOG.md +37 -0
  3. package/DESIGN-GATES.md +5 -9
  4. package/DESIGN-STAGED-ACCEPTANCE.md +103 -0
  5. package/IDE-EXECUTION.md +21 -4
  6. package/REUSE-DISCIPLINE.md +3 -3
  7. package/SKILL.md +14 -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 +8 -4
  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 +14 -1
  44. package/release-state.json +63 -50
  45. package/release.mjs +6 -1
  46. package/releases.json +30 -0
  47. package/remote-capability-cache.mjs +20 -0
  48. package/remote-client.mjs +24 -4
  49. package/scope-amendment-client.mjs +62 -6
  50. package/scope-amendment-observer.mjs +9 -3
  51. package/scope-amendment-remote.mjs +20 -6
  52. package/scope-amendment-retirement.mjs +4 -2
  53. package/scope-amendment-state.mjs +9 -5
  54. package/scope-context-client.mjs +7 -0
  55. package/scope-context-journal.mjs +8 -2
  56. package/scope-context-remote.mjs +16 -5
  57. package/scope-evidence-remote.mjs +16 -7
  58. package/scope-runner-client.mjs +34 -7
  59. package/scope-runner-journal.mjs +82 -5
  60. package/scope-write-observation.d.mts +4 -0
  61. package/scope-write-observation.mjs +43 -0
  62. package/service-prerequisite.mjs +87 -0
  63. package/task-entry-client.mjs +3 -0
  64. package/workflow-observation-client.mjs +2 -0
  65. package/workflow-observation-remote.mjs +13 -5
@@ -21,12 +21,14 @@ async function inspectFiles(location, challenge) {
21
21
  physical.add(identity);
22
22
  }
23
23
  }
24
- export async function observeScopeAmendment(location, state) {
25
- const challenge = scopeAmendmentChallenge(state.challenged.challenge, location);
24
+ export async function observeScopeAmendment(location, state, { beforeSign } = {}) {
25
+ const original = scopeAmendmentChallenge(state.challenged.challenge, location, { allowExpired: beforeSign !== undefined });
26
+ let challenge = original;
26
27
  await inspectFiles(location, challenge);
27
28
  if (!state.snapshotId) { state.snapshotId = `scope-amend-${randomUUID()}`; await saveScopeAmendmentState(location, state); }
28
29
  const input = { repositoryRoot: location.root, home: location.home, flowId: challenge.flowId, stepId: challenge.stepId,
29
30
  snapshotId: state.snapshotId, paths: challenge.files.map(file => file.path).sort(),
31
+ scopeWritePaths: challenge.files.filter(file => file.access !== 'read-only').map(file => file.path).sort(),
30
32
  contractDigest: challenge.proposalDigest, importContexts: location.contextAuthority?.record.importContexts ?? location.binding.importContexts,
31
33
  ...(location.contextAuthority ? { contextRevisionDigest: location.contextAuthority.revisionDigest,
32
34
  contextEvidence: location.contextAuthority.record.evidence } : {}),
@@ -56,7 +58,11 @@ export async function observeScopeAmendment(location, state) {
56
58
  const observed = await attestScopeFacts({ ...input, ownerId: challenge.ownerId, taskDigest: challenge.taskDigest,
57
59
  chainDigest: challenge.chainDigest, repositoryId: challenge.repositoryId, challenge: challenge.challenge,
58
60
  signerConfigPath: location.signerConfigPath }, { repositoryRoot: location.root, facts,
59
- snapshotDigest: facts.snapshotEvidence.snapshotDigest }, { maximumExpiresAt: Date.parse(challenge.expiresAt) });
61
+ snapshotDigest: facts.snapshotEvidence.snapshotDigest }, { maximumExpiresAt: Date.parse(challenge.expiresAt),
62
+ ...(beforeSign === undefined ? {} : { beforeSign: async () => {
63
+ challenge = await beforeSign();
64
+ return { challenge: challenge.challenge, maximumExpiresAt: Date.parse(challenge.expiresAt) };
65
+ } }) });
60
66
  const memberMutation = await scopeAmendmentMutation(location, state, challenge, facts);
61
67
  const core = { schemaVersion: 'entrorove.scope-revision-attestation/1.0', purpose: challenge.purpose,
62
68
  proposalId: challenge.proposalId, proposalDigest: challenge.proposalDigest, expectedRevision: challenge.expectedRevision,
@@ -7,6 +7,8 @@ import { readInvocations } from './local-invocations.mjs';
7
7
  import { saveScopeAmendmentState } from './scope-amendment-state.mjs';
8
8
  import { amendmentFail } from './scope-amendment-wire.mjs';
9
9
  import { retireMissingChallenge } from './scope-amendment-retirement.mjs';
10
+ import { prerequisiteError, retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
11
+ import { consumeRecovery } from './local-recovery-budget.mjs';
10
12
 
11
13
  const unknown = requestId => Object.assign(new Error('Scope amendment outcome is unknown; recover the original request with GET only'),
12
14
  { code: 'ENTROROVE_SCOPE_AMENDMENT_UNKNOWN', outcomeUnknown: true, requestId });
@@ -16,6 +18,8 @@ async function recover(pending, dependencies) {
16
18
  const response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, {
17
19
  headers: { authorization: `Bearer ${token}`, ...ideClientHeaders() } });
18
20
  const result = parseTransportResponse(response, 'original scope amendment status');
21
+ const prerequisite = prerequisiteError(response, result, { requestId: pending.requestId });
22
+ if (prerequisite) throw Object.assign(unknown(pending.requestId), { servicePrerequisite: prerequisite.servicePrerequisite });
19
23
  if (response.status === 404 && result?.error === 'request-not-found') {
20
24
  throw Object.assign(unknown(pending.requestId), { requestNotFound: true });
21
25
  }
@@ -25,7 +29,8 @@ async function recover(pending, dependencies) {
25
29
  verifyAutomaticFeedback(result.feedback, pending.requestId, pending.requestId);
26
30
  result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
27
31
  return { response, result };
28
- } catch (error) { throw error?.requestNotFound ? error : unknown(pending.requestId); }
32
+ } catch (error) { throw error?.requestNotFound ? error : Object.assign(unknown(pending.requestId),
33
+ error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {}); }
29
34
  }
30
35
  async function notSent(pending, home) {
31
36
  try {
@@ -36,10 +41,14 @@ async function notSent(pending, home) {
36
41
  export async function amendmentRemote(stage, operation, payload, state, location, dependencies) {
37
42
  const input = { flowId: location.identity.flowId, operation, payload }, requestId = buildInvocation('continue', input).requestId;
38
43
  let response, result;
39
- if (state.pending) {
44
+ const existing = Boolean(state.pending);
45
+ if (existing) {
40
46
  if (state.pending.stage !== stage || state.pending.requestId !== requestId || canonical(state.pending.input) !== canonical(input)) {
41
47
  amendmentFail('Recover the original amendment request before another operation');
42
48
  }
49
+ }
50
+ if (existing && !await retryKnownPrerequisite(state, location, saveScopeAmendmentState)) {
51
+ consumeRecovery(state, 'status'); await saveScopeAmendmentState(location, state);
43
52
  if (state.challengeRetirement) await retireMissingChallenge(state, location, dependencies, recover, notSent);
44
53
  try { ({ response, result } = await recover(state.pending, dependencies)); }
45
54
  catch (error) {
@@ -48,16 +57,18 @@ export async function amendmentRemote(stage, operation, payload, state, location
48
57
  ({ response, result } = await recover(state.pending, dependencies));
49
58
  }
50
59
  } else {
51
- state.pending = { stage, operation: 'continue', input, requestId };
52
- await saveScopeAmendmentState(location, state);
60
+ if (!existing) { state.pending = { stage, operation: 'continue', input, requestId };
61
+ await saveScopeAmendmentState(location, state); }
53
62
  try { ({ response, result } = await invokeRemoteResult({ operation: 'continue', input }, dependencies)); }
54
63
  catch (error) {
64
+ if (await retainPrerequisite(error, state, location, saveScopeAmendmentState)) throw error;
55
65
  if (await notSent(state.pending, dependencies.home)) {
66
+ state.rejection = { code: 'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT', effects: 'none', requestId };
56
67
  delete state.pending; await saveScopeAmendmentState(location, state);
57
68
  throw Object.assign(new Error('Scope amendment was not transmitted; repair its prerequisite and reuse the original input'),
58
69
  { code: 'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT', knownBeforeTransmission: true, requestId });
59
70
  }
60
- throw unknown(requestId);
71
+ throw Object.assign(unknown(requestId), error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {});
61
72
  }
62
73
  }
63
74
  if (!result?.output || result.output.requestId !== requestId) throw unknown(requestId);
@@ -80,5 +91,8 @@ export async function scopeAmendmentStatus(flowId, dependencies) {
80
91
  input: { flowId, operation: 'workflow-scope-status', payload: {} } }, dependencies);
81
92
  if (!response.ok || result?.output?.status !== 'succeeded') throw new Error();
82
93
  return result.output.result ?? result.output;
83
- } catch { amendmentFail('Current account cannot read its original live scope revision; no amendment mutation was sent'); }
94
+ } catch (error) {
95
+ if (error?.servicePrerequisite) throw error;
96
+ amendmentFail('Current account cannot read its original live scope revision; no amendment mutation was sent');
97
+ }
84
98
  }
@@ -2,6 +2,7 @@ import { canonical, sha256 } from './local-integrity.mjs';
2
2
  import { buildInvocation, invokeRemoteResult } from './remote-client.mjs';
3
3
  import { scopeRevisionAuthority, amendmentFail } from './scope-amendment-wire.mjs';
4
4
  import { saveScopeAmendmentState } from './scope-amendment-state.mjs';
5
+ import { retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
5
6
 
6
7
  export async function retireMissingChallenge(state, location, dependencies, recover, notSent) {
7
8
  const original = state.pending;
@@ -14,14 +15,15 @@ export async function retireMissingChallenge(state, location, dependencies, reco
14
15
  if (attempt && (attempt.requestId !== call.requestId || canonical(attempt.input) !== canonical(input))) {
15
16
  amendmentFail('Challenge retirement must retain its original preparation envelope');
16
17
  }
17
- if (!attempt) {
18
- attempt = state.challengeRetirement = { requestId: call.requestId, input, state: 'pending' };
18
+ if (!attempt || await retryKnownPrerequisite(state, location, saveScopeAmendmentState, 'challengeRetirement')) {
19
+ if (!attempt) attempt = state.challengeRetirement = { requestId: call.requestId, input, state: 'pending' };
19
20
  await saveScopeAmendmentState(location, state);
20
21
  try {
21
22
  const { result } = await invokeRemoteResult({ operation: 'scope-challenge-retire', input }, dependencies);
22
23
  attempt.output = result.output; attempt.feedback = result.feedback; attempt.state = 'completed';
23
24
  await saveScopeAmendmentState(location, state);
24
25
  } catch (error) {
26
+ if (await retainPrerequisite(error, state, location, saveScopeAmendmentState, 'challengeRetirement')) throw error;
25
27
  if (await notSent(attempt, dependencies.home)) {
26
28
  attempt.state = 'not-sent'; await saveScopeAmendmentState(location, state);
27
29
  }
@@ -10,17 +10,21 @@ import { trustedOrigin } from './remote-transport.mjs';
10
10
  import { boundScopeContexts } from './scope-context-state.mjs';
11
11
 
12
12
  const fail = message => scopeFail('ENTROROVE_SCOPE_AMENDMENT_STATE', message);
13
- const maximumStateBytes = 1_000_000;
13
+ const maximumControlBytes = 1_000_000;
14
+ // Retained scope combines original facts and verified history, not a single
15
+ // signing control. Keep its aggregate bound separate from key/config files.
16
+ const maximumRetainedScopeBytes = 8_000_000;
14
17
  // A journal retains the challenge, S proof, original pending request and full
15
18
  // signed response together. Each signed authority retains its own 1 MB bound.
16
19
  const maximumJournalBytes = 8_000_000;
17
- async function privateItem(file, final = 'file', maximumBytes = maximumStateBytes) {
20
+ async function privateItem(file, final = 'file', maximumBytes = maximumControlBytes) {
18
21
  const value = await mutationFile14(file, { final });
19
22
  if (process.getuid && value.stat.uid !== process.getuid()
20
23
  || process.platform !== 'win32' && value.stat.mode & 0o077
21
- || final === 'file' && (value.stat.nlink !== 1 || value.stat.size > maximumBytes)) {
22
- fail('Scope amendment controls must be bounded private files owned by the current OS account');
24
+ || final === 'file' && value.stat.nlink !== 1) {
25
+ fail('Scope amendment controls must be private single-link files owned by the current OS account');
23
26
  }
27
+ if (final === 'file' && value.stat.size > maximumBytes) fail(`Scope amendment file exceeds the ${maximumBytes} byte limit`);
24
28
  return value;
25
29
  }
26
30
  async function privateText(file) {
@@ -68,7 +72,7 @@ export async function scopeAmendmentLocation(repositoryRoot, input, dependencies
68
72
  if (key.asymmetricKeyType !== 'ed25519' || der.toString('base64') !== binding.publicKey
69
73
  || sha256(der) !== binding.keyId) fail('Original scope signing identity changed');
70
74
  const originalStatePath = path.join(stateRoot, 'scope', sha256(root).slice(0, 32), input.flowId, 'state.json');
71
- await privateItem(originalStatePath);
75
+ await privateItem(originalStatePath, 'file', maximumRetainedScopeBytes);
72
76
  const saved = await readMutationJson14(originalStatePath);
73
77
  const original = await retainedScope({ repositoryRoot: root, home, flowId: input.flowId,
74
78
  stepId: saved.stepId, snapshotId: saved.originalSnapshot?.snapshotId }, root);
@@ -1,3 +1,4 @@
1
+ import { prerequisiteFields } from './service-prerequisite.mjs';
1
2
  import path from 'node:path';
2
3
  import { sign } from 'node:crypto';
3
4
  import { canonical } from './local-integrity.mjs';
@@ -9,6 +10,7 @@ import { scopeContextLocation, effectiveScopeContexts, retainContextAuthority,
9
10
  import { contextRemote } from './scope-context-remote.mjs';
10
11
  import { inspectContextEvidence, observeScopeContext, prepareScopeContext } from './scope-context-observer.mjs';
11
12
  import { contextJournal, renewContextChallenge } from './scope-context-journal.mjs';
13
+ import { recoveryFailureFields } from './local-recovery-budget.mjs';
12
14
  import { saveLocalScopeEvidence, ensureScopeEvidence, validateAppliedEvidence } from './scope-evidence-remote.mjs';
13
15
 
14
16
  const schemaVersion = 'entrorove.scope-context-connection/1.0';
@@ -23,6 +25,9 @@ function proposalFor(status, input, location) {
23
25
  }
24
26
  function waiting(value, flowId) {
25
27
  const status = value.proposal?.approval?.status;
28
+ if (status === 'approved') return { ...value, schemaVersion, status: 'succeeded', flowId,
29
+ grantsExecutionAuthority: false, claimsCompletion: false, nextOperation: 'scope-context',
30
+ nextInput: { action: 'apply', flowId, proposalId: value.proposal.proposalId, proposalDigest: value.proposal.proposalDigest } };
26
31
  return { ...value, schemaVersion, status: status === 'declined' ? 'blocked' : 'needs-input', flowId,
27
32
  grantsExecutionAuthority: false, claimsCompletion: false,
28
33
  nextStep: value.nextStep ?? { operation: 'interaction-answer', requiresOriginalOwnerApproval: true,
@@ -118,9 +123,11 @@ export async function runScopeContext(repositoryRoot, input, dependencies) {
118
123
  return await withStateLock(path.join(location.base, 'connection'), () => run(location, input, dependencies));
119
124
  } catch (error) {
120
125
  return { schemaVersion, status: 'blocked', flowId: input?.flowId,
126
+ ...prerequisiteFields(error),
121
127
  code: error?.code?.startsWith('ENTROROVE_') ? error.code : 'ENTROROVE_SCOPE_CONTEXT_PREREQUISITE',
122
128
  message: error?.code?.startsWith('ENTROROVE_') ? error.message : 'Context recovery stopped; preserve original signer, binding, journal and evidence.',
123
129
  ...(error?.requestId ? { requestId: error.requestId } : {}), ...(error?.uploadId ? { uploadId: error.uploadId } : {}), ...(error?.output ? { output: error.output } : {}),
130
+ ...recoveryFailureFields(error),
124
131
  ...(error?.outcomeUnknown ? { outcomeUnknown: true, nextAction: { operation: 'scope-context', method: 'GET',
125
132
  ...(error.requestId ? { requestId: error.requestId } : { uploadId: error.uploadId }), reuseOriginalInput: true, repeatPost: false } } : {}) };
126
133
  }
@@ -5,6 +5,7 @@ import { atomicMutationJson14, readMutationJson14 } from './local-mutation-files
5
5
  import { contextFail } from './scope-context-wire.mjs';
6
6
  import { readContextJournal, saveContextJournal } from './scope-context-state.mjs';
7
7
  import { contextRemote } from './scope-context-remote.mjs';
8
+ import { consumeRecovery } from './local-recovery-budget.mjs';
8
9
 
9
10
  export async function archiveContextAttempt(location, state) {
10
11
  if (!state.attemptId) return;
@@ -20,22 +21,27 @@ export async function contextJournal(location, input) {
20
21
  let state = await readContextJournal(location, input);
21
22
  if (!state.previous && reusableSnapshot(state, input)) {
22
23
  await archiveContextAttempt(location, state);
24
+ consumeRecovery(state, 'attempt', state.signingAttemptId ?? state.attemptId);
23
25
  for (const field of ['rejection', 'checked', 'attestation', 'signingChallenge', 'signingAttemptId',
24
26
  'evidenceDescriptor', 'evidenceSignature', 'evidencePrepared', 'attestationRef', 'evidenceResult']) delete state[field];
25
27
  await saveContextJournal(location, state);
26
28
  } else if (state.previous || !state.pending && !state.evidencePending && !state.applied && (state.rejection
27
29
  || input.action === 'status' && state.completed || input.action === 'apply' && state.completed)) {
28
- await archiveContextAttempt(location, state.previous ?? state); state = {};
30
+ const previous = state.previous ?? state;
31
+ await archiveContextAttempt(location, previous);
32
+ const recovery = previous.completed ? undefined : consumeRecovery(previous, 'attempt', previous.attemptId);
33
+ state = { ...(recovery ? { recovery } : {}) };
29
34
  }
30
35
  if (!state.attemptId) {
31
36
  state = { schemaVersion: 'entrorove.scope-context-journal/1.0', localBindingDigest: location.localBindingDigest,
32
- input, attemptId: randomUUID() }; await saveContextJournal(location, state);
37
+ input, attemptId: randomUUID(), ...(state.recovery ? { recovery: state.recovery } : {}) }; await saveContextJournal(location, state);
33
38
  }
34
39
  return state;
35
40
  }
36
41
  export async function renewContextChallenge(location, state, proposal, dependencies) {
37
42
  if (state.pending || state.evidencePending) contextFail('Recover the original pending request before any challenge renewal');
38
43
  await archiveContextAttempt(location, state);
44
+ consumeRecovery(state, 'signing', state.signingAttemptId ?? state.attemptId);
39
45
  delete state.signingChallenge;
40
46
  state.signingAttemptId = randomUUID(); await saveContextJournal(location, state);
41
47
  const output = await contextRemote('signingChallenge', 'scope-runner-context-challenge', { flowId: location.binding.flowId,
@@ -6,6 +6,8 @@ import { acknowledgeAutomaticFeedback, queryAutomaticFeedback, verifyAutomaticFe
6
6
  import { readInvocations } from './local-invocations.mjs';
7
7
  import { saveContextJournal } from './scope-context-state.mjs';
8
8
  import { contextFail } from './scope-context-wire.mjs';
9
+ import { prerequisiteError, retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
10
+ import { consumeRecovery } from './local-recovery-budget.mjs';
9
11
 
10
12
  const unknown = requestId => Object.assign(new Error('Context recovery outcome is unknown; query its original request with GET only'),
11
13
  { code: 'ENTROROVE_SCOPE_CONTEXT_UNKNOWN', outcomeUnknown: true, requestId });
@@ -15,6 +17,8 @@ async function recover(pending, dependencies) {
15
17
  const response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, {
16
18
  headers: { authorization: `Bearer ${token}`, ...ideClientHeaders() } });
17
19
  const result = parseTransportResponse(response, 'original context recovery status');
20
+ const prerequisite = prerequisiteError(response, result, { requestId: pending.requestId });
21
+ if (prerequisite) throw Object.assign(unknown(pending.requestId), { servicePrerequisite: prerequisite.servicePrerequisite });
18
22
  if (result?.output?.requestId !== pending.requestId || !response.ok || ['running', 'pending'].includes(result.output.status)) throw unknown(pending.requestId);
19
23
  if (pending.operation !== 'scope-runner-context-status' || result.feedback) {
20
24
  result.feedback ??= await queryAutomaticFeedback(pending.requestId, dependencies);
@@ -22,7 +26,8 @@ async function recover(pending, dependencies) {
22
26
  result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
23
27
  }
24
28
  return { response, result };
25
- } catch { throw unknown(pending.requestId); }
29
+ } catch (error) { throw Object.assign(unknown(pending.requestId),
30
+ error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {}); }
26
31
  }
27
32
  async function notSent(pending, home) {
28
33
  try {
@@ -32,20 +37,26 @@ async function notSent(pending, home) {
32
37
  }
33
38
  export async function contextRemote(stage, operation, input, state, location, dependencies, save = saveContextJournal) {
34
39
  const requestId = buildInvocation(operation, input).requestId; let response, result;
35
- if (state.pending) {
40
+ const existing = Boolean(state.pending);
41
+ if (existing) {
36
42
  if (state.pending.stage !== stage || state.pending.requestId !== requestId || state.pending.operation !== operation
37
43
  || canonical(state.pending.input) !== canonical(input)) contextFail('Recover the original context POST before another operation');
44
+ }
45
+ if (existing && !await retryKnownPrerequisite(state, location, save)) {
46
+ consumeRecovery(state, 'status'); await save(location, state);
38
47
  ({ response, result } = await recover(state.pending, dependencies));
39
- } else {
40
- state.pending = { stage, operation, input, requestId }; await save(location, state);
48
+ }
49
+ else {
50
+ if (!existing) { state.pending = { stage, operation, input, requestId }; await save(location, state); }
41
51
  try { ({ response, result } = await invokeRemoteResult({ operation, input }, dependencies)); }
42
52
  catch (error) {
53
+ if (await retainPrerequisite(error, state, location, save)) throw error;
43
54
  if (await notSent(state.pending, dependencies.home)) {
44
55
  delete state.pending; await save(location, state);
45
56
  throw Object.assign(new Error('Context request was not transmitted; repair its prerequisite and reuse the original input'),
46
57
  { code: 'ENTROROVE_SCOPE_CONTEXT_NOT_SENT', knownBeforeTransmission: true, requestId });
47
58
  }
48
- throw unknown(requestId);
59
+ throw Object.assign(unknown(requestId), error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {});
49
60
  }
50
61
  }
51
62
  if (!result?.output || result.output.requestId !== requestId) throw unknown(requestId);
@@ -4,6 +4,7 @@ import { readScopeEvidence, retainScopeEvidence } from './scope-evidence-local.m
4
4
  import { describeScopeEvidence, evidenceBytes, evidenceFail, evidenceUpload, evidenceReference } from './scope-evidence-wire.mjs';
5
5
  import { requestTransport, putScopeEvidence, parseTransportResponse } from './remote-transport.mjs';
6
6
  import { ideClientHeaders } from './ide-presentation.mjs';
7
+ import { prerequisiteError, retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
7
8
 
8
9
  const unknown = uploadId => Object.assign(new Error('Scope evidence upload outcome is unknown; recover the original reservation with GET only'),
9
10
  { code: 'ENTROROVE_SCOPE_EVIDENCE_UNKNOWN', outcomeUnknown: true, uploadId });
@@ -24,10 +25,15 @@ function resultReference(value, upload, descriptor, location) {
24
25
  if (['ownerId', 'taskDigest', 'chainDigest'].some(field => reference[field] !== upload[field])) evidenceFail('Stored scope evidence replaced its original account or task');
25
26
  return reference;
26
27
  }
27
- async function acceptResult(response, upload, state, location, save) {
28
+ async function acceptResult(response, upload, state, location, save, sentPut = false) {
28
29
  let value;
29
30
  try { value = parseTransportResponse(response, 'scope evidence upload'); }
30
31
  catch { throw unknown(upload.uploadId); }
32
+ const prerequisite = prerequisiteError(response, value, { uploadId: upload.uploadId });
33
+ if (prerequisite) {
34
+ if (sentPut && await retainPrerequisite(prerequisite, state, location, save, 'evidencePending')) throw prerequisite;
35
+ throw Object.assign(unknown(upload.uploadId), { servicePrerequisite: prerequisite.servicePrerequisite });
36
+ }
31
37
  const reference = resultReference(value, upload, state.evidenceDescriptor, location);
32
38
  if (reference) {
33
39
  state.attestationRef = reference; state.evidenceResult = value; delete state.evidencePending;
@@ -51,22 +57,25 @@ export async function ensureScopeEvidence(state, location, dependencies, challen
51
57
  const upload = evidenceUpload(state.evidencePrepared.upload, descriptor, location, challenge);
52
58
  const token = await dependencies.readCredential(dependencies.home), headers = { authorization: `Bearer ${token}`, ...ideClientHeaders() };
53
59
  const origin = dependencies.apiOrigin();
54
- if (state.evidencePending) {
60
+ const existing = Boolean(state.evidencePending);
61
+ if (existing) {
55
62
  if (state.evidencePending.uploadId !== upload.uploadId || state.evidencePending.sha256 !== descriptor.sha256
56
63
  || state.evidencePending.canonicalBytes !== descriptor.canonicalBytes) evidenceFail('Recover the exact original evidence reservation first');
64
+ }
65
+ if (existing && !await retryKnownPrerequisite(state, location, save, 'evidencePending')) {
57
66
  let response;
58
67
  try { response = await requestTransport(origin, `/api/skill/evidence/${upload.uploadId}`, { headers }); }
59
68
  catch { throw unknown(upload.uploadId); }
60
69
  return acceptResult(response, upload, state, location, save);
61
70
  }
62
- state.evidencePending = { uploadId: upload.uploadId, sha256: descriptor.sha256, canonicalBytes: descriptor.canonicalBytes };
63
- await save(location, state);
64
- let response;
71
+ if (!existing) { state.evidencePending = { uploadId: upload.uploadId, sha256: descriptor.sha256, canonicalBytes: descriptor.canonicalBytes };
72
+ await save(location, state); }
73
+ let response; const sentPut = Date.parse(upload.expiresAt) > Date.now();
65
74
  try {
66
- if (Date.parse(upload.expiresAt) <= Date.now()) response = await requestTransport(origin, `/api/skill/evidence/${upload.uploadId}`, { headers });
75
+ if (!sentPut) response = await requestTransport(origin, `/api/skill/evidence/${upload.uploadId}`, { headers });
67
76
  else response = await putScopeEvidence(origin, upload.uploadId, evidenceBytes(value), headers);
68
77
  } catch { throw unknown(upload.uploadId); }
69
- return acceptResult(response, upload, state, location, save);
78
+ return acceptResult(response, upload, state, location, save, sentPut);
70
79
  }
71
80
  export async function validateAppliedEvidence(record, state, location, challenge, kind) {
72
81
  const reference = evidenceReference(kind === 'scope-context' ? record.attestationRef : record.candidateAttestationRef,
@@ -1,3 +1,4 @@
1
+ import { prerequisiteFields } from './service-prerequisite.mjs';
1
2
  import { createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from 'node:crypto';
2
3
  import { constants, promises as fs } from 'node:fs';
3
4
  import path from 'node:path';
@@ -6,10 +7,12 @@ import { scopeFail, scopePath, scopeRoot, scopeTarget } from './local-scope-file
6
7
  import { withStateLock } from './local-state-lock.mjs';
7
8
  import { snapshotPlan, snapshotVerify } from './local-execution.mjs';
8
9
  import { probe } from './local-probe.mjs';
10
+ import { recoveryFailureFields } from './local-recovery-budget.mjs';
9
11
  import { contextRemote } from './scope-context-remote.mjs';
10
- import { scopeRunnerJournal, saveScopeRunnerJournal } from './scope-runner-journal.mjs';
12
+ import { scopeRunnerJournal, saveScopeRunnerJournal, renewScopeRunnerChallenge, validateScopeRunnerChallenge } from './scope-runner-journal.mjs';
11
13
  import { saveLocalScopeEvidence, ensureScopeEvidence } from './scope-evidence-remote.mjs';
12
14
  import { boundScopeContexts } from './scope-context-state.mjs';
15
+ import { mutationFile14 } from './local-mutation-files.mjs';
13
16
 
14
17
  async function privateDirectory(directory) {
15
18
  await fs.mkdir(directory, { recursive: true, mode: 0o700 });
@@ -68,8 +71,26 @@ async function scopeConnectionResult(identity, binding, accepted) {
68
71
  runnerId: binding.runnerId, scopeEvidence: accepted.scopeEvidence, grantsExecutionAuthority: false,
69
72
  nextOperation: next.operation, ...(resumeOriginalRequest ? { resumeOriginalRequest: true } : { nextInput }), nextStep: next };
70
73
  }
71
- async function observeConnection(identity, location, state, challenge, contextAuthority, dependencies) {
72
- if (state.evidenceDescriptor) return;
74
+ async function verifyConnectionSnapshot(input) {
75
+ try { return await snapshotVerify(input); }
76
+ catch (error) {
77
+ if (error.code !== 'ENOENT') throw error;
78
+ const directory = path.join(input.home, '.entrorove', 'snapshots', sha256(input.repositoryRoot).slice(0, 32), input.flowId, input.snapshotId);
79
+ const existing = await mutationFile14(directory, { allowMissing: true, final: 'directory' });
80
+ if (!existing.exists) scopeFail('ENTROROVE_SCOPE_SNAPSHOT_MISSING', 'The original scope snapshot directory is unavailable; preserve its journal before another attempt');
81
+ if (!(await mutationFile14(path.join(directory, 'manifest.json'), { allowMissing: true })).exists) {
82
+ scopeFail('ENTROROVE_SCOPE_SNAPSHOT_INTERRUPTED', 'The original scope snapshot was interrupted before its manifest; preserve the partial files and repeat this exact original input to archive its rejected attempt');
83
+ }
84
+ throw error;
85
+ }
86
+ }
87
+ async function observeConnection(identity, location, state, original, contextAuthority, dependencies, remote) {
88
+ let challenge = (state.signingChallenge ?? state.challenged).challenge;
89
+ validateScopeRunnerChallenge(challenge, original, identity, location.binding, { allowExpired: true });
90
+ if (state.evidenceDescriptor) {
91
+ if (state.evidenceDescriptor.challengeId !== challenge.challenge) scopeFail('ENTROROVE_SCOPE_BINDING', 'Recover evidence against its original signing challenge');
92
+ return challenge;
93
+ }
73
94
  const input = { repositoryRoot: identity.repositoryRoot, home: dependencies.home, flowId: identity.flowId,
74
95
  stepId: challenge.stepId, paths: identity.targetPaths, snapshotId: state.snapshotId, importContexts: identity.importContexts,
75
96
  ...(contextAuthority ? { contextRevisionDigest: contextAuthority.revisionDigest, contextEvidence: contextAuthority.record.evidence } : {}) };
@@ -78,13 +99,17 @@ async function observeConnection(identity, location, state, challenge, contextAu
78
99
  try { await snapshotPlan(input); }
79
100
  catch (error) { delete state.snapshotStarted; await saveScopeRunnerJournal(location, state); throw error; }
80
101
  }
81
- const verified = await snapshotVerify(input);
102
+ const verified = await verifyConnectionSnapshot(input);
82
103
  if (verified.verified !== true) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual copied snapshot did not verify');
83
104
  const observed = await probe({ ...input, ownerId: challenge.ownerId, task: challenge.task, taskDigest: challenge.taskDigest,
84
105
  chainDigest: challenge.chainDigest, repositoryId: challenge.repositoryId, challenge: challenge.challenge, signerConfigPath: location.signerConfigPath },
85
- { maximumExpiresAt: Date.parse(challenge.expiresAt) });
106
+ { beforeSign: async () => {
107
+ challenge = await renewScopeRunnerChallenge(identity, location, state, original, remote);
108
+ return { challenge: challenge.challenge, maximumExpiresAt: Date.parse(challenge.expiresAt) };
109
+ } });
86
110
  if (observed.attestation.snapshotDigest !== verified.receipt.snapshotDigest) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual S proof replaced its original verified snapshot');
87
111
  await saveLocalScopeEvidence(observed.attestation, 'scope-observation', challenge, state, location, saveScopeRunnerJournal);
112
+ return challenge;
88
113
  }
89
114
  async function connect(identity, directory, dependencies) {
90
115
  const local = await signer(directory, identity, dependencies), { binding, key, contextAuthority } = local;
@@ -112,8 +137,8 @@ async function connect(identity, directory, dependencies) {
112
137
  }
113
138
  if (!state.snapshotId) { state.snapshotId = `snapshot-${randomUUID()}`; await saveScopeRunnerJournal(location, state); }
114
139
  if (!state.accepted) {
115
- await observeConnection(identity, location, state, challenge, contextAuthority, dependencies);
116
- const attestationRef = await ensureScopeEvidence(state, location, dependencies, challenge, saveScopeRunnerJournal);
140
+ const signingChallenge = await observeConnection(identity, location, state, challenge, contextAuthority, dependencies, remote);
141
+ const attestationRef = await ensureScopeEvidence(state, location, dependencies, signingChallenge, saveScopeRunnerJournal);
117
142
  await remote('accepted', 'scope-runner-attest', { flowId: identity.flowId, attestationRef });
118
143
  }
119
144
  state.completed = true; await saveScopeRunnerJournal(location, state);
@@ -159,7 +184,9 @@ export async function runScopeConnect(repositoryRoot, input, dependencies) {
159
184
  catch (error) {
160
185
  if (!error.code?.startsWith('ENTROROVE_')) throw error;
161
186
  return { schemaVersion: 'entrorove.scope-connection/1.0', status: 'blocked', flowId: input?.flowId,
187
+ ...prerequisiteFields(error),
162
188
  code: error.code, message: error.message, ...(error.output ? { output: error.output } : {}),
189
+ ...recoveryFailureFields(error),
163
190
  ...(error.outcomeUnknown ? { outcomeUnknown: true, nextAction: { method: 'GET', operation: 'scope-connect',
164
191
  ...(error.requestId ? { requestId: error.requestId } : { uploadId: error.uploadId }), reuseOriginalInput: true, repeatPost: false } } : {}) };
165
192
  }
@@ -3,6 +3,8 @@ import { randomUUID } from 'node:crypto';
3
3
  import { canonical, sha256 } from './local-integrity.mjs';
4
4
  import { readMutationJson14, atomicMutationJson14, mutationFile14 } from './local-mutation-files.mjs';
5
5
  import { evidenceFail } from './scope-evidence-wire.mjs';
6
+ import { scopeFail } from './local-scope-files.mjs';
7
+ import { consumeRecovery } from './local-recovery-budget.mjs';
6
8
 
7
9
  export async function saveScopeRunnerJournal(location, state) {
8
10
  if (Buffer.byteLength(canonical(state)) + 1 > 8_000_000) evidenceFail('Scope connection journal exceeds its unchanged bounded control limit');
@@ -18,11 +20,19 @@ export async function scopeRunnerJournal(location, identity) {
18
20
  evidenceFail('Scope connection journal replaced its original task, repository or complete input');
19
21
  }
20
22
  } catch (error) { if (error.code !== 'ENOENT') throw error; }
21
- if (state && !state.pending && !state.evidencePending && (state.completed || state.rejection)) {
22
- const archive = path.join(location.directory, `${state.attemptId}-${sha256(canonical(state))}.json`);
23
- try { if (canonical(await readMutationJson14(archive)) !== canonical(state)) evidenceFail('Scope connection history changed'); }
24
- catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(archive, state, { privateFrom: location.stateRoot }); }
25
- state = undefined;
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);
26
36
  }
27
37
  if (!state) {
28
38
  state = { schemaVersion: 'entrorove.scope-connection-journal/1.0', identity, attemptId: randomUUID() };
@@ -30,3 +40,70 @@ export async function scopeRunnerJournal(location, identity) {
30
40
  }
31
41
  return state;
32
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
+ }