@lmzhen/dsh-evolution-merge 0.29.0 → 0.30.0

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 (97) hide show
  1. package/lib/index.js +1125 -568
  2. package/lib/types/adapters/channel.js +29 -0
  3. package/lib/types/adapters/engine-screen.d.ts +1 -1
  4. package/lib/types/adapters/engine-screen.js +45 -0
  5. package/lib/types/adapters/library.js +14 -0
  6. package/lib/types/adapters/model-runner.js +34 -0
  7. package/lib/types/adapters/select.d.ts +3 -0
  8. package/lib/types/adapters/select.js +35 -0
  9. package/lib/types/adapters/undo.js +35 -0
  10. package/lib/types/approve-command.js +42 -0
  11. package/lib/types/authoring-flow.js +48 -0
  12. package/lib/types/channel-port.js +74 -0
  13. package/lib/types/commands.d.ts +9 -0
  14. package/lib/types/commands.js +79 -0
  15. package/lib/types/commit-assembly.js +48 -0
  16. package/lib/types/commit-execute.js +52 -0
  17. package/lib/types/commit-plan.js +56 -0
  18. package/lib/types/commit-receipt.d.ts +2 -0
  19. package/lib/types/commit-receipt.js +85 -0
  20. package/lib/types/contracts.d.ts +14 -5
  21. package/lib/types/contracts.js +57 -0
  22. package/lib/types/coordinator.d.ts +3 -0
  23. package/lib/types/coordinator.js +131 -0
  24. package/lib/types/decision.js +61 -0
  25. package/lib/types/doctor.js +50 -0
  26. package/lib/types/domain/claim.d.ts +10 -0
  27. package/lib/types/domain/claim.js +12 -0
  28. package/lib/types/domain/commit-journal-store.d.ts +6 -0
  29. package/lib/types/domain/commit-journal-store.js +22 -0
  30. package/lib/types/domain/commit-journal.d.ts +16 -0
  31. package/lib/types/domain/commit-journal.js +25 -0
  32. package/lib/types/domain/errors.d.ts +10 -0
  33. package/lib/types/domain/errors.js +7 -0
  34. package/lib/types/domain/failure-route.d.ts +25 -0
  35. package/lib/types/domain/failure-route.js +22 -0
  36. package/lib/types/domain/indexes.d.ts +21 -0
  37. package/lib/types/domain/indexes.js +15 -0
  38. package/lib/types/domain/ledger.d.ts +17 -0
  39. package/lib/types/domain/ledger.js +65 -0
  40. package/lib/types/domain/markdown.d.ts +2 -0
  41. package/lib/types/domain/markdown.js +57 -0
  42. package/lib/types/domain/migrate-v1.d.ts +41 -0
  43. package/lib/types/domain/migrate-v1.js +23 -0
  44. package/lib/types/domain/pair-index-store.d.ts +6 -0
  45. package/lib/types/domain/pair-index-store.js +25 -0
  46. package/lib/types/domain/pair-index.d.ts +9 -0
  47. package/lib/types/domain/pair-index.js +28 -0
  48. package/lib/types/domain/resolve-pair.d.ts +11 -0
  49. package/lib/types/domain/resolve-pair.js +17 -0
  50. package/lib/types/domain/row.d.ts +113 -0
  51. package/lib/types/domain/row.js +111 -0
  52. package/lib/types/domain/run-record.d.ts +45 -0
  53. package/lib/types/domain/run-record.js +24 -0
  54. package/lib/types/domain/state.d.ts +57 -0
  55. package/lib/types/domain/state.js +65 -0
  56. package/lib/types/gate-cache.js +36 -0
  57. package/lib/types/index.js +241 -0
  58. package/lib/types/ladder.js +162 -0
  59. package/lib/types/merge-command.js +43 -0
  60. package/lib/types/merge-routes.d.ts +14 -4
  61. package/lib/types/merge-routes.js +99 -0
  62. package/lib/types/merge-tool.js +69 -0
  63. package/lib/types/new-child-authoring.js +83 -0
  64. package/lib/types/pair-command.js +18 -0
  65. package/lib/types/params.js +49 -0
  66. package/lib/types/pending-actions.js +28 -0
  67. package/lib/types/prompts.js +60 -0
  68. package/lib/types/read-commands.js +32 -0
  69. package/lib/types/report.js +70 -0
  70. package/lib/types/resume-command.js +57 -0
  71. package/lib/types/round2-outcome.js +50 -0
  72. package/lib/types/routes.d.ts +5 -2
  73. package/lib/types/routes.js +75 -0
  74. package/lib/types/rules.js +55 -0
  75. package/lib/types/run-shell.d.ts +7 -0
  76. package/lib/types/run-shell.js +69 -0
  77. package/lib/types/runtime.d.ts +2 -0
  78. package/lib/types/runtime.js +269 -0
  79. package/lib/types/scan-command.d.ts +1 -0
  80. package/lib/types/scan-command.js +30 -0
  81. package/lib/types/scan-ports.js +93 -0
  82. package/lib/types/scan-run.d.ts +2 -0
  83. package/lib/types/scan-run.js +186 -0
  84. package/lib/types/skill-card.js +2 -0
  85. package/lib/types/skipped.js +24 -0
  86. package/lib/types/stage-tool-registry.js +32 -0
  87. package/lib/types/stage-tool.js +31 -0
  88. package/lib/types/staged-tree.js +75 -0
  89. package/lib/types/staging-files.js +86 -0
  90. package/lib/types/staging.d.ts +7 -0
  91. package/lib/types/staging.js +149 -0
  92. package/lib/types/state.js +103 -0
  93. package/lib/types/status.d.ts +2 -1
  94. package/lib/types/status.js +28 -0
  95. package/lib/types/supersede.js +14 -0
  96. package/lib/types/undo-merge.js +23 -0
  97. package/package.json +4 -4
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The scan pair loop. Registry/report lifecycle stays in withMergeRun; this module owns
3
+ * candidate → round1 → pending/fuse → commit transitions and returns the report draft.
4
+ */
5
+ import { pairKey, } from "./contracts.js";
6
+ import { transition } from "./coordinator.js";
7
+ import { dropRow, loadMergeState, upsertRow } from "./state.js";
8
+ import { recordPair } from "./domain/pair-index-store.js";
9
+ const isAborted = (signal) => signal.aborted;
10
+ function at(now) {
11
+ return new Date(now()).toISOString();
12
+ }
13
+ function failureOf(stage, error) {
14
+ return {
15
+ stage,
16
+ code: 'merge-failed',
17
+ message: error instanceof Error ? error.message : String(error),
18
+ retryable: true,
19
+ retryFrom: stage === 'decide' ? 'agent1' : 'agent2',
20
+ };
21
+ }
22
+ function pairOf(row, candidate, outcome, extra = {}) {
23
+ return {
24
+ id: row.id,
25
+ pairKey: row.pairKey,
26
+ a: row.a,
27
+ b: row.b,
28
+ queue: row.queue,
29
+ candidate,
30
+ ...(candidate.gate === undefined ? {} : { gate: candidate.gate }),
31
+ outcome,
32
+ archived: [],
33
+ ...extra,
34
+ };
35
+ }
36
+ export async function runScan(options) {
37
+ const stage = options.stage ?? (async () => { });
38
+ const candidates = await options.ports.screening(options.signal);
39
+ const before = await loadMergeState(options.io, options.home);
40
+ const queueBefore = { verify: 0, pending: 0, fuse: 0, skipped: 0 };
41
+ for (const row of before.state.rows)
42
+ queueBefore[row.queue] += 1;
43
+ const startedAt = options.startedAt ?? options.now();
44
+ const timeoutMs = (options.timeoutMinutes ?? 0) > 0 ? options.timeoutMinutes * 60_000 : 0;
45
+ const pairs = [];
46
+ const totals = { merged: 0, kept: 0, pending: 0, failed: 0 };
47
+ const remaining = [];
48
+ let stopReason = 'completed';
49
+ for (let index = 0; index < candidates.length; index += 1) {
50
+ if (isAborted(options.signal)) {
51
+ stopReason = 'cancelled';
52
+ remaining.push(...candidates.slice(index).map(candidate => ({ pairKey: pairKey(candidate.a, candidate.b), queue: 'verify' })));
53
+ break;
54
+ }
55
+ if (timeoutMs > 0 && options.now() - startedAt >= timeoutMs) {
56
+ stopReason = 'timeout';
57
+ remaining.push(...candidates.slice(index).map(candidate => ({ pairKey: pairKey(candidate.a, candidate.b), queue: 'verify' })));
58
+ break;
59
+ }
60
+ const candidate = candidates[index];
61
+ await stage('decide', { done: index, total: candidates.length });
62
+ let row;
63
+ try {
64
+ const created = transition(undefined, { type: 'candidate', a: candidate.a, b: candidate.b, at: at(options.now) });
65
+ if (!created.ok || created.row === null)
66
+ throw new Error(created.ok ? 'candidate row missing' : created.message);
67
+ row = created.row;
68
+ await upsertRow(options.io, options.home, options.now, row);
69
+ }
70
+ catch (error) {
71
+ totals.failed += 1;
72
+ pairs.push({
73
+ id: '', pairKey: pairKey(candidate.a, candidate.b), a: candidate.a, b: candidate.b, queue: 'verify',
74
+ candidate, outcome: 'failed', archived: [], failure: failureOf('screening', error),
75
+ });
76
+ continue;
77
+ }
78
+ let round1;
79
+ try {
80
+ round1 = await options.ports.round1(candidate, options.signal);
81
+ }
82
+ catch (error) {
83
+ round1 = { kind: 'failed', failure: failureOf('decide', error) };
84
+ }
85
+ if (isAborted(options.signal)) {
86
+ stopReason = 'cancelled';
87
+ remaining.push(...candidates.slice(index).map(candidate => ({ pairKey: pairKey(candidate.a, candidate.b), queue: 'verify' })));
88
+ break;
89
+ }
90
+ if (round1.kind === 'failed') {
91
+ const next = transition(row, { type: 'round1-fail', failure: round1.failure ?? failureOf('decide', 'round1 failed'), at: at(options.now) });
92
+ if (next.ok && next.row !== null) {
93
+ row = next.row;
94
+ await upsertRow(options.io, options.home, options.now, row);
95
+ }
96
+ pairs.push(pairOf(row, candidate, 'failed', { failure: round1.failure ?? failureOf('decide', 'round1 failed') }));
97
+ totals.failed += 1;
98
+ continue;
99
+ }
100
+ if (round1.kind === 'keep') {
101
+ const next = transition(row, { type: 'round1-keep', at: at(options.now) });
102
+ if (next.ok && next.row !== null) {
103
+ row = next.row;
104
+ await upsertRow(options.io, options.home, options.now, row);
105
+ await recordPair(options.io, options.home, { pairKey: row.pairKey, runId: row.runId ?? 'merge', result: 'kept' });
106
+ }
107
+ pairs.push(pairOf(row, candidate, 'kept', round1.decision === undefined ? {} : { decision: round1.decision }));
108
+ totals.kept += 1;
109
+ continue;
110
+ }
111
+ if (round1.decision === undefined) {
112
+ pairs.push(pairOf(row, candidate, 'failed', { failure: failureOf('decide', 'merge decision missing') }));
113
+ totals.failed += 1;
114
+ continue;
115
+ }
116
+ if (options.approval) {
117
+ const next = transition(row, { type: 'round1-merge', approval: 'on', decision: round1.decision, ...(round1.sourceRevision === undefined ? {} : { sourceRevision: round1.sourceRevision }), ...(round1.targetRevision === undefined ? {} : { targetRevision: round1.targetRevision }), at: at(options.now) });
118
+ if (next.ok && next.row !== null) {
119
+ row = next.row;
120
+ await upsertRow(options.io, options.home, options.now, row);
121
+ }
122
+ pairs.push(pairOf(row, candidate, 'pending', { decision: round1.decision }));
123
+ totals.pending += 1;
124
+ continue;
125
+ }
126
+ const fuse = transition(row, { type: 'round1-merge', approval: 'off', decision: round1.decision, ...(round1.stagingRef === undefined ? {} : { stagingRef: round1.stagingRef }), ...(round1.stagingExpiresAt === undefined ? {} : { stagingExpiresAt: round1.stagingExpiresAt }), at: at(options.now) });
127
+ if (!fuse.ok || fuse.row === null) {
128
+ pairs.push(pairOf(row, candidate, 'failed', { decision: round1.decision, failure: failureOf('decide', 'cannot enter fuse') }));
129
+ totals.failed += 1;
130
+ continue;
131
+ }
132
+ row = fuse.row;
133
+ await upsertRow(options.io, options.home, options.now, row);
134
+ if (isAborted(options.signal)) {
135
+ const cancelled = transition(row, { type: 'round2-cancel', at: at(options.now) });
136
+ if (cancelled.ok && cancelled.row !== null) {
137
+ row = cancelled.row;
138
+ await upsertRow(options.io, options.home, options.now, row);
139
+ }
140
+ stopReason = 'cancelled';
141
+ remaining.push(...candidates.slice(index).map(candidate => ({ pairKey: pairKey(candidate.a, candidate.b), queue: 'fuse' })));
142
+ break;
143
+ }
144
+ await stage('author', { done: index, total: candidates.length });
145
+ let round2;
146
+ try {
147
+ round2 = await options.ports.round2(row, round1.decision, options.signal);
148
+ }
149
+ catch (error) {
150
+ round2 = { kind: 'failed', failure: failureOf('author', error) };
151
+ }
152
+ if (round2.kind === 'merged') {
153
+ const success = transition(row, { type: 'round2-success', at: at(options.now) });
154
+ if (success.ok)
155
+ await dropRow(options.io, options.home, options.now, row.id);
156
+ pairs.push(pairOf(row, candidate, 'merged', {
157
+ decision: round1.decision,
158
+ ...(round2.unitId === undefined ? {} : { unitId: round2.unitId }),
159
+ ...(round2.target === undefined ? {} : { target: round2.target }),
160
+ archived: round2.archived ?? [],
161
+ }));
162
+ totals.merged += 1;
163
+ continue;
164
+ }
165
+ const failed = round2.failure ?? failureOf('author', 'round2 failed');
166
+ const next = transition(row, { type: 'round2-fail', failure: failed, at: at(options.now) });
167
+ if (next.ok && next.row !== null) {
168
+ row = next.row;
169
+ await upsertRow(options.io, options.home, options.now, row);
170
+ }
171
+ pairs.push(pairOf(row, candidate, 'failed', { decision: round1.decision, failure: failed }));
172
+ totals.failed += 1;
173
+ }
174
+ return {
175
+ stopReason,
176
+ scope: {
177
+ candidatePairs: candidates.candidatePairs ?? candidates.length,
178
+ queueBefore,
179
+ },
180
+ pairs,
181
+ totals,
182
+ notes: [],
183
+ ...(remaining.length === 0 ? {} : { remaining }),
184
+ };
185
+ }
186
+ //# sourceMappingURL=scan-run.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=skill-card.js.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Skipped filtering: a pair the operator marked as not-worth-merging stays out of automatic
3
+ * screening until mergeSkippedTtlDays expires. Explicit /merge pair ignores this set.
4
+ */
5
+ import { pairKey } from "./contracts.js";
6
+ export function skippedPairKeys(rows, ttlDays, now) {
7
+ const keys = new Set();
8
+ for (const row of rows) {
9
+ if (row.state !== 'skipped')
10
+ continue;
11
+ if (ttlDays <= 0) {
12
+ keys.add(row.pairKey);
13
+ continue;
14
+ }
15
+ const createdAt = Date.parse(row.createdAt);
16
+ if (!Number.isFinite(createdAt) || now - createdAt < ttlDays * 86_400_000)
17
+ keys.add(row.pairKey);
18
+ }
19
+ return keys;
20
+ }
21
+ export function isSkippedPair(keys, a, b) {
22
+ return keys.has(pairKey(a, b));
23
+ }
24
+ //# sourceMappingURL=skipped.js.map
@@ -0,0 +1,32 @@
1
+ export class StageToolRegistry {
2
+ bindings = new Map();
3
+ bind(childId, context) {
4
+ this.bindings.set(childId, context);
5
+ }
6
+ unbind(childId) {
7
+ this.bindings.delete(childId);
8
+ }
9
+ clear() {
10
+ this.bindings.clear();
11
+ }
12
+ resolve(exec) {
13
+ const childId = childIdOf(exec);
14
+ return childId === undefined ? undefined : this.bindings.get(childId);
15
+ }
16
+ }
17
+ function childIdOf(exec) {
18
+ if (typeof exec !== 'object' || exec === null)
19
+ return undefined;
20
+ const agent = exec.agent;
21
+ if (typeof agent !== 'object' || agent === null)
22
+ return undefined;
23
+ const session = agent.session;
24
+ if (typeof session === 'object' && session !== null) {
25
+ const id = session.id;
26
+ if (typeof id === 'string')
27
+ return id;
28
+ }
29
+ const sessionId = agent.sessionId;
30
+ return typeof sessionId === 'string' ? sessionId : undefined;
31
+ }
32
+ //# sourceMappingURL=stage-tool-registry.js.map
@@ -0,0 +1,31 @@
1
+ import { copyStagedFile, listStagedFiles, readStagedFile, writeStagedFile, } from "./staging-files.js";
2
+ export async function applyStageTool(io, home, ctx, op) {
3
+ if (ctx.childId !== ctx.expectedChildId)
4
+ return { ok: false, code: 'not-merge-child', message: 'merge_stage is only available to the bound merge child' };
5
+ if (op.op === 'write' || op.op === 'copy') {
6
+ if (ctx.phase !== 'author')
7
+ return { ok: false, code: 'phase', message: 'merge_stage write/copy is only allowed in the author phase' };
8
+ }
9
+ if (op.op === 'list') {
10
+ const result = await listStagedFiles(io, home, ctx.runId, op.area, op.path ?? '', op.source);
11
+ return probeResult(result, 'list');
12
+ }
13
+ if (op.op === 'read') {
14
+ const result = await readStagedFile(io, home, ctx.runId, op.path, op.area, op.source);
15
+ return probeResult(result, 'read');
16
+ }
17
+ if (op.op === 'write') {
18
+ const result = await writeStagedFile(io, home, ctx.runId, op.path, op.content);
19
+ return result.ok ? { ok: true, value: { path: result.path } } : { ok: false, code: 'io', message: result.message };
20
+ }
21
+ const result = await copyStagedFile(io, home, ctx.runId, op.source, op.from, op.to);
22
+ return result.ok ? { ok: true, value: { path: result.path } } : { ok: false, code: 'io', message: result.message };
23
+ }
24
+ function probeResult(probe, what) {
25
+ if (probe.kind === 'present')
26
+ return { ok: true, value: probe.value };
27
+ if (probe.kind === 'absent')
28
+ return { ok: false, code: 'not-found', message: what + ': not found' };
29
+ return { ok: false, code: 'io', message: what + ': ' + probe.reason };
30
+ }
31
+ //# sourceMappingURL=stage-tool.js.map
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Read the merged tree a child wrote into a staging run.
3
+ *
4
+ * The tree is the commit source: SKILL.md is the body, every other file is a support file.
5
+ * A missing SKILL.md is unknown, never an empty merge; the caller then refuses commit.
6
+ */
7
+ import { join } from 'node:path';
8
+ import { probeList, probeUnknown, } from '@lmzhen/dsh-evolution-core';
9
+ import { readStaging, stagingRoot } from "./staging.js";
10
+ function safeRelative(path) {
11
+ if (path === '' || path.startsWith('/') || path.startsWith('\\') || path.includes('\0'))
12
+ return false;
13
+ return path.replace(/\\/g, '/').split('/').every(part => part !== '' && part !== '.' && part !== '..');
14
+ }
15
+ async function walkFiles(io, root, rel = '') {
16
+ const abs = rel === '' ? root : join(root, ...rel.split('/'));
17
+ try {
18
+ const text = await io.readText(abs);
19
+ if (text !== null)
20
+ return [rel];
21
+ }
22
+ catch { /* a directory read throws; fall through to list */ }
23
+ const listed = await probeList(io, abs);
24
+ if (listed.kind === 'unknown')
25
+ throw new Error('cannot list ' + abs + ': ' + listed.reason);
26
+ if (listed.kind === 'absent')
27
+ return [];
28
+ const out = [];
29
+ for (const entry of listed.value) {
30
+ if (entry === '' || entry === '.' || entry === '..')
31
+ continue;
32
+ const child = rel === '' ? entry : rel + '/' + entry;
33
+ if (!safeRelative(child))
34
+ continue;
35
+ out.push(...await walkFiles(io, root, child));
36
+ }
37
+ return out;
38
+ }
39
+ export async function readStagedTree(io, home, runId) {
40
+ const staging = await readStaging(io, home, runId);
41
+ if (staging.kind === 'absent')
42
+ return { kind: 'absent' };
43
+ if (staging.kind === 'unknown')
44
+ return probeUnknown(staging.reason);
45
+ const target = staging.value.manifest.target;
46
+ const root = join(stagingRoot(home, runId), 'merged', target);
47
+ let paths;
48
+ try {
49
+ paths = await walkFiles(io, root);
50
+ }
51
+ catch (error) {
52
+ return probeUnknown('cannot list merged tree: ' + (error instanceof Error ? error.message : String(error)));
53
+ }
54
+ if (!paths.includes('SKILL.md'))
55
+ return probeUnknown('merged tree has no SKILL.md');
56
+ const files = [];
57
+ for (const path of paths.sort()) {
58
+ if (path === 'SKILL.md')
59
+ continue;
60
+ try {
61
+ const content = await io.readText(join(root, ...path.split('/')));
62
+ if (content === null)
63
+ return probeUnknown('merged file disappeared: ' + path);
64
+ files.push({ path, content });
65
+ }
66
+ catch (error) {
67
+ return probeUnknown('cannot read merged file ' + path + ': ' + (error instanceof Error ? error.message : String(error)));
68
+ }
69
+ }
70
+ const body = await io.readText(join(root, 'SKILL.md'));
71
+ if (body === null)
72
+ return probeUnknown('merged SKILL.md disappeared');
73
+ return { kind: 'present', value: { target, body, files } };
74
+ }
75
+ //# sourceMappingURL=staged-tree.js.map
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Staged file operations used by the authoring tool.
3
+ *
4
+ * The child never receives a general filesystem write. Every path is resolved under
5
+ * <run>/sources/<source>/ or <run>/merged/<target>/ and must stay there; the staging
6
+ * manifest supplies the target name. Reads are three-state, writes are atomic at the
7
+ * file level, and copy only goes sources -> merged.
8
+ */
9
+ import { join } from 'node:path';
10
+ import { isPresent, probeUnknown, } from '@lmzhen/dsh-evolution-core';
11
+ import { readStaging, stagingRoot } from "./staging.js";
12
+ function safeRelative(path) {
13
+ if (path === '' || path.startsWith('/') || path.startsWith('\\') || path.includes('\0'))
14
+ return false;
15
+ return path.replace(/\\/g, '/').split('/').every(part => part !== '' && part !== '.' && part !== '..');
16
+ }
17
+ function areaRoot(home, runId, target, area, source) {
18
+ const root = stagingRoot(home, runId);
19
+ if (area === 'merged')
20
+ return join(root, 'merged', target);
21
+ if (source === undefined || source === '')
22
+ return null;
23
+ return join(root, 'sources', source);
24
+ }
25
+ export async function writeStagedFile(io, home, runId, path, content, source) {
26
+ if (!safeRelative(path))
27
+ return { ok: false, message: 'unsafe staged path: ' + path };
28
+ const staging = await readStaging(io, home, runId);
29
+ if (!isPresent(staging))
30
+ return { ok: false, message: 'staging is not present for run ' + runId };
31
+ const root = areaRoot(home, runId, staging.value.manifest.target, 'merged', source);
32
+ if (root === null)
33
+ return { ok: false, message: 'staged write requires merged area' };
34
+ const target = join(root, ...path.split('/'));
35
+ try {
36
+ await io.writeText(target, content);
37
+ return { ok: true, path };
38
+ }
39
+ catch (error) {
40
+ return { ok: false, message: 'staged write failed: ' + (error instanceof Error ? error.message : String(error)) };
41
+ }
42
+ }
43
+ export async function readStagedFile(io, home, runId, path, area, source) {
44
+ if (!safeRelative(path))
45
+ return probeUnknown('unsafe staged path: ' + path);
46
+ const staging = await readStaging(io, home, runId);
47
+ if (!isPresent(staging))
48
+ return staging.kind === 'absent' ? { kind: 'absent' } : probeUnknown(staging.reason);
49
+ const root = areaRoot(home, runId, staging.value.manifest.target, area, source);
50
+ if (root === null)
51
+ return probeUnknown('missing source name for sources area');
52
+ try {
53
+ const value = await io.readText(join(root, ...path.split('/')));
54
+ return value === null ? { kind: 'absent' } : { kind: 'present', value };
55
+ }
56
+ catch (error) {
57
+ return probeUnknown('staged read failed: ' + (error instanceof Error ? error.message : String(error)));
58
+ }
59
+ }
60
+ export async function listStagedFiles(io, home, runId, area, path = '', source) {
61
+ if (path !== '' && !safeRelative(path))
62
+ return probeUnknown('unsafe staged path: ' + path);
63
+ const staging = await readStaging(io, home, runId);
64
+ if (!isPresent(staging))
65
+ return staging.kind === 'absent' ? { kind: 'absent' } : probeUnknown(staging.reason);
66
+ const root = areaRoot(home, runId, staging.value.manifest.target, area, source);
67
+ if (root === null)
68
+ return probeUnknown('missing source name for sources area');
69
+ try {
70
+ const entries = await io.list(join(root, ...path.split('/').filter(Boolean)));
71
+ return { kind: 'present', value: [...entries].sort() };
72
+ }
73
+ catch (error) {
74
+ return probeUnknown('staged list failed: ' + (error instanceof Error ? error.message : String(error)));
75
+ }
76
+ }
77
+ /** Copy one source file into the merged target; the only cross-area operation allowed. */
78
+ export async function copyStagedFile(io, home, runId, source, from, to) {
79
+ if (!safeRelative(from) || !safeRelative(to))
80
+ return { ok: false, message: 'unsafe staged copy path' };
81
+ const sourceFile = await readStagedFile(io, home, runId, from, 'sources', source);
82
+ if (!isPresent(sourceFile))
83
+ return { ok: false, message: 'source file is not readable: ' + from };
84
+ return await writeStagedFile(io, home, runId, to, sourceFile.value);
85
+ }
86
+ //# sourceMappingURL=staging-files.js.map
@@ -46,4 +46,11 @@ export declare function readStaging(io: EvolutionIoLike, home: string, runId: st
46
46
  export declare function ensureStaging(io: EvolutionIoLike, home: string, input: CreateStagingInput): Promise<CreateStagingResult>;
47
47
  export declare function setStagingTarget(io: EvolutionIoLike, home: string, stageKey: string, target: string, mode?: 'merge-into-existing' | 'create-umbrella' | 'keep'): Promise<boolean>;
48
48
  export declare function dropStaging(io: EvolutionIoLike, home: string, stageKey: string): Promise<void>;
49
+ export interface StagingCleanupResult {
50
+ readonly removed: readonly string[];
51
+ readonly kept: readonly string[];
52
+ readonly unknown: readonly string[];
53
+ }
54
+ /** Remove expired run-level staging trees; `keep` names runs a live row still points at. */
55
+ export declare function cleanupExpiredStaging(io: EvolutionIoLike, home: string, now: number, keep?: ReadonlySet<string>): Promise<StagingCleanupResult>;
49
56
  //# sourceMappingURL=staging.d.ts.map
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Staging is a run ARTIFACT, not a queue: <home>/merge/runs/<runId>/.
3
+ *
4
+ * createStaging writes a sibling .partial tree first and renames it into place; the .stage
5
+ * marker is the last write inside the partial. A directory without that marker is a
6
+ * half-created artifact and reads as unknown, never as "empty".
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ import { join } from 'node:path';
10
+ import { isPresent, probeAbsent, probePresent, probeText, probeUnknown, } from '@lmzhen/dsh-evolution-core';
11
+ export const STAGING_SCHEMA_VERSION = 1;
12
+ export function stageKeyOf(runId, pairKey) {
13
+ return runId + '/pairs/' + createHash('sha256').update(pairKey).digest('hex').slice(0, 16);
14
+ }
15
+ export function stagingRoot(home, runId) {
16
+ return join(home, 'merge', 'runs', runId);
17
+ }
18
+ /** Relative support paths only; the staging tree must never escape its run directory. */
19
+ function safeRelative(path) {
20
+ if (path === '' || path.startsWith('/') || path.startsWith('\\') || path.includes('\0'))
21
+ return false;
22
+ return path.replace(/\\/g, '/').split('/').every(part => part !== '' && part !== '.' && part !== '..');
23
+ }
24
+ function manifestText(manifest) {
25
+ return JSON.stringify(manifest, null, 2) + '\n';
26
+ }
27
+ export async function createStaging(io, home, input) {
28
+ const final = stagingRoot(home, input.runId);
29
+ if (await io.exists(final))
30
+ return { ok: false, message: 'staging already exists for run ' + input.runId };
31
+ const names = new Set();
32
+ for (const source of input.sources) {
33
+ if (names.has(source.name))
34
+ return { ok: false, message: 'duplicate source ' + source.name };
35
+ names.add(source.name);
36
+ for (const file of source.files) {
37
+ if (!safeRelative(file.path))
38
+ return { ok: false, message: 'unsafe support path: ' + file.path };
39
+ }
40
+ }
41
+ const partial = final + '.partial';
42
+ try {
43
+ await io.remove(partial);
44
+ for (const source of input.sources) {
45
+ const dir = join(partial, 'sources', source.name);
46
+ await io.writeText(join(dir, 'SKILL.md'), source.body);
47
+ for (const file of source.files) {
48
+ await io.writeText(join(dir, ...file.path.split('/')), file.content);
49
+ }
50
+ }
51
+ const at = input.now();
52
+ const manifest = {
53
+ schemaVersion: STAGING_SCHEMA_VERSION,
54
+ runId: input.runId,
55
+ target: input.target,
56
+ ...(input.mode === undefined ? {} : { mode: input.mode }),
57
+ sources: input.sources.map(source => source.name),
58
+ promptVersion: input.promptVersion,
59
+ createdAt: at,
60
+ expiresAt: at + Math.max(0, input.ttlDays) * 86_400_000,
61
+ };
62
+ await io.writeText(join(partial, 'manifest.json'), manifestText(manifest));
63
+ // Marker last: its absence is the half-created signal readStaging reports as unknown.
64
+ await io.writeText(join(partial, '.stage'), '');
65
+ await io.rename(partial, final);
66
+ return { ok: true, path: final, manifest };
67
+ }
68
+ catch (error) {
69
+ try {
70
+ await io.remove(partial);
71
+ }
72
+ catch { /* best effort */ }
73
+ return { ok: false, message: 'staging create failed: ' + (error instanceof Error ? error.message : String(error)) };
74
+ }
75
+ }
76
+ export async function readStaging(io, home, runId) {
77
+ const path = stagingRoot(home, runId);
78
+ if (!await io.exists(path))
79
+ return probeAbsent();
80
+ if (!await io.exists(join(path, '.stage')))
81
+ return probeUnknown('staging ' + runId + ' has no .stage marker (half-created)');
82
+ const probe = await probeText(io, join(path, 'manifest.json'));
83
+ if (!isPresent(probe))
84
+ return probe;
85
+ try {
86
+ const parsed = JSON.parse(probe.value);
87
+ if (parsed.schemaVersion !== STAGING_SCHEMA_VERSION || parsed.runId !== runId || typeof parsed.target !== 'string' || !Array.isArray(parsed.sources)) {
88
+ return probeUnknown('staging manifest has an unexpected shape');
89
+ }
90
+ return probePresent({ path, manifest: parsed });
91
+ }
92
+ catch (error) {
93
+ return probeUnknown('staging manifest is not JSON: ' + (error instanceof Error ? error.message : String(error)));
94
+ }
95
+ }
96
+ export async function ensureStaging(io, home, input) {
97
+ const existing = await readStaging(io, home, input.runId);
98
+ if (existing.kind === 'present')
99
+ return { ok: true, path: stagingRoot(home, input.runId), manifest: existing.value.manifest };
100
+ return createStaging(io, home, input);
101
+ }
102
+ export async function setStagingTarget(io, home, stageKey, target, mode) {
103
+ const view = await readStaging(io, home, stageKey);
104
+ if (view.kind !== 'present')
105
+ return false;
106
+ const manifest = Object.assign({}, view.value.manifest, { target }, mode === undefined ? {} : { mode });
107
+ await io.writeText(join(stagingRoot(home, stageKey), 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
108
+ return true;
109
+ }
110
+ export async function dropStaging(io, home, stageKey) {
111
+ await io.remove(stagingRoot(home, stageKey));
112
+ }
113
+ /** Remove expired run-level staging trees; `keep` names runs a live row still points at. */
114
+ export async function cleanupExpiredStaging(io, home, now, keep = new Set()) {
115
+ let runs;
116
+ try {
117
+ runs = await io.list(join(home, 'merge', 'runs'));
118
+ }
119
+ catch {
120
+ return { removed: [], kept: [], unknown: [] };
121
+ }
122
+ const removed = [];
123
+ const kept = [];
124
+ const unknown = [];
125
+ for (const runId of runs.sort()) {
126
+ if (keep.has(runId)) {
127
+ kept.push(runId);
128
+ continue;
129
+ }
130
+ const view = await readStaging(io, home, runId);
131
+ if (view.kind === 'unknown') {
132
+ unknown.push(runId);
133
+ continue;
134
+ }
135
+ if (view.kind !== 'present' || view.value.manifest.expiresAt > now) {
136
+ kept.push(runId);
137
+ continue;
138
+ }
139
+ try {
140
+ await io.remove(stagingRoot(home, runId));
141
+ removed.push(runId);
142
+ }
143
+ catch {
144
+ unknown.push(runId);
145
+ }
146
+ }
147
+ return { removed, kept, unknown };
148
+ }
149
+ //# sourceMappingURL=staging.js.map