@entrorove/skill 0.1.73 → 0.1.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CAPABILITY-MAP.md +7 -2
  2. package/CHANGELOG.md +51 -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 +10 -6
  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-approved-process.mjs +5 -1
  31. package/local-cli.mjs +40 -1
  32. package/local-contracts.mjs +1 -1
  33. package/local-discipline-repository.mjs +4 -2
  34. package/local-invocations.mjs +6 -0
  35. package/local-mutation-files.mjs +13 -5
  36. package/local-probe.mjs +8 -4
  37. package/local-recovery-budget.mjs +31 -0
  38. package/local-scope-repository.mjs +18 -3
  39. package/local-scope-signature.mjs +2 -2
  40. package/local-source-execution.mjs +47 -0
  41. package/manifest.json +1 -1
  42. package/mutation-challenge-refresh.mjs +3 -0
  43. package/mutation-client.mjs +17 -5
  44. package/native-client.mjs +4 -1
  45. package/package.json +16 -2
  46. package/release-source-groups.mjs +2 -0
  47. package/release-state.json +70 -55
  48. package/release.mjs +6 -1
  49. package/releases.json +48 -0
  50. package/remote-capability-cache.mjs +20 -0
  51. package/remote-client.mjs +24 -4
  52. package/scope-amendment-client.mjs +62 -6
  53. package/scope-amendment-observer.mjs +9 -3
  54. package/scope-amendment-remote.mjs +20 -6
  55. package/scope-amendment-retirement.mjs +4 -2
  56. package/scope-amendment-state.mjs +9 -5
  57. package/scope-context-client.mjs +7 -0
  58. package/scope-context-journal.mjs +8 -2
  59. package/scope-context-remote.mjs +16 -5
  60. package/scope-evidence-remote.mjs +16 -7
  61. package/scope-runner-client.mjs +39 -8
  62. package/scope-runner-journal.mjs +82 -5
  63. package/scope-write-observation.d.mts +4 -0
  64. package/scope-write-observation.mjs +43 -0
  65. package/service-prerequisite.mjs +87 -0
  66. package/task-entry-client.mjs +3 -0
  67. package/workflow-observation-client.mjs +3 -0
  68. package/workflow-observation-connection.mjs +4 -1
  69. package/workflow-observation-interruption.mjs +12 -0
  70. package/workflow-observation-recovery.mjs +42 -0
  71. package/workflow-observation-remote.mjs +13 -5
  72. package/workflow-observation-runner.mjs +13 -6
@@ -1,43 +1,23 @@
1
- import https from 'node:https';
2
- import dns from 'node:dns/promises';
3
1
  import net from 'node:net';
4
2
  import path from 'node:path';
5
3
  import { parse as yaml } from 'yaml';
6
4
  import { mutationRead14, mutationTarget14 } from './local-mutation-files.mjs';
7
5
  import { sha256 } from './local-integrity.mjs';
8
6
  import { designFail } from './design-policy.mjs';
7
+ import { publicResearchAddresses, researchResponse } from './design-research-network.mjs';
9
8
 
10
9
  import { FRAMEWORK_DOC_HOSTS } from './design-frameworks.mjs';
11
10
  export { FRAMEWORK_DOC_HOSTS } from './design-frameworks.mjs';
12
11
  export function researchUrl(value) {
13
12
  let url; try { url = new URL(value); } catch { designFail('RESEARCH_URL', 'Research requires an absolute public HTTPS URL'); }
14
13
  if (url.protocol !== 'https:' || url.username || url.password || url.port && url.port !== '443' || url.hash
15
- || net.isIP(url.hostname) || /(^|\.)(localhost|local|internal|test|invalid)$/i.test(url.hostname)
14
+ || net.isIP(url.hostname.replace(/^\[|\]$/g, '')) || /(^|\.)(localhost|local|internal|test|invalid)$/i.test(url.hostname)
16
15
  || [...url.searchParams.keys()].some(key => /token|secret|password|key|signature|credential/i.test(key))) designFail('RESEARCH_URL', 'Research URLs must be public, credential-free HTTPS references');
17
16
  return url;
18
17
  }
19
- function publicIp(address) {
20
- if (net.isIP(address) !== 4) return false;
21
- const [a, b, c] = address.split('.').map(Number);
22
- return ![0, 10, 127].includes(a) && a < 224 && !(a === 169 && b === 254)
23
- && !(a === 172 && b >= 16 && b <= 31) && !(a === 192 && (b === 168 || b === 0 && [0, 2].includes(c)))
24
- && !(a === 198 && ([18, 19].includes(b) || b === 51 && c === 100)) && !(a === 203 && b === 0 && c === 113)
25
- && !(a === 100 && b >= 64 && b <= 127);
26
- }
27
18
  export async function fetchDesignReference(value, redirects = 0) {
28
- const url = researchUrl(value), addresses = await dns.lookup(url.hostname, { all: true, family: 4 });
29
- if (!addresses.length || addresses.some(item => !publicIp(item.address))) designFail('RESEARCH_URL', 'Research hostname resolves outside the public internet');
30
- const result = await new Promise((resolve, reject) => {
31
- const req = https.get(url, { headers: { accept: 'text/html,text/plain,application/json', 'user-agent': 'Entrorove design research' },
32
- lookup: (_host, options, callback) => callback(null, options.all ? [addresses[0]] : addresses[0].address, 4) }, response => {
33
- const chunks = []; let bytes = 0;
34
- response.on('data', chunk => { bytes += chunk.length; if (bytes > 2_097_152) req.destroy(Object.assign(new Error('Research response exceeds 2 MiB'), { code: 'ENTROROVE_DESIGN_RESEARCH_SIZE' })); else chunks.push(chunk); });
35
- response.on('end', () => resolve({ status: response.statusCode, location: response.headers.location, type: response.headers['content-type'], body: Buffer.concat(chunks) }));
36
- response.on('error', reject);
37
- });
38
- req.setTimeout(15_000, () => req.destroy(Object.assign(new Error('Research fetch timed out'), { code: 'ENTROROVE_DESIGN_RESEARCH_TIMEOUT' })));
39
- req.on('error', reject);
40
- });
19
+ const url = researchUrl(value), addresses = await publicResearchAddresses(url.hostname);
20
+ const result = await researchResponse(url, addresses);
41
21
  if ([301, 302, 303, 307, 308].includes(result.status)) {
42
22
  if (!result.location || redirects >= 4) designFail('RESEARCH_REDIRECT', 'Research redirect did not resolve to a bounded public reference');
43
23
  return fetchDesignReference(new URL(result.location, url).href, redirects + 1);
@@ -76,7 +56,10 @@ export async function assertDeclaredDesignFrameworks(input, design) {
76
56
  if (!checked.has(name)) {
77
57
  const actual = JSON.parse((await mutationRead14(input, input.repositoryRoot, name)).content.toString('utf8'));
78
58
  const packages = { ...actual.dependencies, ...actual.devDependencies };
79
- if (Object.keys(packages).some(packageName => Object.hasOwn(FRAMEWORK_DOC_HOSTS, packageName) && !declared.has(packageName))) designFail('FRAMEWORK_MISSING', 'Research every actual declared framework in the nearest UI package using its official documentation and locked version');
59
+ if (Object.keys(packages).some(packageName => Object.hasOwn(FRAMEWORK_DOC_HOSTS, packageName)
60
+ && !design.frameworks.some(framework => framework.packageName === packageName && framework.packagePath === name))) {
61
+ designFail('FRAMEWORK_MISSING', 'Research every actual declared framework in its nearest UI package using that package file and locked version');
62
+ }
80
63
  checked.add(name);
81
64
  }
82
65
  break;
package/design-runner.mjs CHANGED
@@ -3,10 +3,12 @@ import { assertDesignDeclaration } from './design-declaration.mjs';
3
3
  import { assertDesignWrite } from './design-gate.mjs';
4
4
  import { designUiCandidate } from './design-ui.mjs';
5
5
  import { designFail } from './design-policy.mjs';
6
+ import { assertDesignStageSources } from './design-stage.mjs';
6
7
 
7
8
  export async function designRunnerBefore(input, authority) {
8
9
  const policy = authority.plan.policy.discipline, originals = new Map();
9
10
  if (!policy || policy.access === 'read-only') return originals;
11
+ if (policy.design) await assertDesignStageSources(input, policy.design);
10
12
  assertDesignDeclaration(policy, policy.writes.flatMap(write => [write.targetPath, ...(write.destinationPath ? [write.destinationPath] : [])]));
11
13
  for (const write of policy.writes) {
12
14
  const target = await mutationTarget14(authority.root, write.targetPath, true);
@@ -19,6 +21,7 @@ export async function designRunnerBefore(input, authority) {
19
21
  }
20
22
  export async function designRunnerAfter(input, authority, changed, originals) {
21
23
  const policy = authority.plan.policy.discipline;
24
+ if (policy?.design) await assertDesignStageSources(input, policy.design);
22
25
  for (const name of changed) {
23
26
  const target = await mutationTarget14(authority.root, name, true);
24
27
  const source = target.exists && target.stat.size <= 2_097_152
@@ -0,0 +1,12 @@
1
+ import { object, array, text, identifier, relativePath, sha256 } from './local-contract-types.mjs';
2
+
3
+ const names = min => ({ ...array(identifier, min), maxItems: 256, uniqueItems: true });
4
+ const source = object({ path: relativePath, sha256 }, ['path', 'sha256']);
5
+ export const DESIGN_STAGE_SCHEMA = object({ id: identifier, requirementIds: names(1), pageIds: names(1), edgeIds: names(0),
6
+ readOnlyPages: { ...array(object({ pageId: identifier, sources: { ...array(source, 1), maxItems: 128 } }, ['pageId', 'sources']), 0), maxItems: 128 } },
7
+ ['id', 'requirementIds', 'pageIds', 'edgeIds', 'readOnlyPages']);
8
+ const value = { anyOf: [{ type: 'string', maxLength: 8192 }, { type: 'null' }] };
9
+ export const DESIGN_HANDOFF_SCHEMA = object({ area: { enum: ['local-storage', 'session-storage'] },
10
+ key: { ...text, maxLength: 128, pattern: '^[^\\u0000-\\u001f\\u007f]+$' },
11
+ method: { enum: ['setItem', 'removeItem'] }, before: value, after: value, count: { const: 1 } },
12
+ ['area', 'key', 'method', 'before', 'after', 'count']);
@@ -0,0 +1,78 @@
1
+ import { designFail } from './design-policy.mjs';
2
+ import { browserStepHandoffs } from './design-storage.mjs';
3
+
4
+ export function validateDesignStage(policy) {
5
+ const stage = policy.rules.stage;
6
+ if (!stage) return;
7
+ const active = new Set(stage.pageIds), passive = new Set(stage.readOnlyPages.map(item => item.pageId));
8
+ if (active.size !== stage.pageIds.length || passive.size !== stage.readOnlyPages.length
9
+ || stage.requirementIds.some(id => !policy.site.requirementIds.includes(id))
10
+ || policy.site.pages.some(page => Number(active.has(page.id)) + Number(passive.has(page.id)) !== 1)
11
+ || [...active, ...passive].some(id => !policy.site.pages.some(page => page.id === id))) {
12
+ designFail('STAGE', 'A stage retains the whole site and partitions every original page into executable or source-bound read-only context');
13
+ }
14
+ for (const id of stage.edgeIds) {
15
+ const edge = policy.site.edges.find(item => item.id === id);
16
+ if (!edge || !active.has(edge.from) || !active.has(edge.to)) designFail('STAGE', 'Every executable stage edge must retain its original endpoints');
17
+ }
18
+ const sources = new Map();
19
+ for (const page of stage.readOnlyPages) for (const source of page.sources) {
20
+ if (!policy.rules.verificationPaths.includes(source.path)
21
+ || sources.has(source.path) && sources.get(source.path) !== source.sha256
22
+ || page.sources.filter(item => item.path === source.path).length !== 1) {
23
+ designFail('STAGE', 'Read-only context requires exact existing source hashes in the unchanged verification range');
24
+ }
25
+ sources.set(source.path, source.sha256);
26
+ }
27
+ }
28
+ export const executableDesignPages = policy => policy.rules.stage?.pageIds ?? policy.site.pages.map(page => page.id);
29
+ export const executableDesignEdges = policy => policy.rules.stage?.edgeIds ?? policy.site.edges.map(edge => edge.id);
30
+ export function designAcceptanceScope(policy) {
31
+ return { mode: policy.rules.stage ? 'stage' : 'whole-site', ...(policy.rules.stage ? { stageId: policy.rules.stage.id } : {}),
32
+ pageIds: executableDesignPages(policy), edgeIds: executableDesignEdges(policy),
33
+ readOnlyPageIds: (policy.rules.stage?.readOnlyPages ?? []).map(page => page.pageId),
34
+ claimsWholeBusinessCompletion: false };
35
+ }
36
+ export async function assertDesignStageSources(input, policy) {
37
+ const sources = new Map((policy.rules.stage?.readOnlyPages ?? []).flatMap(page => page.sources.map(source => [source.path, source.sha256])));
38
+ if (!sources.size) return;
39
+ const { repositoryIntegrityFile } = await import('./local-discipline-repository.mjs');
40
+ for (const [name, expected] of sources) {
41
+ const actual = await repositoryIntegrityFile(input.repositoryRoot, name);
42
+ if (actual.sha256 !== expected) designFail('STAGE_SOURCE_CHANGED', 'A frozen read-only context source changed; preserve this result and obtain a truthful new baseline');
43
+ }
44
+ }
45
+ export function assertDesignStageWrite(policy, name) {
46
+ const expected = (policy.rules.stage?.readOnlyPages ?? []).flatMap(page => page.sources).find(source => source.path === name);
47
+ if (expected) designFail('STAGE_READ_ONLY', 'This source is frozen as read-only context in the original approved stage');
48
+ }
49
+ const sensitive = /token|secret|password|passphrase|credential|authorization|cookie|session.?id|private.?key|api.?key|^(?:auth|roles?|permissions?|authenticated|isAdmin|isLoggedIn|accessLevel)$/i;
50
+ function safeHandoffValue(value) {
51
+ if (value === null) return true;
52
+ if (/-----BEGIN .*PRIVATE KEY|\bBearer\s+|https?:\/\/[^\s/]+:[^\s/]+@/i.test(value)) return false;
53
+ let parsed; try { parsed = JSON.parse(value); } catch { return !/[?&](?:token|key|secret|password|signature)=/i.test(value); }
54
+ const pending = [parsed]; let nodes = 0;
55
+ while (pending.length) {
56
+ if (++nodes > 1024) return false;
57
+ const node = pending.pop();
58
+ if (node && typeof node === 'object') for (const [key, item] of Object.entries(node)) {
59
+ if (sensitive.test(key)) return false;
60
+ pending.push(item);
61
+ }
62
+ else if (typeof node === 'string' && /\bBearer\s+|[?&](?:token|secret|password|signature)=/i.test(node)) return false;
63
+ }
64
+ return true;
65
+ }
66
+ export function validateDesignHandoffs(step, edge) {
67
+ const handoffs = browserStepHandoffs(step, edge);
68
+ if (!handoffs.length) return;
69
+ if (!['navigate', 'open'].includes(edge.kind) || step.action === 'fill' || step.mutations.length
70
+ || step.storageHandoffs?.length && step.readBack
71
+ || step.readBack && (step.readBack.jsonPointer || step.readBack.kind !== 'local-storage')
72
+ || new Set(handoffs.map(item => `${item.area}\0${item.key}`)).size !== handoffs.length
73
+ || handoffs.some(item => sensitive.test(item.key)
74
+ || (item.method === 'removeItem') !== (item.after === null)
75
+ || !safeHandoffValue(item.before) || !safeHandoffValue(item.after))) {
76
+ designFail('HANDOFF', 'Only an explicit navigation/open action may declare exact noncredential storage handoffs; no HTTP commit or success authority is granted');
77
+ }
78
+ }
@@ -0,0 +1,63 @@
1
+ import { canonical } from './local-integrity.mjs';
2
+
3
+ export function browserStepHandoffs(step, edge) {
4
+ if (step.storageHandoffs?.length) return step.storageHandoffs;
5
+ if (!['navigate', 'open'].includes(edge.kind) || step.readBack?.kind !== 'local-storage') return [];
6
+ const proof = step.readBack;
7
+ return [{ area: 'local-storage', key: proof.key, method: proof.after === null ? 'removeItem' : 'setItem', count: 1,
8
+ before: proof.before === null ? null : canonical(proof.before), after: proof.after === null ? null : canonical(proof.after), format: 'json' }];
9
+ }
10
+ function normalized(value, format) {
11
+ if (value === null || format !== 'json') return value;
12
+ try { return canonical(JSON.parse(value)); } catch { return undefined; }
13
+ }
14
+ async function storageSnapshot(page) {
15
+ return page.evaluate(() => {
16
+ let total = 0;
17
+ const read = store => {
18
+ if (store.length > 256) throw new Error('storage observation limit');
19
+ return Object.fromEntries(Array.from({ length: store.length }, (_, index) => {
20
+ const key = store.key(index), value = store.getItem(key); total += key.length + value.length;
21
+ if (total > 1_048_576) throw new Error('storage observation limit');
22
+ return [key, value];
23
+ }));
24
+ };
25
+ return { 'local-storage': read(localStorage), 'session-storage': read(sessionStorage) };
26
+ });
27
+ }
28
+ export function browserStorage(base, parents) {
29
+ let before = new Map(), actor;
30
+ const empty = () => ({ 'local-storage': {}, 'session-storage': {} });
31
+ return {
32
+ async begin(context, page) {
33
+ actor = page; before = new Map();
34
+ for (const current of context.pages()) if (new URL(current.url()).origin === base.origin) before.set(current, await storageSnapshot(current));
35
+ },
36
+ async check(context, current, handoffs, writes, commits, failures) {
37
+ if (commits) return;
38
+ const same = (value, expected, format) => normalized(value, format) === normalized(expected, format);
39
+ if (writes.length !== handoffs.length || handoffs.some(expected => writes.filter(event =>
40
+ [actor, current].includes(event.page) && event.area === expected.area && event.key === expected.key && event.method === expected.method
41
+ && same(event.before, expected.before, expected.format) && same(event.after, expected.after, expected.format)).length !== 1)) {
42
+ failures.push('unexpected-browser-storage-write');
43
+ }
44
+ for (const page of context.pages()) {
45
+ if (new URL(page.url()).origin !== base.origin) continue;
46
+ const original = before.get(page) ?? before.get(parents.get(page)) ?? empty(), after = await storageSnapshot(page);
47
+ for (const area of ['local-storage', 'session-storage']) {
48
+ const expected = { ...original[area] };
49
+ const applies = area === 'local-storage' || page === actor || page === current && !before.has(page);
50
+ for (const change of handoffs.filter(value => applies && value.area === area)) {
51
+ const value = Object.hasOwn(expected, change.key) ? expected[change.key] : null;
52
+ if (!same(value, change.before, change.format)) failures.push('handoff-before-state');
53
+ if (change.after === null) delete expected[change.key]; else expected[change.key] = change.after;
54
+ }
55
+ if (Object.keys(after[area]).length !== Object.keys(expected).length || Object.keys(expected).some(key => {
56
+ const declaration = handoffs.find(value => applies && value.area === area && value.key === key);
57
+ return !Object.hasOwn(after[area], key) || !same(after[area][key], expected[key], declaration?.format);
58
+ })) failures.push('handoff-storage-state');
59
+ }
60
+ }
61
+ }
62
+ };
63
+ }
@@ -0,0 +1,67 @@
1
+ import { createHook } from 'node:async_hooks';
2
+ import { performance } from 'node:perf_hooks';
3
+ import { fileURLToPath } from 'node:url';
4
+ import path from 'node:path';
5
+
6
+ const sdkRoot = path.dirname(fileURLToPath(import.meta.url));
7
+ function captureFsStack(exclude) {
8
+ const value = {}; Error.captureStackTrace(value, exclude); return value;
9
+ }
10
+ export function fsTraceStack(stack, root = sdkRoot) {
11
+ const frames = [];
12
+ for (const line of String(stack ?? '').split('\n').slice(1, 40)) {
13
+ const match = /(?:\()?((?:file:\/\/\/|node:)[^()\s]+):(\d+):(\d+)\)?$/.exec(line.trim());
14
+ if (!match) continue;
15
+ const [, source, row, column] = match; let module;
16
+ if (source.startsWith('node:')) module = source;
17
+ else {
18
+ let filename; try { filename = fileURLToPath(source); } catch { continue; }
19
+ const relative = path.relative(root, filename);
20
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || !/^[A-Za-z0-9_./-]+\.mjs$/.test(relative)) continue;
21
+ if (relative === 'fs-request-trace.mjs') continue;
22
+ module = relative;
23
+ }
24
+ frames.push({ module, line: Number(row), column: Number(column) });
25
+ if (frames.length >= 12) break;
26
+ }
27
+ return frames;
28
+ }
29
+ export function createFsRequestTrace({ hookFactory = createHook, now = () => performance.now(),
30
+ repeat = setInterval, cancel = clearInterval, write = value => process.stderr.write(`${JSON.stringify(value)}\n`),
31
+ stack = captureFsStack, thresholdMs = 15_000, maximumPending = 128 } = {}) {
32
+ if (!Number.isSafeInteger(thresholdMs) || thresholdMs < 1 || !Number.isSafeInteger(maximumPending)
33
+ || maximumPending < 1 || maximumPending > 128) throw new Error('Invalid bounded FS trace settings');
34
+ const pending = new Map(); let dropped = 0, reported = 0, enabled = true;
35
+ const emit = value => { try { write({ schemaVersion: 'entrorove.fs-request-trace/1.0', metadataOnly: true, ...value }); } catch {} };
36
+ const hook = hookFactory({
37
+ init: function fsInit(asyncId, type, triggerAsyncId) {
38
+ if ((type !== 'FSREQPROMISE' && type !== 'FSREQCALLBACK') || !enabled) return;
39
+ if (pending.size >= maximumPending) { dropped++; return; }
40
+ try { pending.set(asyncId, { asyncId, triggerAsyncId, resourceType: type, created: now(), stack: stack(fsInit), reported: false }); }
41
+ catch { dropped++; }
42
+ },
43
+ destroy(asyncId) { pending.delete(asyncId); },
44
+ promiseResolve(asyncId) { pending.delete(asyncId); },
45
+ });
46
+ const check = () => {
47
+ for (const request of pending.values()) {
48
+ const elapsedMs = Math.max(0, Math.floor(now() - request.created));
49
+ if (request.reported || elapsedMs < thresholdMs) continue;
50
+ request.reported = true; reported++;
51
+ let creationStack = [];
52
+ try { creationStack = fsTraceStack(typeof request.stack === 'string' ? request.stack : request.stack?.stack); } catch {}
53
+ request.stack = null;
54
+ emit({ kind: 'fs-request-stall', resourceType: request.resourceType, asyncId: request.asyncId,
55
+ triggerAsyncId: request.triggerAsyncId, elapsedMs, pendingCount: pending.size, dropped,
56
+ creationStack, creationStackAvailable: creationStack.length > 0, cancelsRequest: false, grantsExecutionAuthority: false });
57
+ }
58
+ };
59
+ hook.enable(); const timer = repeat(check, Math.min(1000, thresholdMs)); timer.unref?.();
60
+ const stop = () => {
61
+ if (enabled) { enabled = false; hook.disable(); cancel(timer); pending.clear(); }
62
+ return { enabled, pendingCount: pending.size, dropped, reported };
63
+ };
64
+ stop.inspect = () => ({ enabled, pendingCount: pending.size, dropped, reported });
65
+ return stop;
66
+ }
67
+ export const startFsTrace = () => createFsRequestTrace();
@@ -25,7 +25,11 @@ function supervisionError(code, message, effects = 'unknown') {
25
25
  }
26
26
 
27
27
  export async function runApprovedProcess(authority) {
28
- const started = performance.now(), input = launchInput(authority);
28
+ const started = performance.now();
29
+ let input;
30
+ try { input = launchInput(authority); }
31
+ catch (error) { throw Object.assign(error, { execution: { schemaVersion: RUNNER_SCHEMA, status: 'failed', evidence: [],
32
+ effects: 'none', prelaunchVerified: true } }); }
29
33
  return new Promise((resolve, reject) => {
30
34
  let finished = false;
31
35
  const supervisor = fork(SUPERVISOR, [], { execPath: process.execPath, execArgv: [], env: {}, detached: true,
package/local-cli.mjs CHANGED
@@ -44,6 +44,45 @@ async function stdinJson() {
44
44
  return value;
45
45
  }
46
46
 
47
+ // Discovery takes an optional input object. A host that keeps stdin open
48
+ // without writing must not block the skill, so wait only briefly for data.
49
+ export async function optionalStdinJson(idleMs = 200) {
50
+ if (process.stdin.isTTY) return {};
51
+ return new Promise((resolve, reject) => {
52
+ let raw = '';
53
+ let settled = false;
54
+ let timer;
55
+ const cleanup = () => {
56
+ if (timer) clearTimeout(timer);
57
+ process.stdin.off('data', onData);
58
+ process.stdin.off('end', onEnd);
59
+ process.stdin.off('error', onError);
60
+ process.stdin.pause();
61
+ };
62
+ const finish = (error) => {
63
+ if (settled) return;
64
+ settled = true;
65
+ cleanup();
66
+ if (error) return reject(error);
67
+ if (!raw.trim()) return resolve({});
68
+ let value;
69
+ try { value = JSON.parse(raw); } catch { return reject(new Error('Local input must be valid JSON')); }
70
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
71
+ return reject(new Error('local input must be a JSON object'));
72
+ }
73
+ resolve(value);
74
+ };
75
+ const onData = chunk => { raw += chunk; };
76
+ const onEnd = () => finish();
77
+ const onError = error => finish(error);
78
+ process.stdin.on('data', onData);
79
+ process.stdin.once('end', onEnd);
80
+ process.stdin.once('error', onError);
81
+ timer = setTimeout(() => finish(), idleMs);
82
+ process.stdin.resume();
83
+ });
84
+ }
85
+
47
86
  const operations = {
48
87
  'mutation-consumption-attest': attestMutationConsumption14,
49
88
  'mutation-delegate-prepare': preparePreservedMutationSigner,
@@ -102,7 +141,7 @@ export async function runLocal(args = [], dependencies) {
102
141
  try {
103
142
  const operation = args[0];
104
143
  if (operation === 'capabilities' || operation === 'help') {
105
- validateLocalInput(operation, process.stdin.isTTY ? {} : await stdinJson());
144
+ validateLocalInput(operation, await optionalStdinJson());
106
145
  process.stdout.write(`${JSON.stringify(capabilitiesFor(args[1]))}\n`); return;
107
146
  }
108
147
  const handler = operations[operation];
@@ -101,7 +101,7 @@ const scopeInteraction = object({ type: enumeration(['confirm', 'choice', 'multi
101
101
  const scopeTask = object({ goal: { ...text, pattern: '\\S', maxLength: 12000 }, workType: enumeration(['implementation', 'documentation', 'calculation', 'merge', 'review', 'decision', 'diagnosis']),
102
102
  difficulty: enumeration(['low', 'medium', 'high']), risk: enumeration(['low', 'medium', 'high']), crossModule: { type: 'boolean' }, parallel: { type: 'boolean' }, needParallel: { type: 'boolean' },
103
103
  unclear: { type: 'boolean' }, architectureContract: { type: 'boolean' }, explicitAimlockRequested: { type: 'boolean' }, explicitEntroroveRequested: { type: 'boolean' }, userSpecifiedSkills: array(text),
104
- requirements: { ...array(scopeRequirement, 1), maxItems: 500 }, phase: enumeration(['plan', 'coordinate', 'architecture', 'verify', 'merge', 'decision']), baselineRef: text, interaction: scopeInteraction }, ['goal', 'workType', 'difficulty', 'risk']);
104
+ requirements: { ...array(scopeRequirement, 1), maxItems: 500 }, authorization: record, phase: enumeration(['plan', 'coordinate', 'architecture', 'verify', 'merge', 'decision']), baselineRef: text, interaction: scopeInteraction }, ['goal', 'workType', 'difficulty', 'risk']);
105
105
  const probe = { ...step, paths: probePaths, targetPaths: probePaths, task: scopeTask, baselineRef: text, snapshotId: identifier,
106
106
  targetSymbols: array(text, 1), contextMapPath: relativePath, importContexts, contextRevisionDigest: sha256,
107
107
  contextEvidence: { ...array(object({ path: relativePath, sha256 }, ['path', 'sha256'])), maxItems: 200 }, signerConfigPath: nullable(text),
@@ -100,8 +100,10 @@ export async function disciplineRepositoryObservation(input, root, { analyze = f
100
100
  let graph;
101
101
  try { graph = roots.length ? await importGraph(root, roots, input, disciplineAnalysisLimits) : { edges: [], dynamicSources: [], complete: true }; }
102
102
  catch (error) {
103
- if (!['ENTROROVE_IMPORT_UNRESOLVED', 'ENTROROVE_IMPORT_INVALID', 'ENTROROVE_IMPORT_UNSUPPORTED', 'ENTROROVE_SCOPE_LIMIT', 'ENTROROVE_BUDGET_SCOPE'].includes(error.code)) throw error;
104
- graph = { edges: [], dynamicSources: [], complete: false }; unresolved.push({ reason: error.code, message: error.message });
103
+ if (!['ENTROROVE_IMPORT_UNRESOLVED', 'ENTROROVE_IMPORT_INVALID', 'ENTROROVE_IMPORT_UNSUPPORTED', 'ENTROROVE_IMPORT_ADDRESS_UNSUPPORTED', 'ENTROROVE_SCOPE_LIMIT', 'ENTROROVE_BUDGET_SCOPE'].includes(error.code)) throw error;
104
+ const address = error.code === 'ENTROROVE_IMPORT_ADDRESS_UNSUPPORTED'
105
+ ? { path: error.sourcePath, sha256: error.sourceSha256, selectorDigest: error.selectorDigest } : {};
106
+ graph = { edges: [], dynamicSources: [], complete: false }; unresolved.push({ ...address, reason: error.code, message: error.message });
105
107
  }
106
108
  for (const [relative, content] of overrides) if (semanticSource(relative) && /\.(?:[cm]?[jt]sx?|vue)$/i.test(relative)) {
107
109
  try {
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { withStateLock } from './local-state-lock.mjs';
6
+ import { servicePrerequisite } from './service-prerequisite.mjs';
6
7
 
7
8
  const SHA = /^[a-f0-9]{64}$/;
8
9
  const UUID = /^[a-f0-9-]{36}$/;
@@ -66,6 +67,11 @@ export async function appendInvocation(value, home = os.homedir()) {
66
67
  || !['remote', 'local'].includes(value.kind)) throw new Error('Invalid invocation ledger metadata');
67
68
  const record = { schemaVersion: 'entrorove.invocation/1.0', eventId: randomUUID(), at: new Date().toISOString(),
68
69
  kind: value.kind, operation: value.operation, requestId: value.requestId, event: value.event };
70
+ if (value.servicePrerequisite !== undefined) {
71
+ const prerequisite = servicePrerequisite({ status: 403 }, value.servicePrerequisite);
72
+ if (value.event !== 'rejected' || !prerequisite) throw new Error('Invalid service prerequisite rejection receipt');
73
+ record.servicePrerequisite = prerequisite;
74
+ }
69
75
  if (value.durationMs !== undefined) {
70
76
  if (!Number.isSafeInteger(value.durationMs) || value.durationMs < 0) throw new Error('Invalid local invocation duration');
71
77
  record.durationMs = value.durationMs;
@@ -1,12 +1,20 @@
1
- import { constants, promises as fs } from 'node:fs';
1
+ import nodeFs, { constants, promises as fs } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { setImmediate as yieldMetadata14 } from 'node:timers/promises';
5
6
  import { canonical, sha256 } from './local-integrity.mjs';
6
7
  import { readBudgetedProjectFile } from './local-budget-reads.mjs';
7
8
 
8
9
  const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
9
10
  export const mutationFail14 = (code, message) => { throw Object.assign(new Error(message), { code }); };
11
+ let metadataObservations14 = 0;
12
+ // Observe metadata afresh without waiting for a queued filesystem completion.
13
+ // Yield between bounded groups so timers and signals still run during scans.
14
+ async function lstatMetadata14(target) {
15
+ if (++metadataObservations14 % 128 === 0) await yieldMetadata14();
16
+ return nodeFs.lstatSync(target);
17
+ }
10
18
  export function mutationId14(value, field) {
11
19
  if (typeof value !== 'string' || !ID.test(value)) mutationFail14('ENTROROVE_ID_INVALID', `${field} must be a safe identifier`);
12
20
  return value;
@@ -46,11 +54,11 @@ export function mutationInside14(root, target, field = 'path') {
46
54
  async function inspectPath(target, { allowMissing = false, final = 'file', code = 'ENTROROVE_FILE_INVALID' } = {}) {
47
55
  const resolved = absolute(target, 'filesystem path');
48
56
  const base = path.parse(resolved).root; const parts = path.relative(base, resolved).split(path.sep).filter(Boolean);
49
- let current = base; const ancestors = [{ path: base, stat: await fs.lstat(base) }];
57
+ let current = base; const ancestors = [{ path: base, stat: await lstatMetadata14(base) }];
50
58
  if (!parts.length) return { target: resolved, stat: ancestors[0].stat, exists: true, ancestors };
51
59
  for (const [index, part] of parts.entries()) {
52
60
  current = path.join(current, part); let stat;
53
- try { stat = await fs.lstat(current); } catch (error) {
61
+ try { stat = await lstatMetadata14(current); } catch (error) {
54
62
  if (error.code === 'ENOENT' && allowMissing) return { target: resolved, exists: false, stat: null, ancestors };
55
63
  throw error;
56
64
  }
@@ -71,7 +79,7 @@ async function canonicalDirectoryPath(value, { allowMissing = false, code }) {
71
79
  for (const [index, part] of parts.entries()) {
72
80
  const candidate = path.join(current, part);
73
81
  let stat;
74
- try { stat = await fs.lstat(candidate); } catch (error) {
82
+ try { stat = await lstatMetadata14(candidate); } catch (error) {
75
83
  if (error.code === 'ENOENT' && allowMissing) return path.join(current, ...parts.slice(index));
76
84
  throw error;
77
85
  }
@@ -90,7 +98,7 @@ async function canonicalDirectoryPath(value, { allowMissing = false, code }) {
90
98
  }
91
99
  async function assertAncestors(item, code = 'ENTROROVE_STATE_CHANGED') {
92
100
  for (const ancestor of item.ancestors) {
93
- const stat = await fs.lstat(ancestor.path);
101
+ const stat = await lstatMetadata14(ancestor.path);
94
102
  if (stat.isSymbolicLink() || !stat.isDirectory() || !sameNode(stat, ancestor.stat)) {
95
103
  mutationFail14(code, 'mutation path ancestors changed during filesystem access');
96
104
  }
package/local-probe.mjs CHANGED
@@ -71,15 +71,19 @@ async function probeHandoffCandidate(input, root, candidate, signingOptions) {
71
71
  }
72
72
  function probeSigningOptions(options) {
73
73
  if (!options || typeof options !== 'object' || Array.isArray(options)
74
- || Object.keys(options).some(key => key !== 'maximumExpiresAt')
75
- || options.maximumExpiresAt !== undefined && (!Number.isSafeInteger(options.maximumExpiresAt) || options.maximumExpiresAt < 1)) {
76
- scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Internal probe signing options require only a finite exact deadline');
74
+ || Object.keys(options).some(key => !['maximumExpiresAt', 'beforeSign'].includes(key))
75
+ || options.maximumExpiresAt !== undefined && (!Number.isSafeInteger(options.maximumExpiresAt) || options.maximumExpiresAt < 1)
76
+ || options.beforeSign !== undefined && typeof options.beforeSign !== 'function') {
77
+ scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Internal probe signing options require a finite deadline or an original bound challenge callback');
77
78
  }
78
- return { maximumExpiresAt: options.maximumExpiresAt };
79
+ return { maximumExpiresAt: options.maximumExpiresAt, beforeSign: options.beforeSign };
79
80
  }
80
81
  export async function probe(input, options = {}) {
81
82
  const signingOptions = probeSigningOptions(options);
82
83
  rejectScopePrivileges(input);
84
+ if (signingOptions.beforeSign && Object.hasOwn(input, 'handoffCandidate')) {
85
+ scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Temporary handoff candidates cannot renew an ordinary scope challenge');
86
+ }
83
87
  const root = await scopeRoot(input.repositoryRoot);
84
88
  const candidate = await verifiedScopeCandidate(input, root);
85
89
  if (candidate && await handoffReadBudget(input)) return probeHandoffCandidate(input, root, candidate, signingOptions);
@@ -0,0 +1,31 @@
1
+ // Durable per-operation limits. These are recovery limits, not an execution
2
+ // permission, and callers cannot enlarge them through request payloads.
3
+ const limits = Object.freeze({ attempt: 2, signing: 2, status: 3, prerequisite: 2 });
4
+ const fail = recovery => Object.assign(new Error('Automatic recovery limit reached; preserve the original task and pending evidence, and report the concrete failure before another attempt'),
5
+ { code: 'ENTROROVE_RECOVERY_EXHAUSTED', recovery: structuredClone(recovery), automaticRetryAllowed: false });
6
+ export function consumeRecovery(state, kind, eventId) {
7
+ if (!Object.hasOwn(limits, kind)) throw new Error('Unknown recovery boundary');
8
+ const recovery = Object.hasOwn(state, 'recovery') ? state.recovery : { schemaVersion: 'entrorove.recovery-budget/1.0', counts: {} };
9
+ if (!recovery || recovery.schemaVersion !== 'entrorove.recovery-budget/1.0' || !recovery.counts || Array.isArray(recovery.counts)
10
+ || Object.keys(recovery).some(key => !['schemaVersion', 'counts', 'events'].includes(key))
11
+ || Object.entries(recovery.counts).some(([key, value]) => !Object.hasOwn(limits, key)
12
+ || !Number.isSafeInteger(value) || value < 0 || value > limits[key])) {
13
+ throw Object.assign(new Error('Original recovery accounting is invalid'), { code: 'ENTROROVE_RECOVERY_STATE' });
14
+ }
15
+ if (recovery.events && (Array.isArray(recovery.events) || Object.entries(recovery.events).some(([key, value]) =>
16
+ !Object.hasOwn(limits, key) || typeof value !== 'string' || !value || value.length > 256 || !recovery.counts[key]))) {
17
+ throw Object.assign(new Error('Original recovery event is invalid'), { code: 'ENTROROVE_RECOVERY_STATE' });
18
+ }
19
+ if (eventId !== undefined && (typeof eventId !== 'string' || !eventId || eventId.length > 256)) throw new Error('Invalid recovery event');
20
+ if (eventId && recovery.events?.[kind] === eventId) return structuredClone(recovery);
21
+ if ((recovery.counts[kind] ?? 0) >= limits[kind]) throw fail(recovery);
22
+ recovery.counts[kind] = (recovery.counts[kind] ?? 0) + 1;
23
+ if (eventId) recovery.events = { ...recovery.events, [kind]: eventId };
24
+ state.recovery = recovery;
25
+ return structuredClone(recovery);
26
+ }
27
+ export function recoveryFailureFields(error) {
28
+ return error?.code === 'ENTROROVE_RECOVERY_EXHAUSTED' || error?.code === 'ENTROROVE_RECOVERY_STATE'
29
+ ? { code: error.code, message: error.message, automaticRetryAllowed: false,
30
+ ...(error.recovery ? { recovery: error.recovery } : {}) } : {};
31
+ }
@@ -21,6 +21,7 @@ import { uniConditionalVariants } from './local-scope-uni-conditional.mjs';
21
21
  import { createUniBuildInspection } from './local-scope-uni-build.mjs';
22
22
  import { createScopeBuildIO } from './local-scope-build-io.mjs';
23
23
  import { observeBuildFacts } from './local-scope-build-facts.mjs';
24
+ import { scopeWriteFiles, scopeWriteObservation } from './scope-write-observation.mjs';
24
25
 
25
26
  const execute = promisify(execFile);
26
27
  const inspectionFreshness = new WeakMap();
@@ -247,7 +248,11 @@ function declarationModules(content, from) {
247
248
  }
248
249
  async function resolveImport(root, from, specifier, workspace, input, aliases, configPath, declarations) {
249
250
  if (specifier.startsWith('/') || /^[A-Za-z]:/.test(specifier) || specifier.includes('\\') || /[\u0000-\u001f\u007f]/.test(specifier)) {
250
- scopeFail('ENTROROVE_PATH_UNSAFE', 'Source module addresses require safe repository-relative or declared package selectors');
251
+ // This is a source expression, not permission to probe an absolute target.
252
+ // Strict dependency inspection still rejects it; whole-repository usage
253
+ // observation can retain this specific semantic uncertainty without I/O.
254
+ throw Object.assign(new Error('Source module address has no supported local projection; no target was inspected'),
255
+ { code: 'ENTROROVE_IMPORT_ADDRESS_UNSUPPORTED', sourcePath: from, selectorDigest: sha256(specifier) });
251
256
  }
252
257
  if (/^(?:https?:|file:|data:)/i.test(specifier)) scopeFail('ENTROROVE_IMPORT_UNSUPPORTED', 'remote source imports cannot be inspected as local facts');
253
258
  if (declarations.has(specifier)) return null;
@@ -367,7 +372,10 @@ export async function importGraph(root, originals, input = { repositoryRoot: roo
367
372
  catch (error) {
368
373
  const missing = error.code === 'ENTROROVE_IMPORT_UNRESOLVED' && specifier.startsWith('.')
369
374
  ? error.missingCandidates?.filter(candidate => pendingCreates.has(candidate)) : null;
370
- if (error.code !== 'ENTROROVE_IMPORT_UNRESOLVED') throw error;
375
+ if (error.code !== 'ENTROROVE_IMPORT_UNRESOLVED') {
376
+ if (error.code === 'ENTROROVE_IMPORT_ADDRESS_UNSUPPORTED') error.sourceSha256 = read.sha256;
377
+ throw error;
378
+ }
371
379
  unresolvedImports.push({ path: relative, specifier, reason: missing?.length ? 'approved-create-not-present' : 'source-import-unresolved',
372
380
  candidatePaths: missing?.length ? missing : error.missingCandidates ?? [] });
373
381
  dynamic = true;
@@ -435,6 +443,13 @@ export async function collectScopeFacts(root, requested, baselineRef, knownFiles
435
443
  const verifyGraph = inspectionFreshness.get(graph), observedMetadata = graph.workspaceMetadata.map(file => ({ path: file.path, sha256: file.sha256 }));
436
444
  inspectionFreshness.set(graph, async () => { await verifyGraph(); await stableScope(root, observedMetadata, input, scopeBuildSourceRead); });
437
445
  const observedGit = await observeGitFacts(root, files, baselineRef, input); const history = observedGit.facts;
446
+ let writeScope;
447
+ if (input.scopeWritePaths !== undefined) {
448
+ const writable = scopeWriteFiles(files, input.scopeWritePaths);
449
+ const observed = await observeGitFacts(root, writable, baselineRef, input);
450
+ if (['available', 'head', 'baseline'].some(field => observed.facts[field] !== history[field])) scopeFail('ENTROROVE_SCOPE_CHANGED', 'Write observation lost the full scope Git identity');
451
+ writeScope = scopeWriteObservation(writable, observed, knownFiles);
452
+ }
438
453
  const modules = [...new Map(graph.nodes.map(node => [node.boundary.path, node.boundary])).values()].sort((a, b) => a.path.localeCompare(b.path));
439
454
  await stableScope(root, files, input);
440
455
  await stableScope(root, modules.filter(module => module.manifestPath).map(module => ({ path: module.manifestPath, sha256: module.manifestDigest })), input, scopeBuildSourceRead);
@@ -445,7 +460,7 @@ export async function collectScopeFacts(root, requested, baselineRef, knownFiles
445
460
  : sourceLines + files.filter(file => !file.exists).reduce((sum, file) => sum + (knownFiles.find(previous => previous.path === file.path)?.lines ?? 0), 0);
446
461
  return { targetPaths: [...new Set(requested)].sort(), fileCount: files.length, files, bytes, sourceLines, tokenEstimate: Math.ceil(bytes / 4), modules,
447
462
  crossModule: modules.length > 1, risk: observedScopeRisk(files), importGraph: graph, git: history,
448
- estimatedChangedLines };
463
+ estimatedChangedLines, ...(writeScope ? { writeScope } : {}) };
449
464
  }
450
465
  async function contextEvidence(root, graph, input) {
451
466
  if (input.contextEvidence === undefined) return;
@@ -37,8 +37,8 @@ function binding(input, result) {
37
37
  }
38
38
  async function signingChallenge(input, beforeSign, maximumExpiresAt) {
39
39
  if (beforeSign === undefined) return { input, maximumExpiresAt };
40
- if (typeof beforeSign !== 'function' || !input.contextRevisionDigest || Object.hasOwn(input, 'handoffCandidate')) {
41
- scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Challenge renewal is restricted to an original context observation');
40
+ if (typeof beforeSign !== 'function' || Object.hasOwn(input, 'handoffCandidate')) {
41
+ scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Challenge renewal requires an original bound scope observation');
42
42
  }
43
43
  const fresh = await beforeSign();
44
44
  if (!fresh || Object.keys(fresh).sort().join(',') !== 'challenge,maximumExpiresAt'