@entrorove/skill 0.1.36 → 0.1.38

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 (64) hide show
  1. package/CAPABILITY-MAP.md +11 -0
  2. package/CHANGELOG.md +10 -0
  3. package/IDE-EXECUTION.md +93 -0
  4. package/REUSE-DISCIPLINE.md +18 -0
  5. package/SKILL.md +22 -89
  6. package/cli.mjs +12 -8
  7. package/cloud-task-client.mjs +20 -7
  8. package/cloud-watch-client.mjs +4 -3
  9. package/ide-code-mode.mjs +38 -0
  10. package/ide-command.mjs +76 -0
  11. package/ide-hook.mjs +167 -0
  12. package/ide-install.mjs +59 -0
  13. package/ide-presentation.mjs +15 -7
  14. package/ide-results.mjs +59 -0
  15. package/ide-session.mjs +86 -0
  16. package/local-approved-plan.mjs +21 -6
  17. package/local-archguard-ast.mjs +6 -1
  18. package/local-archguard-language-ast.mjs +7 -0
  19. package/local-cli.mjs +6 -1
  20. package/local-contracts.mjs +4 -1
  21. package/local-discipline-actions.mjs +71 -0
  22. package/local-discipline-analysis.mjs +153 -0
  23. package/local-discipline-batch.mjs +32 -0
  24. package/local-discipline-contracts.d.mts +6 -0
  25. package/local-discipline-contracts.mjs +62 -0
  26. package/local-discipline-gate.mjs +85 -0
  27. package/local-discipline-native.mjs +33 -0
  28. package/local-discipline-operations.mjs +39 -0
  29. package/local-discipline-repository.mjs +87 -0
  30. package/local-discipline-snapshot.mjs +29 -0
  31. package/local-equivalence-runner.mjs +33 -0
  32. package/local-error.mjs +12 -0
  33. package/local-execution.mjs +1 -0
  34. package/local-git-executable.mjs +33 -0
  35. package/local-integrity.d.mts +5 -0
  36. package/local-integrity.mjs +4 -0
  37. package/local-merge-controller.mjs +1 -0
  38. package/local-merge-rollback.mjs +2 -1
  39. package/local-mutation-authority.mjs +16 -7
  40. package/local-mutation-batch.mjs +7 -3
  41. package/local-mutation-files.mjs +22 -0
  42. package/local-mutation-snapshot.mjs +6 -3
  43. package/local-mutation-wire.mjs +6 -1
  44. package/local-runner-discipline.mjs +23 -0
  45. package/local-scope-git-reads.mjs +3 -1
  46. package/local-scope-repository.mjs +19 -1
  47. package/local-task-handoff-write-receipts.mjs +11 -0
  48. package/manifest.json +1 -1
  49. package/native-client.mjs +1 -1
  50. package/native-notification.mjs +13 -6
  51. package/package.json +37 -4
  52. package/release-state.json +62 -31
  53. package/release.mjs +11 -1
  54. package/releases.json +12 -0
  55. package/remote-client.mjs +16 -1
  56. package/reuse-discipline-release.mjs +18 -0
  57. package/runtime/validator-runtime-v7.0.44.d.mts +3 -0
  58. package/runtime/validator-runtime-v7.0.44.mjs +1362 -0
  59. package/snapshot-cleanup-journal.mjs +32 -0
  60. package/snapshot-retention.mjs +95 -0
  61. package/task-entry-client.mjs +28 -0
  62. package/task-identity.d.mts +2 -0
  63. package/task-identity.mjs +9 -0
  64. package/validator-runtime-build.mjs +16 -0
@@ -0,0 +1,32 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { atomicMutationJson14, readMutationJson14, privateMutationDirectory14, cleanupMutationDirectory14 } from './local-mutation-files.mjs';
5
+
6
+ const absent = cause => { if (cause.code !== 'ENOENT') throw cause; return null; };
7
+ export async function resumeSnapshotRemoval(file, directory, flowId) {
8
+ if (!await fs.lstat(file).catch(absent)) return null;
9
+ const saved = await readMutationJson14(file);
10
+ if (saved.schemaVersion !== 'entrorove.snapshot-removal/1.0' || saved.flowId !== flowId
11
+ || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(saved.snapshotId ?? '')
12
+ || !/^cleanup-[a-f0-9-]{36}$/u.test(saved.quarantine ?? '')
13
+ || !Number.isSafeInteger(saved.bytes) || saved.bytes < 0) throw new Error('Unknown snapshot cleanup journal was preserved');
14
+ const source = path.join(directory, saved.snapshotId), quarantine = path.join(directory, saved.quarantine);
15
+ const original = await fs.lstat(source).catch(absent), moved = await fs.lstat(quarantine).catch(absent);
16
+ if (original && moved) throw new Error('Snapshot cleanup cannot remove two competing directories');
17
+ const stat = original ?? moved;
18
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink() || String(stat.dev) !== saved.device || String(stat.ino) !== saved.inode)) {
19
+ throw new Error('Snapshot cleanup refuses a substituted directory');
20
+ }
21
+ if (original) await fs.rename(source, quarantine);
22
+ if (stat) await cleanupMutationDirectory14(await privateMutationDirectory14(quarantine));
23
+ await fs.unlink(file);
24
+ return { name: saved.snapshotId, bytes: saved.bytes };
25
+ }
26
+ export async function removeSnapshotWithJournal(file, directory, flowId, record) {
27
+ const stat = await fs.lstat(path.join(directory, record.name));
28
+ await atomicMutationJson14(file, { schemaVersion: 'entrorove.snapshot-removal/1.0', flowId,
29
+ snapshotId: record.name, quarantine: `cleanup-${randomUUID()}`, bytes: record.bytes,
30
+ device: String(stat.dev), inode: String(stat.ino) });
31
+ return resumeSnapshotRemoval(file, directory, flowId);
32
+ }
@@ -0,0 +1,95 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { canonical, sha256, verifySnapshotManifest } from './local-integrity.mjs';
5
+ import { withStateLock } from './local-state-lock.mjs';
6
+ import { mutationStateRoot14, atomicMutationJson14, privateMutationDirectory14 } from './local-mutation-files.mjs';
7
+ import { resumeSnapshotRemoval, removeSnapshotWithJournal } from './snapshot-cleanup-journal.mjs';
8
+
9
+ const id = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value);
10
+ async function realDirectory(directory) {
11
+ const stat = await fs.lstat(directory).catch(cause => { if (cause.code !== 'ENOENT') throw cause; return null; });
12
+ if (!stat) return false;
13
+ if (!stat.isDirectory() || stat.isSymbolicLink() || process.platform !== 'win32' && stat.uid !== process.getuid()) throw new Error('Snapshot cleanup requires an owned real directory');
14
+ return true;
15
+ }
16
+ async function safeTree(directory) {
17
+ let bytes = 0;
18
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
19
+ const file = path.join(directory, entry.name), stat = await fs.lstat(file);
20
+ if (stat.isSymbolicLink() || stat.nlink > 1 && !stat.isDirectory() || process.platform !== 'win32' && stat.uid !== process.getuid()) throw new Error('Snapshot cleanup preserves unknown links or ownership');
21
+ if (stat.isDirectory()) bytes += await safeTree(file);
22
+ else if (stat.isFile()) bytes += stat.size;
23
+ else throw new Error('Snapshot cleanup preserves unknown filesystem entries');
24
+ }
25
+ return bytes;
26
+ }
27
+ function completedProof(call, result) {
28
+ if (call.operation !== 'continue' || call.input?.operation !== 'workflow-verdict' || !id(call.input.flowId)) return null;
29
+ const output = result?.output, receipt = result?.feedback;
30
+ const reports = output?.result?.reports ?? output?.reports;
31
+ if (output?.status !== 'succeeded' || output.flowId !== call.input.flowId || !Array.isArray(reports) || !reports.length
32
+ || reports.some(report => !['pass','pass-with-risk'].includes(report.verdict)) || receipt?.requestId !== output.requestId
33
+ || receipt.resultDigest !== sha256(canonical(output)) || receipt.evaluation?.disposition !== 'good'
34
+ || receipt.evaluation.validation !== 'passed' || receipt.acknowledgement?.stored !== true) return null;
35
+ return { flowId: output.flowId, requestId: output.requestId, receiptId: receipt.receiptId, evaluationDigest: receipt.evaluation.digest };
36
+ }
37
+ async function protectedSnapshots(root, repository, flowId) {
38
+ const target = path.join(root, 'scope', repository, flowId, 'state.json');
39
+ const stat = await fs.lstat(target).catch(cause => { if (cause.code !== 'ENOENT') throw cause; return null; });
40
+ if (!stat) return new Set();
41
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024 * 1024) throw new Error('Unknown scope binding was preserved');
42
+ const state = JSON.parse(await fs.readFile(target, 'utf8'));
43
+ return new Set([state.originalSnapshot?.snapshotId, state.snapshotId].filter(id));
44
+ }
45
+ async function cleanupRepository(root, repository, proof) {
46
+ const directory = path.join(root, 'snapshots', repository, proof.flowId);
47
+ if (!await realDirectory(directory)) return { removed: [], retained: [], releasedBytes: 0 };
48
+ const journal = path.join(root, 'snapshot-cleanup', `${sha256(`${repository}:${proof.flowId}`)}-pending.json`);
49
+ const resumed = await resumeSnapshotRemoval(journal, directory, proof.flowId);
50
+ const records = [], protectedIds = await protectedSnapshots(root, repository, proof.flowId);
51
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
52
+ if (!entry.isDirectory() || entry.isSymbolicLink() || !id(entry.name)) continue;
53
+ const snapshot = path.join(directory, entry.name), manifestPath = path.join(snapshot, 'manifest.json');
54
+ const stat = await fs.lstat(manifestPath).catch(cause => { if (cause.code !== 'ENOENT') throw cause; return null; });
55
+ if (!stat?.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 1_000_000
56
+ || process.platform !== 'win32' && (stat.uid !== process.getuid() || stat.mode & 0o077)) continue;
57
+ const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
58
+ if (!Number.isFinite(Date.parse(manifest.createdAt))) continue;
59
+ if (!verifySnapshotManifest(manifest, { snapshotId: entry.name, flowId: proof.flowId, stepId: manifest.stepId, repositoryDigest: repository }).verified) continue;
60
+ records.push({ name: entry.name, stepId: manifest.stepId, time: Date.parse(manifest.createdAt), bytes: await safeTree(snapshot) });
61
+ }
62
+ // Preserve the original rollback image and final image for each real step,
63
+ // including the still-referenced origin snapshot. Superseded copies are freed.
64
+ const retained = new Set(protectedIds);
65
+ for (const step of new Set(records.map(record => record.stepId))) {
66
+ const ordered = records.filter(record => record.stepId === step).sort((a, b) => a.time - b.time || a.name.localeCompare(b.name));
67
+ retained.add(ordered[0].name); retained.add(ordered.at(-1).name);
68
+ }
69
+ const removed = resumed ? [resumed.name] : []; let releasedBytes = resumed?.bytes ?? 0;
70
+ for (const record of records) if (!retained.has(record.name)) {
71
+ await removeSnapshotWithJournal(journal, directory, proof.flowId, record);
72
+ removed.push(record.name); releasedBytes += record.bytes;
73
+ }
74
+ return { removed, retained: [...retained].sort(), releasedBytes };
75
+ }
76
+ export async function cleanupCompletedSnapshots(call, result, { home = os.homedir() } = {}) {
77
+ const proof = completedProof(call, result);
78
+ if (!proof) return { status: 'preserved', reason: 'verified-original-completion-required' };
79
+ const root = await mutationStateRoot14({ home }), snapshotRoot = path.join(root, 'snapshots');
80
+ if (!await realDirectory(snapshotRoot)) return { status: 'completed', removed: 0, releasedBytes: 0 };
81
+ const records = [], recordDirectory = path.join(root, 'snapshot-cleanup');
82
+ await privateMutationDirectory14(recordDirectory);
83
+ const receiptPath = path.join(recordDirectory, `${sha256(proof.flowId)}.json`);
84
+ return withStateLock(receiptPath, async () => {
85
+ for (const entry of await fs.readdir(snapshotRoot, { withFileTypes: true })) {
86
+ if (entry.isDirectory() && !entry.isSymbolicLink() && /^[a-f0-9]{32}$/u.test(entry.name)) records.push({ repositoryDigest: entry.name,
87
+ ...await cleanupRepository(root, entry.name, proof) });
88
+ }
89
+ const receipt = { schemaVersion: 'entrorove.snapshot-cleanup/1.0', status: 'completed', ...proof, records,
90
+ removed: records.reduce((sum, item) => sum + item.removed.length, 0), releasedBytes: records.reduce((sum, item) => sum + item.releasedBytes, 0),
91
+ observedAt: new Date().toISOString() };
92
+ await atomicMutationJson14(receiptPath, receipt, { replace: true });
93
+ return receipt;
94
+ });
95
+ }
@@ -0,0 +1,28 @@
1
+ import { requestTransport, parseTransportResponse, trustedOrigin } from './remote-transport.mjs';
2
+ import { runCloudTask } from './cloud-task-client.mjs';
3
+ import { displayIdeEvent, ideClientHeaders, ideTaskIdentity } from './ide-presentation.mjs';
4
+
5
+ /** New IDE tasks use the authenticated account preference; resumes keep their original route. */
6
+ export async function routeTaskEntry(data, token, dependencies) {
7
+ if (data.operation !== 'start' || data.input?.sandboxTaskId) return null;
8
+ const choice = data.sandboxChoice;
9
+ if (choice !== undefined && !['use','local'].includes(choice)) throw new Error('sandboxChoice must be use or local');
10
+ if (choice === 'local') {
11
+ displayIdeEvent({ schemaVersion: 'entrorove.ide-event/1.0',kind: 'status',phase: 'route',
12
+ message: '本次采用本地方案。',separateLine: true },dependencies.onIdeEvent,{home:dependencies.home});
13
+ return null;
14
+ }
15
+ const response = await requestTransport(trustedOrigin(dependencies.apiOrigin()), '/api/cloud-sandbox', {
16
+ headers: { authorization: `Bearer ${token}`,...ideClientHeaders() },timeoutMs: 15000,
17
+ });
18
+ const preference = parseTransportResponse(response, 'cloud-sandbox preference');
19
+ if (!response.ok || typeof preference?.enabled !== 'boolean') {
20
+ throw new Error(`Cloud sandbox preference unavailable: HTTP ${response.status}. Task entry stopped.`);
21
+ }
22
+ if (!preference.enabled && choice === undefined) return null;
23
+ const task = data.input?.task;
24
+ if (!task || typeof task.goal !== 'string') throw new Error('Task entry requires the original task goal');
25
+ const ideTaskId = ideTaskIdentity(data.ideTaskId);
26
+ return runCloudTask({ goal: task.goal, task,
27
+ ...(ideTaskId ? { ideTaskId } : {}),...(choice ? { sandboxChoice: choice } : {}) },dependencies);
28
+ }
@@ -0,0 +1,2 @@
1
+ export function scopedTaskNumber(identity: string): string;
2
+ export function sandboxTaskNumber(taskId: string): string;
@@ -0,0 +1,9 @@
1
+ export function scopedTaskNumber(identity) {
2
+ let hash = 2166136261;
3
+ for (const character of identity) {
4
+ hash ^= character.codePointAt(0) ?? 0;
5
+ hash = Math.imul(hash, 16777619);
6
+ }
7
+ return String((hash >>> 0) % 100000).padStart(5, '0');
8
+ }
9
+ export function sandboxTaskNumber(taskId) { return scopedTaskNumber(`task:${taskId}`); }
@@ -0,0 +1,16 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import { resolve } from 'node:path';
4
+ const file = 'validator-runtime-v7.0.44.mjs';
5
+ export async function buildSkillValidator({ checkOnly = false } = {}) {
6
+ const source = await readFile(resolve(import.meta.dirname, '../../assets', file));
7
+ const target = resolve(import.meta.dirname, 'runtime', file);
8
+ if (checkOnly) {
9
+ const actual = await readFile(target).catch(() => null);
10
+ if (!actual?.equals(source)) throw new Error('Packaged Validator snapshot differs from the generated source');
11
+ } else { await mkdir(resolve(import.meta.dirname, 'runtime'), { recursive: true }); await writeFile(target, source); }
12
+ return { file, sha256: createHash('sha256').update(source).digest('hex') };
13
+ }
14
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
15
+ process.stdout.write(JSON.stringify(await buildSkillValidator({ checkOnly: process.argv.includes('--check') })) + '\n');
16
+ }