@entrorove/skill 0.1.62 → 0.1.67

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 (89) hide show
  1. package/CAPABILITY-MAP.md +30 -2
  2. package/CHANGELOG.md +25 -0
  3. package/DELIVERY-BOUNDARIES.md +2 -0
  4. package/DESIGN-GATES.md +160 -0
  5. package/IDE-EXECUTION.md +32 -3
  6. package/REUSE-DISCIPLINE.md +16 -2
  7. package/SKILL.md +9 -3
  8. package/blueprint-criterion-runner.mjs +1 -1
  9. package/cli-input.mjs +22 -0
  10. package/cli.mjs +13 -12
  11. package/cloud-task-client.mjs +10 -2
  12. package/cloud-task-images.mjs +39 -0
  13. package/design-browser-checks.mjs +86 -0
  14. package/design-browser-identity.mjs +13 -0
  15. package/design-browser-state.mjs +41 -0
  16. package/design-browser.mjs +181 -0
  17. package/design-context.mjs +34 -0
  18. package/design-contracts.d.mts +4 -0
  19. package/design-contracts.mjs +77 -0
  20. package/design-declaration.mjs +17 -0
  21. package/design-frameworks.mjs +4 -0
  22. package/design-gate.mjs +19 -0
  23. package/design-lint.mjs +115 -0
  24. package/design-local-contracts.mjs +15 -0
  25. package/design-operations.mjs +81 -0
  26. package/design-policy.d.mts +5 -0
  27. package/design-policy.mjs +137 -0
  28. package/design-release.mjs +25 -0
  29. package/design-research.mjs +99 -0
  30. package/design-runner.mjs +31 -0
  31. package/design-store.mjs +116 -0
  32. package/design-ui.mjs +24 -0
  33. package/ide-exit.mjs +49 -9
  34. package/ide-hook.mjs +2 -2
  35. package/install-identity.mjs +16 -0
  36. package/local-cli.mjs +3 -1
  37. package/local-contracts.mjs +6 -1
  38. package/local-discipline-actions.mjs +5 -0
  39. package/local-discipline-batch.mjs +12 -3
  40. package/local-discipline-contracts.mjs +15 -3
  41. package/local-discipline-gate.mjs +17 -2
  42. package/local-discipline-repository.mjs +11 -5
  43. package/local-error.mjs +18 -1
  44. package/local-execution.mjs +2 -1
  45. package/local-finder-plist.mjs +87 -0
  46. package/local-finder-store.mjs +115 -0
  47. package/local-mutation-authority.mjs +13 -0
  48. package/local-mutation-completion-evidence.mjs +83 -0
  49. package/local-mutation-consumption.mjs +33 -10
  50. package/local-mutation-environment-observation.mjs +85 -0
  51. package/local-mutation-finder-observation.mjs +58 -0
  52. package/local-mutation-wire.mjs +26 -8
  53. package/local-runner-discipline.mjs +17 -3
  54. package/local-scope-files.mjs +3 -1
  55. package/local-scope-font.mjs +45 -0
  56. package/local-scope-repository.mjs +16 -3
  57. package/local-scope-zip.mjs +114 -0
  58. package/manifest.json +1 -1
  59. package/member-producer-repair-authority.mjs +48 -0
  60. package/model-images.d.mts +9 -0
  61. package/model-images.mjs +87 -0
  62. package/mutation-challenge-refresh.mjs +51 -0
  63. package/mutation-client.mjs +42 -24
  64. package/mutation-connection-state.mjs +17 -0
  65. package/package-archive.mjs +54 -0
  66. package/package-replacement.mjs +48 -0
  67. package/package.json +50 -6
  68. package/release-state.json +87 -45
  69. package/release.mjs +11 -1
  70. package/releases.json +30 -0
  71. package/remote-client.mjs +1 -1
  72. package/remote-transport.mjs +48 -16
  73. package/reuse-discipline-release.mjs +3 -1
  74. package/rollback-history.mjs +51 -0
  75. package/rollback-runner.mjs +77 -0
  76. package/scope-amendment-client.mjs +1 -1
  77. package/scope-amendment-consumption.mjs +32 -0
  78. package/scope-amendment-mutation.mjs +2 -3
  79. package/scope-amendment-observer.mjs +2 -1
  80. package/scope-amendment-remote.mjs +12 -2
  81. package/scope-amendment-retirement.mjs +54 -0
  82. package/scope-amendment-state.mjs +7 -4
  83. package/task-entry-client.mjs +1 -0
  84. package/version.mjs +20 -3
  85. package/workflow-local-runner.mjs +9 -0
  86. package/workflow-observation-client.mjs +11 -0
  87. package/workflow-observation-connection.mjs +2 -1
  88. package/workflow-observation-remote.mjs +26 -1
  89. package/workflow-observation-wire.mjs +6 -3
@@ -2,11 +2,14 @@ import { canonical, sha256 } from './local-integrity.mjs';
2
2
  import { disciplineRepositoryObservation, disciplineRepositoryDifferences } from './local-discipline-repository.mjs';
3
3
  import { atomicMutationJson14, privateMutationDirectory14, mutationStateRoot14, readMutationJson14 } from './local-mutation-files.mjs';
4
4
  import path from 'node:path';
5
+ import { designRunnerBefore, designRunnerAfter } from './design-runner.mjs';
6
+ import { replacementRunnerBefore, replacementRunnerAfter } from './rollback-runner.mjs';
5
7
 
6
8
  export async function runnerDisciplineBefore(input, authority) {
7
- if (!authority.plan.policy.discipline) return null;
9
+ const packageOriginals = await replacementRunnerBefore(input, authority);
10
+ const designOriginals = await designRunnerBefore(input, authority);
8
11
  const original = await disciplineRepositoryObservation(input, authority.root);
9
- return { input, original: original.comparison, readOnly: authority.plan.policy.discipline.access === 'read-only' };
12
+ return { input, designOriginals, packageOriginals, original: original.comparison, readOnly: authority.plan.policy.discipline?.access === 'read-only' };
10
13
  }
11
14
  export async function runnerDisciplineAfter(before, authority, processResult) {
12
15
  if (!before) return null;
@@ -28,5 +31,16 @@ export async function runnerDisciplineAfter(before, authority, processResult) {
28
31
  { code: 'ENTROROVE_READ_ONLY_EXECUTION_DRIFT', execution: { schemaVersion: 'entrorove.validator-local-runner/1.0',
29
32
  status: 'failed', process: processResult, evidence: [], effects: 'unknown', comparison: report } });
30
33
  }
31
- return { ...report, reportDigest };
34
+ try {
35
+ await replacementRunnerAfter(before.input, authority, before.original, after, changed, before.packageOriginals);
36
+ await designRunnerAfter(before.input, authority, changed, before.designOriginals);
37
+ }
38
+ catch (error) {
39
+ error.execution = { schemaVersion: 'entrorove.validator-local-runner/1.0', status: 'failed', process: processResult,
40
+ evidence: [], effects: 'unknown', comparison: report };
41
+ throw error;
42
+ }
43
+ // Keep legacy non-discipline subjects compatible with their frozen Validator
44
+ // schema; the actual comparison and UI refusal above still ran and persisted.
45
+ return authority.plan.policy.discipline ? { ...report, reportDigest } : null;
32
46
  }
@@ -2,6 +2,8 @@ import { constants, promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { canonical, sha256 } from './local-integrity.mjs';
4
4
  import { readBudgetedProjectFile } from './local-budget-reads.mjs';
5
+ import { verifiedSfnt } from './local-scope-font.mjs';
6
+ import { verifiedZip } from './local-scope-zip.mjs';
5
7
 
6
8
  export function scopeFail(code, message) { throw Object.assign(new Error(message), { code }); }
7
9
  export function observedScopeRisk(files) {
@@ -135,7 +137,7 @@ export async function scopeFiles(root, requested, knownFiles = []) {
135
137
  return [...found].sort();
136
138
  }
137
139
  export function lineCount(content) {
138
- if (!content.length) return 0;
140
+ if (!content.length || verifiedSfnt(content) || verifiedZip(content)) return 0;
139
141
  return content.toString('utf8').split(/\r\n|\n|\r/).length - (/[\r\n]$/.test(content.toString('utf8')) ? 1 : 0);
140
142
  }
141
143
  export async function packageBoundary(root, relative, input = { repositoryRoot: root }) {
@@ -0,0 +1,45 @@
1
+ function checksum(bytes, start, length, adjustment = -1) {
2
+ let sum = 0;
3
+ for (let offset = start; offset < start + length; offset += 4) {
4
+ let word = 0;
5
+ for (let i = 0; i < 4; i++) word = word * 256 + (offset + i < start + length ? bytes[offset + i] : 0);
6
+ if (offset !== adjustment) sum = (sum + word) >>> 0;
7
+ }
8
+ return sum;
9
+ }
10
+ // Recognize an sfnt container from its complete bytes, never its name. Unknown
11
+ // or damaged data keeps the conservative text count; a magic prefix is no proof.
12
+ export function verifiedSfnt(bytes) {
13
+ if (bytes.length < 28 || ![0x4f54544f, 0x00010000].includes(bytes.readUInt32BE(0))) return false;
14
+ const count = bytes.readUInt16BE(4), end = 12 + count * 16;
15
+ if (!count || count > 4096 || end > bytes.length) return false;
16
+ const selector = Math.floor(Math.log2(count)), search = 16 * 2 ** selector;
17
+ if (bytes.readUInt16BE(6) !== search || bytes.readUInt16BE(8) !== selector
18
+ || bytes.readUInt16BE(10) !== count * 16 - search) return false;
19
+ const tables = []; let previous = ''; let head;
20
+ for (let index = 12; index < end; index += 16) {
21
+ if (bytes.subarray(index, index + 4).some(byte => byte < 0x20 || byte > 0x7e)) return false;
22
+ const tag = bytes.toString('ascii', index, index + 4);
23
+ if (!/^[\x20-\x7e]{4}$/.test(tag) || tag <= previous) return false;
24
+ previous = tag;
25
+ const offset = bytes.readUInt32BE(index + 8), length = bytes.readUInt32BE(index + 12);
26
+ if (offset % 4 || offset < end || offset + length > bytes.length) return false;
27
+ const adjustment = tag === 'head' ? offset + 8 : -1;
28
+ if (tag === 'head') {
29
+ if (length !== 54 || bytes.readUInt32BE(offset) !== 0x00010000
30
+ || bytes.readUInt32BE(offset + 12) !== 0x5f0f3cf5) return false;
31
+ head = offset;
32
+ }
33
+ if (checksum(bytes, offset, length, adjustment) !== bytes.readUInt32BE(index + 4)) return false;
34
+ tables.push({ offset, length });
35
+ }
36
+ if (head === undefined || checksum(bytes, 0, bytes.length) !== 0xb1b0afba) return false;
37
+ let covered = end;
38
+ for (const table of tables.sort((a, b) => a.offset - b.offset)) {
39
+ if (table.offset < covered) return false;
40
+ for (; covered < table.offset; covered++) if (bytes[covered] !== 0) return false;
41
+ covered = table.offset + table.length;
42
+ }
43
+ for (; covered < bytes.length; covered++) if (bytes[covered] !== 0) return false;
44
+ return true;
45
+ }
@@ -264,17 +264,20 @@ async function configMetadata(root, relative, input) {
264
264
  async function resolveLocalImport(root, relative, required = true) {
265
265
  if (relative.startsWith('../') || relative === '..') scopeFail('ENTROROVE_PATH_UNSAFE', 'local import escapes the repository');
266
266
  const replacements = /\.js$/.test(relative) ? [relative.replace(/\.js$/, '.ts'), relative.replace(/\.js$/, '.tsx')] : [];
267
- for (const candidate of [relative, ...replacements, ...['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.json', '.d.ts', '.d.mts', '.d.cts', '/index.js', '/index.ts', '/index.d.ts'].map(ext => relative + ext)]) {
267
+ const candidates = [relative, ...replacements, ...['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.json', '.d.ts', '.d.mts', '.d.cts', '/index.js', '/index.ts', '/index.d.ts'].map(ext => relative + ext)];
268
+ for (const candidate of candidates) {
268
269
  const item = await scopeProbeTarget(root, candidate, dependencyReference(candidate));
269
270
  if (item.exists && item.stat.isFile()) return candidate;
270
271
  }
271
- if (required) scopeFail('ENTROROVE_IMPORT_UNRESOLVED', 'a local import has no resolvable source file');
272
+ if (required) throw Object.assign(new Error('a local import has no resolvable source file'),
273
+ { code: 'ENTROROVE_IMPORT_UNRESOLVED', missingCandidates: candidates });
272
274
  return null;
273
275
  }
274
276
  export async function importGraph(root, originals, input = { repositoryRoot: root }, limits = {}) {
275
277
  const pending = originals.filter(file => file.exists !== false).map(file => file.path); const visited = new Map(); const edges = []; let dynamic = false;
276
278
  let sourceBytes = 0;
277
279
  const dynamicSources = [], unresolvedImports = [];
280
+ const pendingCreates = new Set(originals.filter(file => file.exists === false && input.scopeCreatePaths?.includes(file.path)).map(file => file.path));
278
281
  const workspace = await workspaceIndex(root, input);
279
282
  const metadata = new Map(workspace.metadata.map(item => [item.path, item]));
280
283
  while (pending.length) {
@@ -300,7 +303,17 @@ export async function importGraph(root, originals, input = { repositoryRoot: roo
300
303
  unresolvedImports.push(...imports.unresolvedImports.map(site => ({ path: relative, ...site })));
301
304
  if (imports.dynamic) dynamicSources.push({ path: relative, sha256: read.sha256 });
302
305
  for (const specifier of imports.specifiers.sort()) {
303
- const target = await resolveImport(root, relative, specifier, workspace, input, aliases, configPath, declarations);
306
+ let target;
307
+ try { target = await resolveImport(root, relative, specifier, workspace, input, aliases, configPath, declarations); }
308
+ catch (error) {
309
+ const missing = error.code === 'ENTROROVE_IMPORT_UNRESOLVED' && specifier.startsWith('.')
310
+ ? error.missingCandidates?.filter(candidate => pendingCreates.has(candidate)) : null;
311
+ if (!missing?.length) throw error;
312
+ unresolvedImports.push({ path: relative, specifier, reason: 'approved-create-not-present', candidatePaths: missing });
313
+ dynamic = true;
314
+ if (!dynamicSources.some(source => source.path === relative)) dynamicSources.push({ path: relative, sha256: read.sha256 });
315
+ continue;
316
+ }
304
317
  edges.push({ from: relative, to: target, external: target === null, specifier,
305
318
  ...(declarations.has(specifier) ? { declarationOnly: true, declarationPath: relative } : {}) });
306
319
  if (target !== null && !visited.has(target)) pending.push(target);
@@ -0,0 +1,114 @@
1
+ import { inflateRawSync } from 'node:zlib';
2
+
3
+ const MAX_ENTRY_BYTES = 16 * 1024 * 1024, MAX_TOTAL_BYTES = 64 * 1024 * 1024;
4
+ const crcTable = Uint32Array.from({ length: 256 }, (_, value) => {
5
+ for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
6
+ return value >>> 0;
7
+ });
8
+ function crc32(bytes) {
9
+ let value = 0xffffffff;
10
+ for (const byte of bytes) value = crcTable[(value ^ byte) & 255] ^ (value >>> 8);
11
+ return (value ^ 0xffffffff) >>> 0;
12
+ }
13
+ function plainExtra(bytes) {
14
+ let offset = 0;
15
+ while (offset + 4 <= bytes.length) {
16
+ const kind = bytes.readUInt16LE(offset), size = bytes.readUInt16LE(offset + 2);
17
+ if ([1, 0x9901].includes(kind) || offset + 4 + size > bytes.length) return false;
18
+ offset += 4 + size;
19
+ }
20
+ return offset === bytes.length;
21
+ }
22
+ function directory(bytes) {
23
+ const floor = Math.max(0, bytes.length - 65557); let end = bytes.length - 22;
24
+ for (; end >= floor; end--) {
25
+ if (bytes.readUInt32LE(end) === 0x06054b50 && end + 22 + bytes.readUInt16LE(end + 20) === bytes.length) break;
26
+ }
27
+ if (end < floor || bytes.readUInt16LE(end + 4) || bytes.readUInt16LE(end + 6)) return null;
28
+ const count = bytes.readUInt16LE(end + 10), offset = bytes.readUInt32LE(end + 16), size = bytes.readUInt32LE(end + 12);
29
+ if (count > 10_000 || count !== bytes.readUInt16LE(end + 8) || offset + size !== end) return null;
30
+ const entries = []; let cursor = offset, total = 0;
31
+ for (let index = 0; index < count; index++) {
32
+ if (cursor + 46 > end || bytes.readUInt32LE(cursor) !== 0x02014b50) return null;
33
+ const nameLength = bytes.readUInt16LE(cursor + 28), extraLength = bytes.readUInt16LE(cursor + 30);
34
+ const next = cursor + 46 + nameLength + extraLength + bytes.readUInt16LE(cursor + 32);
35
+ const flags = bytes.readUInt16LE(cursor + 8), method = bytes.readUInt16LE(cursor + 10);
36
+ const compressed = bytes.readUInt32LE(cursor + 20), expanded = bytes.readUInt32LE(cursor + 24);
37
+ if (!nameLength || next > end || flags & ~0x080e || ![0, 8].includes(method) || bytes.readUInt16LE(cursor + 34)
38
+ || compressed === 0xffffffff || expanded > MAX_ENTRY_BYTES || (total += expanded) > MAX_TOTAL_BYTES
39
+ || !plainExtra(bytes.subarray(cursor + 46 + nameLength, cursor + 46 + nameLength + extraLength))) return null;
40
+ entries.push({ central: cursor, local: bytes.readUInt32LE(cursor + 42), flags, method, compressed, expanded,
41
+ crc: bytes.readUInt32LE(cursor + 16), name: bytes.subarray(cursor + 46, cursor + 46 + nameLength) });
42
+ cursor = next;
43
+ }
44
+ return cursor === end ? { entries, offset } : null;
45
+ }
46
+ function descriptorEnd(bytes, offset, entry, limit) {
47
+ for (const length of [16, 12]) {
48
+ const start = offset + length - 12;
49
+ if (offset + length > limit || length === 16 && bytes.readUInt32LE(offset) !== 0x08074b50) continue;
50
+ if (bytes.readUInt32LE(start) === entry.crc && bytes.readUInt32LE(start + 4) === entry.compressed
51
+ && bytes.readUInt32LE(start + 8) === entry.expanded) return offset + length;
52
+ }
53
+ return -1;
54
+ }
55
+ function entryEnd(bytes, entry, limit) {
56
+ const start = entry.local;
57
+ if (start + 30 > limit || bytes.readUInt32LE(start) !== 0x04034b50) return -1;
58
+ const nameLength = bytes.readUInt16LE(start + 26), extraLength = bytes.readUInt16LE(start + 28);
59
+ const data = start + 30 + nameLength + extraLength, end = data + entry.compressed;
60
+ if (end > limit || !bytes.subarray(start + 30, start + 30 + nameLength).equals(entry.name)
61
+ || !plainExtra(bytes.subarray(start + 30 + nameLength, data))) return -1;
62
+ for (const [local, central] of [[4, 6], [6, 8], [8, 10], [10, 12], [12, 14]]) {
63
+ if (bytes.readUInt16LE(start + local) !== bytes.readUInt16LE(entry.central + central)) return -1;
64
+ }
65
+ for (const [field, expected] of [[14, entry.crc], [18, entry.compressed], [22, entry.expanded]]) {
66
+ const actual = bytes.readUInt32LE(start + field);
67
+ if (actual !== expected && (!(entry.flags & 8) || actual !== 0)) return -1;
68
+ }
69
+ const compressed = bytes.subarray(data, end); let expanded;
70
+ if (entry.method === 0) expanded = compressed;
71
+ else {
72
+ try {
73
+ const result = inflateRawSync(compressed, { maxOutputLength: Math.max(1, entry.expanded), info: true });
74
+ if (result.engine.bytesWritten !== compressed.length) return -1;
75
+ expanded = result.buffer;
76
+ } catch { return -1; }
77
+ }
78
+ if (expanded.length !== entry.expanded || crc32(expanded) !== entry.crc) return -1;
79
+ return entry.flags & 8 ? descriptorEnd(bytes, end, entry, limit) : end;
80
+ }
81
+ // PKWARE APPNOTE 6.3.10, 4.3: verify complete ordinary ZIP records and CRCs
82
+ // in bounded memory. Never extract, execute entries or grant their paths access.
83
+ export function verifiedZip(bytes) {
84
+ if (bytes.length < 22 || ![0x04034b50, 0x06054b50].includes(bytes.readUInt32LE(0))) return false;
85
+ const found = directory(bytes); if (!found) return false;
86
+ let covered = 0;
87
+ for (const entry of found.entries.sort((a, b) => a.local - b.local)) {
88
+ if (entry.local !== covered) return false;
89
+ covered = entryEnd(bytes, entry, found.offset);
90
+ if (covered < 0) return false;
91
+ }
92
+ return covered === found.offset;
93
+ }
94
+ // Metadata inspection reuses the complete CRC/length validation above; no entry
95
+ // is written or executed. Invalid names, duplicates and links remain unknown.
96
+ export function zipMetadataEntries(bytes) {
97
+ if (!verifiedZip(bytes)) return null;
98
+ const found = directory(bytes), result = new Map();
99
+ for (const entry of found.entries) {
100
+ let name;
101
+ try { name = new TextDecoder('utf-8', { fatal: true }).decode(entry.name); } catch { return null; }
102
+ const mode = bytes.readUInt32LE(entry.central + 38) >>> 16, type = mode & 0o170000;
103
+ if (!name || name.startsWith('/') || /[\\\u0000-\u001f\u007f]/.test(name) || /^[A-Za-z]:/.test(name)
104
+ || name.split('/').some(part => part === '..' || part === '.') || result.has(name)
105
+ || type && ![0o100000, 0o040000].includes(type)) return null;
106
+ const start = entry.local + 30 + bytes.readUInt16LE(entry.local + 26) + bytes.readUInt16LE(entry.local + 28);
107
+ const compressed = bytes.subarray(start, start + entry.compressed);
108
+ const metadata = /(?:^|\/)(?:package|manifest)\.json$/.test(name);
109
+ if (metadata && entry.expanded > 262_144) return null;
110
+ result.set(name, metadata ? entry.method === 0 ? compressed
111
+ : inflateRawSync(compressed, { maxOutputLength: Math.max(1, entry.expanded) }) : null);
112
+ }
113
+ return result;
114
+ }
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.62",
4
+ "version": "0.1.67",
5
5
  "endpoint": "https://entrorove.com",
6
6
  "requestSchemaVersion": "entrorove.skill.request/2.0",
7
7
  "supportedRequestSchemaVersions": [
@@ -0,0 +1,48 @@
1
+ import { canonical } from './local-integrity.mjs';
2
+ import { observationAuthoritySignature } from './workflow-observation-wire.mjs';
3
+
4
+ const keys = ['schemaVersion', 'repairId', 'flowId', 'ownerId', 'taskId', 'stepId', 'leaseId', 'generation', 'leaseDigest',
5
+ 'planId', 'planDigest', 'executionPlanSha256', 'failedObservationId', 'failureResultDigest', 'previousAcceptanceDigest',
6
+ 'kind', 'paths', 'dependencyRepairDigest', 'failureStepId', 'baselineSettlementId', 'baselineSettlementDigest',
7
+ 'grantId', 'sequence', 'previousGrantDigest', 'scopeDigest', 'requestId', 'issuedAt', 'expiresAt', 'authorityReceipt'];
8
+ const identity = ['flowId', 'ownerId', 'taskId', 'stepId', 'leaseId', 'generation', 'planId', 'planDigest', 'executionPlanSha256'];
9
+ const shas = ['leaseDigest', 'planDigest', 'executionPlanSha256', 'failureResultDigest', 'previousAcceptanceDigest', 'scopeDigest', 'requestId'];
10
+ export function memberProducerRepairStepMatches(status, authority, { settledPass = false } = {}) {
11
+ try {
12
+ const values = status?.producerRepairAuthorities;
13
+ if (!Array.isArray(values) || values.length > 128) return false;
14
+ const matching = values.filter(p => p.stepId === authority.stepId);
15
+ if (matching.length !== 1) return false;
16
+ const p = matching[0], expected = [...keys, ...(Object.hasOwn(p, 'scopeRevisionDigest') ? ['scopeRevisionDigest'] : [])];
17
+ if (canonical(Object.keys(p).sort()) !== canonical(expected.sort())) return false;
18
+ observationAuthoritySignature(p);
19
+ const start = Date.parse(p.issuedAt), end = Date.parse(p.expiresAt), at = Date.parse(authority.issuedAt);
20
+ if (p.schemaVersion !== 'entrorove.member-producer-repair/1.0' || identity.some(k => p[k] !== authority[k])
21
+ || status.flow?.stepId !== p.failureStepId || status.flow.ownerId !== p.ownerId
22
+ || status.flow.flowId !== p.flowId || status.flow.status !== 'running' || status.workflow?.phase !== 'execute'
23
+ || status.workflow.terminalAt || ['pending', 'declined'].includes(status.workflow.authorization)
24
+ || !['producer-correction', 'dependency-revalidation'].includes(p.kind)
25
+ || shas.some(k => !/^[a-f0-9]{64}$/.test(p[k])) || authority.leaseDigest && authority.leaseDigest !== p.leaseDigest
26
+ || !Array.isArray(p.paths) || p.paths.length > 512 || new Set(p.paths).size !== p.paths.length
27
+ || canonical(p.paths) !== canonical([...p.paths].sort()) || (p.kind === 'producer-correction') !== (p.paths.length > 0)
28
+ || p.paths.some(path => typeof path !== 'string' || !path || path.startsWith('/') || /[\\:\u0000-\u001f\u007f]/.test(path)
29
+ || path.split('/').some(part => !part || part === '.' || part === '..'))
30
+ || !Number.isSafeInteger(p.sequence) || p.sequence < 1 || !Number.isSafeInteger(p.generation) || p.generation < 1
31
+ || ['grantId', 'repairId', 'failedObservationId', 'failureStepId'].some(k => typeof p[k] !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(p[k]))
32
+ || (p.sequence === 1 ? p.previousGrantDigest !== null : !/^[a-f0-9]{64}$/.test(p.previousGrantDigest))
33
+ || !Number.isFinite(start) || !Number.isFinite(end) || start > Date.now() || end <= start || end - start > 600000
34
+ || !Number.isFinite(at) || at < start || at >= end || Date.parse(authority.expiresAt) > end
35
+ || !settledPass && end <= Date.now()
36
+ || (p.dependencyRepairDigest !== null && !/^[a-f0-9]{64}$/.test(p.dependencyRepairDigest))
37
+ || (Object.hasOwn(p, 'scopeRevisionDigest') && p.scopeRevisionDigest !== authority.scopeRevisionDigest
38
+ && Object.hasOwn(authority, 'scopeRevisionDigest'))) return false;
39
+ if (settledPass && (authority.verificationMode !== 'member-local' || authority.runnerKeyId !== null
40
+ || !authority.writeDelegation || p.kind !== 'producer-correction' || !Array.isArray(authority.paths)
41
+ || !authority.paths.length || authority.paths.some(path => !p.paths.includes(path)))) return false;
42
+ return true;
43
+ } catch { return false; }
44
+ }
45
+ export function memberProducerRepairDependency(status, pass) {
46
+ if (!memberProducerRepairStepMatches(status, pass, { settledPass: true })) return null;
47
+ return status.producerRepairAuthorities.find(p => p.stepId === pass.stepId).dependencyRepairDigest;
48
+ }
@@ -0,0 +1,9 @@
1
+ export type ModelImageInput = { url: string; detail?: 'auto'|'low'|'high'|'original' };
2
+ export type ModelImageMetadata = { sha256: string; mimeType: string; bytes: number; detail: 'auto'|'low'|'high'|'original' };
3
+ export type ModelImage = ModelImageInput & ModelImageMetadata;
4
+ export const MODEL_IMAGE_LIMITS: Readonly<{count: number; perImageBytes: number; totalBytes: number; requestBytes: number; maxSide: number}>;
5
+ export function inspectModelImage(bytes: Buffer): Omit<ModelImageMetadata,'detail'>;
6
+ export function normalizeModelImages(value?: unknown): ModelImage[];
7
+ export function modelImageManifest(images: readonly ModelImage[]): ModelImageMetadata[];
8
+ export function modelUserContent(goal: string, images: readonly ModelImage[]): string | Array<
9
+ { type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string; detail: ModelImageMetadata['detail'] } }>;
@@ -0,0 +1,87 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export const MODEL_IMAGE_LIMITS = Object.freeze({ count: 4, perImageBytes: 2 * 1024 * 1024,
4
+ totalBytes: 8 * 1024 * 1024, requestBytes: 12 * 1024 * 1024, maxSide: 8192 });
5
+ const DETAILS = new Set(['auto', 'low', 'high', 'original']);
6
+ const invalid = () => new Error('Images require bounded JPEG, PNG, GIF or WebP content with valid dimensions');
7
+
8
+ function jpegSize(bytes) {
9
+ let offset = 2;
10
+ while (offset + 4 <= bytes.length) {
11
+ if (bytes[offset++] !== 255) throw invalid();
12
+ while (bytes[offset] === 255) offset++;
13
+ if (offset + 3 > bytes.length) throw invalid();
14
+ const marker = bytes[offset++];
15
+ if (marker === 0xda || marker === 0xd9) break;
16
+ const length = bytes.readUInt16BE(offset);
17
+ if (length < 2 || offset + length > bytes.length) throw invalid();
18
+ if ([0xc0,0xc1,0xc2,0xc3,0xc5,0xc6,0xc7,0xc9,0xca,0xcb,0xcd,0xce,0xcf].includes(marker)) {
19
+ if (length < 8) throw invalid();
20
+ return [bytes.readUInt16BE(offset + 5), bytes.readUInt16BE(offset + 3)];
21
+ }
22
+ offset += length;
23
+ }
24
+ throw invalid();
25
+ }
26
+
27
+ function webpSize(bytes) {
28
+ if (bytes.length < 30 || bytes.readUInt32LE(4) + 8 !== bytes.length) throw invalid();
29
+ const chunk = bytes.toString('ascii', 12, 16), length = bytes.readUInt32LE(16);
30
+ if (20 + length + (length % 2) > bytes.length) throw invalid();
31
+ if (chunk === 'VP8X' && length === 10) return [bytes.readUIntLE(24, 3) + 1, bytes.readUIntLE(27, 3) + 1];
32
+ if (chunk === 'VP8 ' && length >= 10 && bytes.subarray(23,26).equals(Buffer.from([0x9d,1,0x2a]))) {
33
+ return [bytes.readUInt16LE(26) & 0x3fff, bytes.readUInt16LE(28) & 0x3fff];
34
+ }
35
+ if (chunk === 'VP8L' && length >= 5 && bytes[20] === 0x2f) {
36
+ const packed = bytes.readUInt32LE(21);
37
+ return [(packed & 0x3fff) + 1, ((packed >>> 14) & 0x3fff) + 1];
38
+ }
39
+ throw invalid();
40
+ }
41
+
42
+ /** Inspect content headers and dimensions; the provider still decodes the actual image. */
43
+ export function inspectModelImage(bytes) {
44
+ if (!Buffer.isBuffer(bytes) || bytes.length < 26 || bytes.length > MODEL_IMAGE_LIMITS.perImageBytes) throw invalid();
45
+ let mimeType, size;
46
+ if (bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10]))
47
+ && bytes.readUInt32BE(8) === 13 && bytes.toString('ascii',12,16) === 'IHDR') {
48
+ mimeType = 'image/png'; size = [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
49
+ } else if (bytes[0] === 255 && bytes[1] === 216) {
50
+ mimeType = 'image/jpeg'; size = jpegSize(bytes);
51
+ } else if (['GIF87a','GIF89a'].includes(bytes.toString('ascii',0,6))) {
52
+ mimeType = 'image/gif'; size = [bytes.readUInt16LE(6), bytes.readUInt16LE(8)];
53
+ } else if (bytes.toString('ascii',0,4) === 'RIFF' && bytes.toString('ascii',8,12) === 'WEBP') {
54
+ mimeType = 'image/webp'; size = webpSize(bytes);
55
+ } else throw invalid();
56
+ if (size.some(value => !Number.isInteger(value) || value < 1 || value > MODEL_IMAGE_LIMITS.maxSide)) throw invalid();
57
+ return { mimeType, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') };
58
+ }
59
+
60
+ export function normalizeModelImages(value = []) {
61
+ if (!Array.isArray(value) || value.length > MODEL_IMAGE_LIMITS.count) throw new Error('At most four input images are supported');
62
+ let total = 0;
63
+ return value.map(image => {
64
+ if (!image || typeof image !== 'object' || Array.isArray(image)
65
+ || Object.keys(image).some(key => !['url','detail'].includes(key))
66
+ || typeof image.url !== 'string' || !DETAILS.has(image.detail ?? 'auto')) throw invalid();
67
+ if (image.url.length > Math.ceil(MODEL_IMAGE_LIMITS.perImageBytes / 3) * 4 + 64) throw invalid();
68
+ const match = /^data:(image\/(?:jpeg|png|gif|webp));base64,([A-Za-z0-9+/]+={0,2})$/.exec(image.url);
69
+ if (!match) throw new Error('Input images must use inline base64 data URLs; remote URLs are not fetched');
70
+ const bytes = Buffer.from(match[2], 'base64');
71
+ if (bytes.toString('base64') !== match[2]) throw invalid();
72
+ const metadata = inspectModelImage(bytes);
73
+ if (metadata.mimeType !== match[1]) throw new Error('Image media type differs from its actual content');
74
+ total += bytes.length;
75
+ if (total > MODEL_IMAGE_LIMITS.totalBytes) throw new Error('Input images exceed the total byte limit');
76
+ return Object.freeze({ url: image.url, detail: image.detail ?? 'auto', ...metadata });
77
+ });
78
+ }
79
+
80
+ export function modelImageManifest(images) {
81
+ return images.map(({ sha256, mimeType, bytes, detail }) => ({ sha256, mimeType, bytes, detail }));
82
+ }
83
+
84
+ export function modelUserContent(goal, images) {
85
+ return images.length ? [{ type: 'text', text: goal }, ...images.map(image => ({ type: 'image_url',
86
+ image_url: { url: image.url, detail: image.detail } }))] : goal;
87
+ }
@@ -0,0 +1,51 @@
1
+ import { canonical, sha256 } from './local-integrity.mjs';
2
+ import { buildInvocation } from './remote-client.mjs';
3
+ import { mutationFail14 } from './local-mutation-files.mjs';
4
+
5
+ const fail = message => mutationFail14('ENTROROVE_MUTATION_CHALLENGE_REFRESH', message);
6
+ export async function refreshExpiredMutationChallenge(context, callbacks) {
7
+ const { state, identity, mutator, location, dependencies, fields } = context;
8
+ const { remote, save, challengeBinding } = callbacks, old = state.challenged.challenge;
9
+ if (state.issued || state.gatePassPath || Date.parse(old.expiresAt) > Date.now()) return state.challenged;
10
+ challengeBinding(old, identity, mutator, { live: false });
11
+ if (old.verificationMode !== 'member-local' || !old.writeDelegation || old.runnerKeyId !== null) {
12
+ fail('Expired challenge recovery requires the original member-local editing authority');
13
+ }
14
+ if (!state.refreshOriginal && state.attestation) {
15
+ const input = { challengeId: old.challengeId, attestation: state.attestation };
16
+ if (!state.pending) {
17
+ state.pending = { stage: 'issued', operation: 'mutation-pass-issue', input,
18
+ requestId: buildInvocation('mutation-pass-issue', input).requestId };
19
+ await save(location, state);
20
+ }
21
+ if (state.pending.stage === 'issued') {
22
+ try { await remote('issued', 'mutation-pass-issue', input, state, location, dependencies); }
23
+ catch (error) {
24
+ if (state.pending || error.output?.status !== 'blocked'
25
+ || !['ENTROROVE_MUTATION_LEASE', 'ENTROROVE_MUTATION_EXPIRED'].includes(error.output.code)
26
+ || state.feedback?.acknowledgement?.stored !== true) throw error;
27
+ }
28
+ if (state.issued) return state.challenged;
29
+ }
30
+ }
31
+ if (state.pending && state.pending.stage !== 'refreshed') fail('Recover only the original pending mutation request before challenge refresh');
32
+ if (!Array.isArray(state.attempts ?? []) || (state.attempts?.length ?? 0) >= 512) fail('Original mutation challenge history reached its bound');
33
+ if (!state.refreshOriginal) {
34
+ state.refreshOriginal = Object.fromEntries(['challenged', 'planned', 'planStarted', 'attestation', 'feedback', 'failures']
35
+ .filter(key => Object.hasOwn(state, key)).map(key => [key, structuredClone(state[key])]));
36
+ await save(location, state);
37
+ }
38
+ if (canonical(state.refreshOriginal.challenged.challenge) !== canonical(old)) fail('Original archived challenge binding changed');
39
+ const ref = { challengeId: old.challengeId, challengeDigest: sha256(canonical(old)) };
40
+ const input = { ...Object.fromEntries(fields.map(field => [field, identity[field]])),
41
+ ...(identity.previousPassId ? { previousPassId: identity.previousPassId } : {}),
42
+ mutationPublicKey: mutator.mutationPublicKey, refreshExpiredChallenge: ref };
43
+ const refreshed = state.refreshed ?? await remote('refreshed', 'mutation-snapshot-challenge', input, state, location, dependencies);
44
+ challengeBinding(refreshed.challenge, identity, mutator, { live: false });
45
+ if (canonical(refreshed.challenge.previousChallenge) !== canonical(ref)) fail('Fresh challenge lost its exact expired predecessor');
46
+ state.attempts = [...(state.attempts ?? []), { ...state.refreshOriginal, archivedAt: new Date().toISOString() }];
47
+ for (const key of ['challenged', 'planned', 'planStarted', 'attestation', 'failures', 'refreshOriginal', 'refreshed']) delete state[key];
48
+ state.challenged = refreshed;
49
+ await save(location, state);
50
+ return refreshed;
51
+ }
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { canonical, sha256 } from './local-integrity.mjs';
3
3
  import { withStateLock } from './local-state-lock.mjs';
4
4
  import { mutationId14, mutationPaths14, mutationTarget14, mutationFail14, privateMutationDirectory14,
5
- readMutationJson14, atomicMutationJson14 } from './local-mutation-files.mjs';
5
+ atomicMutationJson14 } from './local-mutation-files.mjs';
6
6
  import { preparePreservedMutationSigner, preservedMutationLocation } from './local-preserved-mutation-signer.mjs';
7
7
  import { snapshotPlan14, snapshotVerify14 } from './local-mutation-snapshot.mjs';
8
8
  import { attestMutationSnapshot14 } from './local-mutation-binding.mjs';
@@ -17,8 +17,10 @@ import { appendInvocation, readInvocations } from './local-invocations.mjs';
17
17
  import { bootstrapCloudMutationAuthority } from './cloud-mutation-authority.mjs';
18
18
  import { attestMutationConsumption14, preservedMutationConsumptionState } from './local-mutation-consumption.mjs';
19
19
  import { scopeAmendmentRefreshAction } from './scope-amendment-reference.mjs';
20
+ import { readMutationConnection as readConnection } from './mutation-connection-state.mjs';
21
+ import { readMutationEnvironmentObservation } from './local-mutation-environment-observation.mjs';
22
+ import { refreshExpiredMutationChallenge } from './mutation-challenge-refresh.mjs';
20
23
 
21
- const schema = 'entrorove.mutation-connection-state/1.0';
22
24
  const fields = ['taskId', 'flowId', 'stepId', 'leaseId', 'generation', 'runnerId', 'paths'];
23
25
  const fail = message => mutationFail14('ENTROROVE_MUTATION_CONNECTION_BINDING', message);
24
26
  const unknown = requestId => Object.assign(new Error(`Mutation connection outcome is unknown; recover original requestId=${requestId} without repeating its POST`),
@@ -36,18 +38,6 @@ function inputIdentity(input) {
36
38
  if (Object.hasOwn(input, 'previousPassId')) mutationId14(input.previousPassId, 'previousPassId');
37
39
  return { ...input, paths: mutationPaths14(input.paths) };
38
40
  }
39
- async function readConnection(location, identity) {
40
- let value;
41
- try { value = await readMutationJson14(location.file); }
42
- catch (error) { if (error.code !== 'ENOENT') throw error; }
43
- if (!value) return { schemaVersion: schema, identity };
44
- if (value.schemaVersion !== schema || canonical(value.identity) !== canonical(identity)
45
- || value.pending && (value.pending.requestId !== buildInvocation(value.pending.operation, value.pending.input).requestId
46
- || !['mutation-snapshot-challenge', 'mutation-pass-issue', 'mutation-pass-settle'].includes(value.pending.operation))) {
47
- fail('Mutation connection journal differs from the original repository, request or physical key');
48
- }
49
- return value;
50
- }
51
41
  async function recoverRemote(pending, dependencies) {
52
42
  try {
53
43
  const token = await dependencies.readCredential(dependencies.home), origin = trustedOrigin(dependencies.apiOrigin());
@@ -90,7 +80,11 @@ async function remote(stage, operation, input, state, location, dependencies) {
90
80
  }
91
81
  }
92
82
  delete state.pending;
83
+ if (result.feedback) state.feedback = result.feedback;
93
84
  if (!response.ok || !['succeeded', 'reported'].includes(result?.output?.status)) {
85
+ const failure = { operation, requestId, code: result?.output?.code ?? null, ...(result?.output ? { output: result.output } : {}),
86
+ ...(result.feedback ? { feedback: result.feedback } : {}) };
87
+ if (canonical(state.failures?.at(-1) ?? null) !== canonical(failure)) state.failures = [...(state.failures ?? []), failure];
94
88
  try { await save(location, state); } catch { throw unknown(requestId); }
95
89
  throw Object.assign(new Error(result?.output?.message ?? `Mutation connection blocked at ${operation}`),
96
90
  { code: result?.output?.code ?? 'ENTROROVE_MUTATION_CONNECTION_BLOCKED', output: result?.output, requestId });
@@ -147,17 +141,21 @@ async function authorizeCachedConnection(identity, pass, dependencies) {
147
141
  mutationFail14('ENTROROVE_MUTATION_CONNECTION_AUTHORIZATION', 'Current account could not confirm the original active mutation flow');
148
142
  }
149
143
  if (!response.ok || status?.schemaVersion !== 'entrorove.flow-status/1.0' || status.flow?.flowId !== identity.flowId
150
- || status.flow.ownerId !== pass.ownerId || status.flow.stepId !== identity.stepId || status.flow.status !== 'running'
144
+ || status.flow.ownerId !== pass.ownerId || status.flow.stepId !== identity.stepId
145
+ && !memberProducerRepairStepMatches(status, pass, { settledPass: true }) || status.flow.status !== 'running'
151
146
  || !['execute', 'merge', 'deliver'].includes(status.workflow?.phase) || status.workflow.terminalAt
152
147
  || ['pending', 'declined'].includes(status.workflow.authorization)) {
153
148
  mutationFail14('ENTROROVE_MUTATION_CONNECTION_AUTHORIZATION', 'Current account must retain the original owned active flow and business step');
154
149
  }
150
+ return status;
155
151
  }
156
152
  async function connect(identity, local, mutator, location, dependencies) {
157
153
  const state = await readConnection(location, identity);
158
- const challenged = state.challenged ?? await remote('challenged', 'mutation-snapshot-challenge',
154
+ let challenged = state.challenged ?? await remote('challenged', 'mutation-snapshot-challenge',
159
155
  { ...Object.fromEntries(fields.map(field => [field, identity[field]])),
160
156
  ...(identity.previousPassId ? { previousPassId: identity.previousPassId } : {}), mutationPublicKey: mutator.mutationPublicKey }, state, location, dependencies);
157
+ challenged = await refreshExpiredMutationChallenge({ state, identity, mutator, location, dependencies, fields },
158
+ { remote, save, challengeBinding });
161
159
  const challenge = challenged.challenge;
162
160
  challengeBinding(challenge, identity, mutator, { live: !state.gatePassPath });
163
161
  const input = { ...local, snapshotId: challenge.snapshotId };
@@ -167,9 +165,14 @@ async function connect(identity, local, mutator, location, dependencies) {
167
165
  if (canonical(stored) !== canonical(state.issued.pass)) fail('Installed mutation pass differs from its original connection');
168
166
  await authorizeCachedConnection(identity, stored, dependencies);
169
167
  const consumption = await preservedMutationConsumptionState({ ...input, gatePassPath: state.gatePassPath });
170
- if (consumption.complete) return { schemaVersion: 'entrorove.mutation-connection/1.0', status: 'blocked', flowId: identity.flowId,
171
- code: 'ENTROROVE_MUTATION_SETTLEMENT_REQUIRED', gatePassPath: state.gatePassPath,
172
- message: 'Original physical writes are consumed; settle their actual M receipt with fresh S evidence before requesting the next restricted pass',
168
+ const expiredIncomplete = !consumption.complete && Date.parse(stored.expiresAt) <= Date.now();
169
+ const hasWrites = Object.values(consumption.ledger.writes).some(value => value.status === 'written');
170
+ if (consumption.complete || expiredIncomplete) return { schemaVersion: 'entrorove.mutation-connection/1.0', status: 'blocked', flowId: identity.flowId,
171
+ code: expiredIncomplete ? hasWrites ? 'ENTROROVE_MUTATION_PARTIAL_SETTLEMENT_REQUIRED' : 'ENTROROVE_MUTATION_UNUSED_SETTLEMENT_REQUIRED'
172
+ : 'ENTROROVE_MUTATION_SETTLEMENT_REQUIRED', gatePassPath: state.gatePassPath,
173
+ message: expiredIncomplete && !hasWrites
174
+ ? 'Close only the original expired unused pass with fresh S and exact unchanged unwritten targets. Zero writes do not complete work or authorize tests; unknown pending effects remain refused'
175
+ : 'Settle only the actual original completed writes with fresh S. Preserve original protection, failed records and any exact unchanged unwritten targets; unknown pending effects remain refused',
173
176
  localAction: await scopeAmendmentRefreshAction({ ...input, gatePassPath: state.gatePassPath }, stored) ?? { command: 'npx @entrorove/skill mutation-settle <absolute repositoryRoot>',
174
177
  input: { taskId: stored.taskId, flowId: stored.flowId, stepId: stored.stepId, gatePassPath: state.gatePassPath } } };
175
178
  passBinding(state.issued.pass, challenge, state.attestation);
@@ -226,7 +229,7 @@ async function settleMutation(repositoryRoot, input, dependencies) {
226
229
  await bootstrapCloudMutationAuthority(dependencies);
227
230
  const local = { ...input, repositoryRoot, home: dependencies.home }, original = await preservedMutationConsumptionState(local);
228
231
  const { pass, binding } = original;
229
- await authorizeCachedConnection({ flowId: pass.flowId, stepId: pass.stepId }, pass, dependencies);
232
+ const flowStatus = await authorizeCachedConnection({ flowId: pass.flowId, stepId: pass.stepId }, pass, dependencies);
230
233
  const directory = path.join(binding.directory, 'settlements'); await privateMutationDirectory14(directory, { privateFrom: binding.stateRoot });
231
234
  const location = { ...binding, file: path.join(directory, `${pass.passId}.json`) };
232
235
  const identity = { flowId: pass.flowId, stepId: pass.stepId, passDigest: sha256(canonical(pass)),
@@ -250,17 +253,31 @@ async function settleMutation(repositoryRoot, input, dependencies) {
250
253
  }
251
254
  settlementBinding(output, pass, state.consumptionReceipt, state.settlementId);
252
255
  if (state.settlementId === undefined) { state.settlementId = output.settlementId; await save(location, state); }
256
+ const remaining = state.consumptionReceipt.unwritten?.map(file => file.path);
257
+ const environmentObservation = await readMutationEnvironmentObservation(local, pass);
253
258
  return { schemaVersion: 'entrorove.mutation-settlement/1.0', status: 'reported', settled: true,
254
259
  flowId: pass.flowId, taskId: pass.taskId, passId: pass.passId, settlementId: output.settlementId,
255
260
  consumptionReceiptDigest: output.consumptionReceiptDigest,
261
+ ...(environmentObservation ? { environmentObservation, environmentObservationAcknowledgedByServer: false } : {}),
256
262
  claimsVerifiedExecution: false, claimsCompletion: false, ...(state.feedback ? { feedback: state.feedback } : {}),
257
- observationAction: { command: 'npx @entrorove/skill workflow-observe-step <absolute repositoryRoot>', independentValidation: 'pending',
258
- input: { taskId: pass.taskId, flowId: pass.flowId, stepId: pass.stepId, leaseId: pass.leaseId,
259
- generation: pass.generation, settlementId: output.settlementId } },
263
+ ...(remaining ? { partial: true, unused: state.consumptionReceipt.writes.length === 0,
264
+ writtenPaths: state.consumptionReceipt.writes.map(file => file.path), unwrittenPaths: remaining } : {}),
265
+ ...(!remaining ? { observationAction: { command: 'npx @entrorove/skill workflow-observe-step <absolute repositoryRoot>', independentValidation: 'pending',
266
+ input: settlementObservationInput(output, pass, flowStatus) } } : {}),
260
267
  localAction: { command: 'npx @entrorove/skill mutation-connect <absolute repositoryRoot>',
261
- input: { ...Object.fromEntries(fields.map(field => [field, pass[field]])), previousPassId: pass.passId } } };
268
+ input: { ...Object.fromEntries(fields.map(field => [field, pass[field]])), ...(remaining ? { paths: remaining } : {}), previousPassId: pass.passId } } };
262
269
  });
263
270
  }
271
+ function settlementObservationInput(output, pass, flowStatus) {
272
+ const original = Object.fromEntries(['taskId', 'flowId', 'stepId', 'leaseId', 'generation'].map(k => [k, pass[k]]));
273
+ original.settlementId = output.settlementId;
274
+ const dependencyRepairDigest = memberProducerRepairDependency(flowStatus, pass);
275
+ const input = output.observationInput ?? { ...original, ...(dependencyRepairDigest ? { dependencyRepairDigest } : {}) };
276
+ const { dependencyRepairDigest: dependency, ...identity } = input;
277
+ if (canonical(identity) !== canonical(original) || dependency !== undefined && !/^[a-f0-9]{64}$/.test(dependency)
278
+ || dependencyRepairDigest && dependency !== dependencyRepairDigest) fail('Observation guidance lost its original settled identity or accepted ancestor repair');
279
+ return input;
280
+ }
264
281
  async function runMutationClient(operation, repositoryRoot, input, dependencies, work) {
265
282
  let preflight = true;
266
283
  try {
@@ -290,3 +307,4 @@ async function runMutationClient(operation, repositoryRoot, input, dependencies,
290
307
  }
291
308
  export const runMutationConnect = (repositoryRoot, input, dependencies) => runMutationClient('mutation-connect', repositoryRoot, input, dependencies, connectMutation);
292
309
  export const runMutationSettle = (repositoryRoot, input, dependencies) => runMutationClient('mutation-settle', repositoryRoot, input, dependencies, settleMutation);
310
+ import { memberProducerRepairStepMatches, memberProducerRepairDependency } from './member-producer-repair-authority.mjs';