@khoavu882/doflow 2.0.0 → 2.0.1

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 (113) hide show
  1. package/README.md +4 -5
  2. package/bin/doflow.js +1 -1
  3. package/core/.claude-plugin/plugin.json +1 -1
  4. package/core/.codex-plugin/plugin.json +1 -1
  5. package/core/.plugin/plugin.json +1 -1
  6. package/core/harnesses/claude/hooks/mcp-tool-guard.sh +4 -1
  7. package/core/harnesses/claude/hooks/pre-implement-gate.sh +4 -1
  8. package/core/harnesses/claude/settings/settings.json +8 -9
  9. package/core/harnesses/codex/hooks/hooks.json +0 -10
  10. package/core/harnesses/codex/hooks/mcp-tool-guard.sh +4 -1
  11. package/core/harnesses/codex/hooks/pre-implement-gate.sh +4 -1
  12. package/core/harnesses/copilot/hooks/doflow-lib.sh +4 -3
  13. package/core/harnesses/copilot/hooks/doflow.json +0 -6
  14. package/core/harnesses/kiro/agents/doflow.json +0 -10
  15. package/core/harnesses/kiro/hooks/doflow.json +0 -10
  16. package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +7 -3
  17. package/core/harnesses/kiro/hooks/pre-implement-gate.sh +7 -3
  18. package/core/harnesses/pi/extensions/doflow-guard.js +3 -2
  19. package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +1 -1
  20. package/core/harnesses/shared/hooks/adapters/antigravity.js +1 -1
  21. package/core/harnesses/shared/hooks/bridge/bridge.sh +35 -25
  22. package/core/harnesses/shared/hooks/bridge/envelope.jq +1 -1
  23. package/core/harnesses/shared/hooks/bridge/respond.sh +6 -0
  24. package/core/harnesses/shared/hooks/policies/capture-failure.sh +1 -1
  25. package/core/harnesses/shared/hooks/policies/deny-json.sh +23 -1
  26. package/core/harnesses/shared/hooks/policies/mcp-policy.conf +1 -1
  27. package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +4 -6
  28. package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +20 -21
  29. package/core/harnesses/shared/hooks/policies/stop-check.sh +7 -6
  30. package/core/harnesses/shared/hooks/policies/subagent-audit.sh +1 -1
  31. package/core/harnesses/shared/hooks/stream-hook-runner.js +11 -12
  32. package/core/registry/contracts.json +1 -1
  33. package/core/registry/contribution-points.json +5 -2
  34. package/core/registry/gates.json +5 -5
  35. package/core/registry/harnesses.json +9 -9
  36. package/core/registry/intake.json +53 -0
  37. package/core/registry/lifecycle.json +133 -711
  38. package/core/registry/package-templates/intake-category/files/category.json +8 -0
  39. package/core/registry/package-templates/intake-category/template.json +4 -0
  40. package/core/registry/packages/doflow/doflow-package.json +2 -2
  41. package/core/registry/rules.json +8 -8
  42. package/core/registry/settings.json +0 -4
  43. package/core/registry/storage.json +8 -0
  44. package/core/registry/telemetry.json +1 -1
  45. package/core/registry/verification.json +0 -5
  46. package/core/shared/guidance/references/DOFLOW_CHAIN.md +4 -4
  47. package/core/shared/guidance/references/ORCHESTRATION.md +2 -0
  48. package/core/shared/guidance/references/WORKFLOW_HANDOFF.md +19 -1
  49. package/core/shared/guidance/rules/RULES.md +2 -2
  50. package/core/shared/scripts/doflow/bash/validate-artifacts.sh +1 -1
  51. package/core/shared/scripts/doflow/bin/doflow-run +5 -2
  52. package/core/shared/skills/do/SKILL.md +3 -3
  53. package/core/shared/skills/do-brainstorm/SKILL.md +26 -24
  54. package/core/shared/skills/do-brainstorm/references/handoff_branches.md +2 -1
  55. package/core/shared/skills/do-code-review/SKILL.md +7 -4
  56. package/core/shared/skills/do-design/SKILL.md +5 -3
  57. package/core/shared/skills/do-diagnose/SKILL.md +4 -2
  58. package/core/shared/skills/do-document/SKILL.md +1 -0
  59. package/core/shared/skills/do-document/references/embedded-research.md +1 -1
  60. package/core/shared/skills/do-execute-plan/SKILL.md +4 -2
  61. package/core/shared/skills/do-execute-plan/references/run_position.md +3 -1
  62. package/core/shared/skills/do-execute-plan/references/scaffold.md +1 -1
  63. package/core/shared/skills/do-flow/SKILL.md +4 -4
  64. package/core/shared/skills/do-flow/references/task_classes.md +5 -5
  65. package/core/shared/skills/do-implement/SKILL.md +9 -7
  66. package/core/shared/skills/do-plan/SKILL.md +9 -6
  67. package/core/shared/workflows/do-flow-chain.md +2 -0
  68. package/package.json +1 -1
  69. package/src/adapters/claude/index.js +23 -2
  70. package/src/adapters/codex/hooks.js +39 -3
  71. package/src/adapters/codex/index.js +13 -0
  72. package/src/adapters/copy-tree.js +1 -2
  73. package/src/cli/commands/status.js +1 -1
  74. package/src/cli/index.js +20 -8
  75. package/src/cli/runtime-commands.js +12 -4
  76. package/src/lifecycle/coverage.js +1 -30
  77. package/src/lifecycle/hook-tier.js +2 -2
  78. package/src/release/docs-blocks.js +32 -52
  79. package/src/runtime/context/pointer.js +67 -12
  80. package/src/runtime/context-pack.js +4 -1
  81. package/src/runtime/evidence-ledger.js +18 -0
  82. package/src/runtime/health.js +59 -1
  83. package/src/runtime/implementation-gate.js +4 -4
  84. package/src/runtime/intake/engine.js +677 -0
  85. package/src/runtime/intake/index.js +136 -0
  86. package/src/runtime/intake/manifest.js +140 -0
  87. package/src/runtime/intake/taxonomy.js +206 -0
  88. package/src/runtime/lifecycle/cli.js +73 -5
  89. package/src/runtime/lifecycle/followup.js +32 -6
  90. package/src/runtime/lifecycle/overview.js +2 -1
  91. package/src/runtime/packages/points.js +10 -0
  92. package/src/runtime/roadmap/attendance.js +137 -0
  93. package/src/runtime/roadmap/gates.js +6 -3
  94. package/src/runtime/roadmap/loop.js +113 -30
  95. package/src/runtime/roadmap/merge.js +17 -8
  96. package/src/runtime/roadmap/rows.js +13 -4
  97. package/src/runtime/roadmap/seed.js +153 -41
  98. package/src/runtime/roadmap/worktree-cleanup.js +147 -0
  99. package/src/runtime/settings/registry.js +1 -1
  100. package/src/runtime/slots.js +1 -1
  101. package/src/runtime/task-classifier.js +39 -4
  102. package/src/runtime/task-state.js +2 -3
  103. package/src/runtime/verb-view.js +267 -0
  104. package/src/runtime/verification/engine.js +0 -45
  105. package/src/runtime/worker/launch.js +5 -37
  106. package/src/runtime/worker/readiness.js +2 -2
  107. package/src/runtime/workflow-orchestrator.js +33 -5
  108. package/core/harnesses/claude/hooks/pre-bash-guard.sh +0 -10
  109. package/core/harnesses/codex/hooks/pre-bash-guard.sh +0 -10
  110. package/core/harnesses/copilot/hooks/doflow-pre-bash-guard.sh +0 -9
  111. package/core/harnesses/kiro/hooks/pre-bash-guard.sh +0 -25
  112. package/core/harnesses/shared/hooks/policies/blocked-patterns.conf +0 -105
  113. package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +0 -579
@@ -0,0 +1,677 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The intake engine (136 IC-002, IC-003, IC-004): sorts the loose files of a feature's `additional-context/`
5
+ * folder into category subfolders, previews a sort, undoes one, lists the folder and files prompt text. The
6
+ * files are the user's, so every rule below favours leaving things alone:
7
+ * - only loose, non-hidden regular files at the folder's root move; subfolders, dotfiles and links never do;
8
+ * - nothing is deleted or overwritten (a taken destination name gets a numbered suffix) and nothing outside
9
+ * the folder is written;
10
+ * - every move is journaled `planned` before the rename and `done` after the destination's hash is checked,
11
+ * so a crash is reconciled by the next run and a changed file is reported, never moved again;
12
+ * - matching reads names and extensions only, in registry order, so the same folder gives the same moves.
13
+ * The manifest is `manifest.ts`'s; the category rules are `taxonomy.ts`'s.
14
+ *
15
+ * A move never replaces a file: it is `link(from, to)` (which fails with EEXIST when `to` exists, atomically on
16
+ * one volume) then `unlink(from)`, so a file that appears at the destination between the plan and the move is
17
+ * left alone and the move is reported `failed`. On a volume that cannot hard-link (EPERM, ENOTSUP, EOPNOTSUPP,
18
+ * EMLINK: exFAT, some SMB or FUSE mounts) the move falls back to a look at the destination and a rename, which
19
+ * leaves a window in which a file created in that instant would be replaced; the journal entry then carries
20
+ * `fallback: 'rename'` (an undo that fell back records `undoFallback`, so the sort-time value stays). A crash between the link and the unlink leaves one file under two names; the next run
21
+ * settles it (same device and inode: the origin name is dropped and the move is `done`).
22
+ *
23
+ * Ceiling accepted: the manifest is rewritten whole after every journal step (quadratic in the number of
24
+ * moves in one run, fine for the handful of loose files a context folder holds).
25
+ */
26
+
27
+ const nodeFs = require('node:fs');
28
+ const path = require('node:path');
29
+ const nodeCrypto = require('node:crypto');
30
+ const { categoryFolder, isPlainText } = require('./taxonomy.js');
31
+ const {
32
+ readManifest, writeManifest, withManifestLock, newRunId, isConfinedPath, assertIntakeDir, INTAKE_DIR, IntakeUsageError,
33
+ } = require('./manifest.js');
34
+
35
+ const CONTEXT_DIR = 'additional-context';
36
+ const HASH_CHUNK = 1 << 20;
37
+
38
+ // ── small helpers ──────────────────────────────────────────────────────────────────────────────
39
+
40
+ /** Byte order of the names, so the order does not depend on the locale or on UTF-16 surrogates. */
41
+ function byBytes(a , b ) { return Buffer.compare(Buffer.from(a), Buffer.from(b)); }
42
+
43
+ function lstatOrNull(fsImpl , file ) {
44
+ try { return fsImpl.lstatSync(file); } catch (error ) {
45
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ /** Errors that mean the volume cannot hard-link; `moveNoReplace` then falls back to a look and a rename. */
51
+ const NO_LINK_CODES = ['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'EMLINK'];
52
+
53
+ /** True when two lstat results are the same file: one device and inode, i.e. the same bytes under two names. */
54
+ function sameFile(a , b ) { return Boolean(a && b) && a.dev === b.dev && a.ino === b.ino; }
55
+
56
+ /** Thrown when a name to be dropped no longer is the file it was linked to: the user's newer content is there. */
57
+ const changedError = () => Object.assign(new Error('the file was replaced while it moved'), { code: 'ECHANGED' });
58
+
59
+ /**
60
+ * Drops the name `drop` only while it is still the file `keep` names (same device and inode), else throws ECHANGED
61
+ * and drops nothing: an editor that saved atomically in between has put newer content under `drop`, and that is not
62
+ * ours to delete. A name that is already gone needs no drop. Ceiling accepted: the lstat and the unlink are two
63
+ * calls, so a swap in the instant between them is not seen; the window is one syscall wide, not a whole move.
64
+ */
65
+ function unlinkIfSame(fsImpl , drop , keep ) {
66
+ const dropped = lstatOrNull(fsImpl, drop);
67
+ if (!dropped) return;
68
+ if (!sameFile(dropped, lstatOrNull(fsImpl, keep))) throw changedError();
69
+ fsImpl.unlinkSync(drop);
70
+ }
71
+
72
+ /**
73
+ * Moves `from` to `to` and never replaces `to`: `linkSync` fails with EEXIST when it exists, then the origin
74
+ * name is dropped, but only while it is still the linked file (`unlinkIfSame`; ECHANGED keeps both names). If that
75
+ * last step fails otherwise the new link is removed again (it is the same inode, so no data goes) and the error is
76
+ * thrown. On a volume that cannot hard-link it falls back to lstat + rename and says so.
77
+ */
78
+ function moveNoReplace(fsImpl , from , to ) {
79
+ try {
80
+ fsImpl.linkSync(from, to);
81
+ } catch (error ) {
82
+ if (!NO_LINK_CODES.includes(error.code)) throw error;
83
+ if (lstatOrNull(fsImpl, to)) throw Object.assign(new Error(`EEXIST: ${to} already exists`), { code: 'EEXIST' });
84
+ fsImpl.renameSync(from, to);
85
+ return { fallback: true };
86
+ }
87
+ try {
88
+ unlinkIfSame(fsImpl, from, to);
89
+ } catch (error ) {
90
+ if (error.code !== 'ECHANGED') {
91
+ try { fsImpl.unlinkSync(to); } catch { /* the link is not ours to chase any further */ }
92
+ }
93
+ throw error;
94
+ }
95
+ return { fallback: false };
96
+ }
97
+
98
+ function extOf(name ) {
99
+ const dot = name.lastIndexOf('.');
100
+ return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
101
+ }
102
+
103
+ function stemOf(name ) {
104
+ const dot = name.lastIndexOf('.');
105
+ return dot > 0 ? name.slice(0, dot) : name;
106
+ }
107
+
108
+ /** True for the errors of a file this user may not read: the file is then left alone and reported `unreadable`. */
109
+ const isUnreadable = (error ) => error?.code === 'EACCES' || error?.code === 'EPERM';
110
+
111
+ /** `sha256:<hex>` and size of a file, read in chunks so a 22 MB recording is never held whole. */
112
+ function hashFile(fsImpl , file ) {
113
+ const hash = nodeCrypto.createHash('sha256');
114
+ const fd = fsImpl.openSync(file, 'r');
115
+ let bytes = 0;
116
+ try {
117
+ const buffer = Buffer.alloc(HASH_CHUNK);
118
+ for (;;) {
119
+ const read = fsImpl.readSync(fd, buffer, 0, HASH_CHUNK, null);
120
+ if (read === 0) break;
121
+ hash.update(buffer.subarray(0, read));
122
+ bytes += read;
123
+ }
124
+ } finally {
125
+ fsImpl.closeSync(fd);
126
+ }
127
+ return { sha256: `sha256:${hash.digest('hex')}`, bytes };
128
+ }
129
+
130
+ function hashBytes(buffer ) { return `sha256:${nodeCrypto.createHash('sha256').update(buffer).digest('hex')}`; }
131
+
132
+ const rel = (...parts ) => parts.join('/');
133
+
134
+ /**
135
+ * The context folder as a real directory, or 'absent'. A link or a plain file there is refused, and so is every
136
+ * action when the feature folder above it does not exist (a wrong `--slug` is a usage error, not an empty folder).
137
+ */
138
+ function contextState(contextDir , fsImpl ) {
139
+ const stat = lstatOrNull(fsImpl, contextDir);
140
+ if (!stat) {
141
+ const parent = lstatOrNull(fsImpl, path.dirname(contextDir));
142
+ if (!parent || !parent.isDirectory()) throw new IntakeUsageError(`${path.dirname(contextDir)} is not a feature folder that exists`);
143
+ return 'absent';
144
+ }
145
+ if (stat.isSymbolicLink()) throw new IntakeUsageError(`${contextDir} is a symbolic link; intake works only inside a real additional-context folder`);
146
+ if (!stat.isDirectory()) throw new IntakeUsageError(`${contextDir} exists and is not a folder`);
147
+ return 'present';
148
+ }
149
+
150
+ /**
151
+ * Checks the folder chain `dir` (relative, `/`-separated) under the context folder. Existing links and files
152
+ * on the chain are refused; the folders that do not exist yet are returned, shallowest first.
153
+ */
154
+ function chainOf(contextDir , dir , fsImpl ) {
155
+ const missing = [];
156
+ let walked = '';
157
+ for (const segment of dir.split('/')) {
158
+ walked = walked ? rel(walked, segment) : segment;
159
+ const stat = lstatOrNull(fsImpl, path.join(contextDir, walked));
160
+ if (!stat) missing.push(walked);
161
+ else if (!stat.isDirectory()) return { missing, bad: `${walked} exists and is not a plain folder` };
162
+ }
163
+ return { missing, bad: null };
164
+ }
165
+
166
+ // ── planning ───────────────────────────────────────────────────────────────────────────────────
167
+
168
+ /** Creates one folder; false when it already exists as a folder (someone made it since the plan). */
169
+ function makeDir(fsImpl , dir ) {
170
+ try {
171
+ fsImpl.mkdirSync(dir);
172
+ return true;
173
+ } catch (error ) {
174
+ if (error.code === 'EEXIST' && lstatOrNull(fsImpl, dir)?.isDirectory()) return false;
175
+ throw error;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Root entries of the context folder: loose files and folder names. Hidden names, links and others are not either.
181
+ * A loose file whose name the manifest could not hold (the `isConfinedPath` rule, e.g. a backslash) is listed in
182
+ * `unsupported`, never in `files`: journaling it would make the manifest unreadable on every later call.
183
+ */
184
+ function rootEntries(contextDir , fsImpl ) {
185
+ const files = [];
186
+ const unsupported = [];
187
+ const dirs = new Set ();
188
+ for (const entry of fsImpl.readdirSync(contextDir, { withFileTypes: true })) {
189
+ if (entry.name.startsWith('.')) continue;
190
+ if (entry.isFile()) (isConfinedPath(entry.name) ? files : unsupported).push(entry.name);
191
+ else if (entry.isDirectory()) dirs.add(entry.name);
192
+ }
193
+ return { files: files.sort(byBytes), dirs, unsupported: unsupported.sort(byBytes) };
194
+ }
195
+
196
+ function ruleMatches(rule , name ) {
197
+ if (!rule.extensions.includes(extOf(name))) return false;
198
+ return !rule.namePattern || new RegExp(rule.namePattern, 'i').test(stemOf(name));
199
+ }
200
+
201
+ /** The first non-companion rule that claims `name`, in category then rule order. */
202
+ function directClaim(categories , name ) {
203
+ for (const category of categories) {
204
+ for (const rule of category.rules) {
205
+ if (!rule.companionOf && ruleMatches(rule, name)) return { category, rule };
206
+ }
207
+ }
208
+ return null;
209
+ }
210
+
211
+ /**
212
+ * Plans a sort of the loose files (IC-002). Pure with respect to the folder: it reads, hashes and decides, and
213
+ * writes nothing. A companion rule sends a file beside a same-stem file that is itself moving in this plan, or
214
+ * that already sits in the category folder or is recorded `done` in the manifest.
215
+ */
216
+ function planSort({ contextDir, taxonomy, now, fsImpl = nodeFs, withHashes = true } ) {
217
+ const { files, dirs, unsupported } = rootEntries(contextDir, fsImpl);
218
+ const windowMs = taxonomy.recentWindowSeconds * 1000;
219
+ const recent = new Set ();
220
+ for (const name of files) {
221
+ if (windowMs > 0 && now.getTime() - fsImpl.lstatSync(path.join(contextDir, name)).mtimeMs < windowMs) recent.add(name);
222
+ }
223
+
224
+ // Phase 1: direct claims. A file that is recent does not move, so it is nobody's companion either.
225
+ const direct = new Map ();
226
+ for (const name of files) {
227
+ const claim = directClaim(taxonomy.categories, name);
228
+ if (claim) direct.set(name, claim);
229
+ }
230
+ const movers = files.filter((name ) => direct.has(name) && !recent.has(name));
231
+
232
+ const placeOf = (claim ) => {
233
+ const folder = categoryFolder(claim.category, dirs);
234
+ return { dir: claim.rule.into ? rel(folder, claim.rule.into) : folder, categoryId: claim.category.id };
235
+ };
236
+
237
+ // A partner that no longer is loose: already in the category folder, or recorded `done` by an earlier run. This
238
+ // settles a render stranded by a crash or by a recent-window skip while its source moved.
239
+ let doneMoves = null;
240
+ const settledPartnerPlace = (category , rule , name ) => {
241
+ const stem = stemOf(name);
242
+ for (const sourceRule of category.rules) {
243
+ if (sourceRule.companionOf) continue;
244
+ const place = placeOf({ category, rule: sourceRule });
245
+ const isPartner = (other ) => stemOf(other) === stem && rule.companionOf.includes(extOf(other)) && sourceRule.extensions.includes(extOf(other));
246
+ let inFolder = false;
247
+ try {
248
+ const folder = lstatOrNull(fsImpl, path.join(contextDir, place.dir));
249
+ inFolder = Boolean(folder && folder.isDirectory() && fsImpl.readdirSync(path.join(contextDir, place.dir), { withFileTypes: true }).some((entry ) => entry.isFile() && isPartner(entry.name)));
250
+ } catch (error ) {
251
+ if (!isUnreadable(error)) throw error; // a folder this user may not read holds no partner we can see: the render stays loose
252
+ }
253
+ if (inFolder) return place;
254
+ const done = doneMoves ??= readManifest(contextDir, fsImpl).runs.flatMap((run ) => run.moves.filter((move ) => move.state === 'done'));
255
+ if (done.some((move ) => path.posix.dirname(move.to) === place.dir && isPartner(path.posix.basename(move.to)))) return place;
256
+ }
257
+ return null;
258
+ };
259
+
260
+ // Phase 2: the final claim of each file, rules in order, a companion rule resolved against the movers.
261
+ const claimOf = (name ) => {
262
+ for (const category of taxonomy.categories) {
263
+ for (const rule of category.rules) {
264
+ if (!ruleMatches(rule, name)) continue;
265
+ if (!rule.companionOf) return placeOf({ category, rule });
266
+ const stem = stemOf(name);
267
+ const partner = movers.find((other ) => other !== name && stemOf(other) === stem && rule.companionOf.includes(extOf(other)));
268
+ if (partner) return placeOf(direct.get(partner));
269
+ const settled = settledPartnerPlace(category, rule, name);
270
+ if (settled) return settled;
271
+ }
272
+ }
273
+ return null;
274
+ };
275
+
276
+ const moves = [];
277
+ const skipped = unsupported.map((name ) => ({ path: name, reason: 'unsupported-name', detail: 'the name cannot be recorded in the manifest' }));
278
+ const unmatched = [];
279
+ const createdDirs = [];
280
+ const taken = new Set ();
281
+ const chains = new Map ();
282
+ for (const name of files) {
283
+ const claim = claimOf(name);
284
+ if (!claim) { unmatched.push(name); continue; }
285
+ if (recent.has(name)) { skipped.push({ path: name, reason: 'recent' }); continue; }
286
+ const from = path.join(contextDir, name);
287
+ let stat ;
288
+ try {
289
+ if (withHashes) stat = hashFile(fsImpl, from);
290
+ else { fsImpl.accessSync(from, nodeFs.constants.R_OK); stat = { sha256: null, bytes: fsImpl.lstatSync(from).size }; }
291
+ } catch (error ) {
292
+ if (!isUnreadable(error)) throw error;
293
+ skipped.push({ path: name, reason: 'unreadable', detail: error.code });
294
+ continue;
295
+ }
296
+ if (!chains.has(claim.dir)) chains.set(claim.dir, chainOf(contextDir, claim.dir, fsImpl));
297
+ const chain = chains.get(claim.dir) ;
298
+ if (chain.bad) { skipped.push({ path: name, reason: 'failed', detail: `destination folder ${claim.dir}: ${chain.bad}` }); continue; }
299
+ const to = freeName(contextDir, claim.dir, name, taken, fsImpl);
300
+ taken.add(to.toLowerCase());
301
+ for (const dir of chain.missing) if (!createdDirs.includes(dir)) createdDirs.push(dir);
302
+ moves.push({ from: name, to, category: claim.categoryId, sha256: stat.sha256, bytes: stat.bytes });
303
+ }
304
+ return { moves, skipped, unmatched, createdDirs };
305
+ }
306
+
307
+ /** The first free relative path for `name` in `dir`: the name itself, else `<stem>-<n><.ext>`. */
308
+ function freeName(contextDir , dir , name , taken , fsImpl ) {
309
+ const dot = name.lastIndexOf('.');
310
+ const stem = dot > 0 ? name.slice(0, dot) : name;
311
+ const ext = dot > 0 ? name.slice(dot) : '';
312
+ for (let n = 0; ; n += 1) {
313
+ const candidate = rel(dir, n === 0 ? name : `${stem}-${n}${ext}`);
314
+ if (!taken.has(candidate.toLowerCase()) && !lstatOrNull(fsImpl, path.join(contextDir, candidate))) return candidate;
315
+ }
316
+ }
317
+
318
+ // ── manifest reconcile ─────────────────────────────────────────────────────────────────────────
319
+
320
+ /**
321
+ * IC-007: settles every `planned` move left by a run that died between its steps. The origin still holding a
322
+ * file means the rename never happened (`failed`); a destination holding the recorded hash with the origin
323
+ * empty means it did (`done`); the same file under both names (a crash between link and unlink) drops the
324
+ * origin name and is `done` too. Anything else is `failed`. Returns true when the manifest changed.
325
+ */
326
+ function reconcile(manifest , contextDir , now , fsImpl , assertHeld = () => {}) {
327
+ let changed = false;
328
+ for (const run of manifest.runs) {
329
+ let settled = false;
330
+ for (const move of run.moves) {
331
+ if (move.state !== 'planned') continue;
332
+ const toStat = lstatOrNull(fsImpl, path.join(contextDir, move.to));
333
+ const fromStat = move.from === null ? null : lstatOrNull(fsImpl, path.join(contextDir, move.from));
334
+ const toIsFile = Boolean(toStat && toStat.isFile());
335
+ if (move.from === null && toIsFile) { assertHeld(); dropPromptTemps(contextDir, toStat, fsImpl); }
336
+ if (fromStat && toIsFile && sameFile(fromStat, toStat) && hashFile(fsImpl, path.join(contextDir, move.to)).sha256 === move.sha256) {
337
+ assertHeld();
338
+ fsImpl.unlinkSync(path.join(contextDir, move.from));
339
+ move.state = 'done';
340
+ move.reason = null;
341
+ } else if (fromStat) {
342
+ move.state = 'failed';
343
+ move.reason = 'the move did not complete: the origin still holds a file';
344
+ } else if (toIsFile && hashFile(fsImpl, path.join(contextDir, move.to)).sha256 === move.sha256) {
345
+ move.state = 'done';
346
+ move.reason = null;
347
+ } else {
348
+ move.state = 'failed';
349
+ move.reason = toIsFile ? 'the destination does not hold the recorded content' : 'neither the origin nor the destination holds the file';
350
+ }
351
+ settled = true;
352
+ changed = true;
353
+ }
354
+ if (settled && run.finishedAt === null) run.finishedAt = now.toISOString();
355
+ }
356
+ return changed;
357
+ }
358
+
359
+ /**
360
+ * A crash between a prompt file's link and its unlink leaves `.intake/prompt.<pid>.tmp` as a second name of the
361
+ * filed file. Only a temp that is that very file (same device and inode) is dropped; any other is left alone.
362
+ */
363
+ function dropPromptTemps(contextDir , filed , fsImpl ) {
364
+ const intake = path.join(contextDir, INTAKE_DIR);
365
+ for (const name of fsImpl.readdirSync(intake)) {
366
+ if (!/^prompt\..*\.tmp$/.test(name)) continue;
367
+ const temp = path.join(intake, name);
368
+ if (sameFile(lstatOrNull(fsImpl, temp), filed)) fsImpl.unlinkSync(temp);
369
+ }
370
+ }
371
+
372
+ const hasPlanned = (manifest ) => manifest.runs.some((run ) => run.moves.some((move ) => move.state === 'planned'));
373
+
374
+ // ── actions ────────────────────────────────────────────────────────────────────────────────────
375
+
376
+ const absent = (action , extra = {}) => ({ action, state: 'absent', ...extra });
377
+ const absentSort = (action ) => absent(action, { runId: null, moves: [], skipped: [], unmatched: [], createdDirs: [] });
378
+
379
+ /** IC-002 preview: the plan, with `runId: null`; no lock, no manifest, no write. */
380
+ function previewSort({ contextDir, taxonomy, now = new Date(), fsImpl = nodeFs } ) {
381
+ if (contextState(contextDir, fsImpl) === 'absent') return absentSort('preview');
382
+ return { action: 'preview', state: 'sorted', runId: null, ...planSort({ contextDir, taxonomy, now, fsImpl }) };
383
+ }
384
+
385
+ /** IC-002 sort. */
386
+ function sortFolder({ contextDir, taxonomy, now = new Date(), fsImpl = nodeFs } ) {
387
+ if (contextState(contextDir, fsImpl) === 'absent') return absentSort('sort');
388
+ const empty = (plan ) => ({ action: 'sort', state: 'sorted', runId: null, moves: [], skipped: plan.skipped, unmatched: plan.unmatched, createdDirs: [] });
389
+ // Nothing to move and nothing to settle: report without a lock or a single write.
390
+ const first = planSort({ contextDir, taxonomy, now, fsImpl, withHashes: false });
391
+ if (first.moves.length === 0 && !hasPlanned(readManifest(contextDir, fsImpl))) return empty(first);
392
+
393
+ return withManifestLock(contextDir, (assertHeld ) => {
394
+ const manifest = readManifest(contextDir, fsImpl);
395
+ if (reconcile(manifest, contextDir, now, fsImpl, assertHeld)) writeManifest(contextDir, manifest, fsImpl, assertHeld);
396
+ const plan = planSort({ contextDir, taxonomy, now, fsImpl });
397
+ if (plan.moves.length === 0) return empty(plan);
398
+
399
+ const run = { id: newRunId(now), action: 'sort', startedAt: now.toISOString(), finishedAt: null, source: null, stage: null, createdDirs: [], moves: [] };
400
+ manifest.runs.push(run);
401
+ const moves = [];
402
+ const skipped = [...plan.skipped];
403
+ for (const planned of plan.moves) {
404
+ const from = path.join(contextDir, planned.from);
405
+ const to = path.join(contextDir, planned.to);
406
+ const entry = { from: planned.from, to: planned.to, sha256: planned.sha256, bytes: planned.bytes, at: now.toISOString(), state: 'planned', reason: null };
407
+ const fail = (reason ) => {
408
+ entry.state = 'failed';
409
+ entry.reason = reason;
410
+ skipped.push({ path: planned.from, reason: 'failed', detail: reason });
411
+ writeManifest(contextDir, manifest, fsImpl, assertHeld);
412
+ };
413
+ const chain = chainOf(contextDir, path.posix.dirname(planned.to), fsImpl);
414
+ if (chain.bad) {
415
+ run.moves.push(entry);
416
+ fail(`destination folder: ${chain.bad}`);
417
+ continue;
418
+ }
419
+ let madeDirs = true;
420
+ for (const dir of chain.missing) {
421
+ try {
422
+ if (makeDir(fsImpl, path.join(contextDir, dir))) run.createdDirs.push(dir);
423
+ } catch (error ) {
424
+ // A folder that cannot be created fails this move only; the file stays where it is.
425
+ run.moves.push(entry);
426
+ fail(`destination folder ${dir} could not be created: ${error.code ?? error.message}`);
427
+ madeDirs = false;
428
+ break;
429
+ }
430
+ }
431
+ if (!madeDirs) continue;
432
+ run.moves.push(entry);
433
+ writeManifest(contextDir, manifest, fsImpl, assertHeld); // journal: planned
434
+ assertHeld();
435
+ try {
436
+ if (moveNoReplace(fsImpl, from, to).fallback) entry.fallback = 'rename';
437
+ } catch (error ) {
438
+ fail(error.code === 'EEXIST' ? 'the destination appeared before the move'
439
+ : error.code === 'ECHANGED' ? `${planned.from} was replaced by newer content while it moved; ${planned.from} holds that newer content and ${planned.to} holds the planned copy; neither was deleted`
440
+ : `rename failed: ${error.message}`);
441
+ continue;
442
+ }
443
+ const after = hashFile(fsImpl, to).sha256;
444
+ if (after !== planned.sha256) {
445
+ // The file is not what was planned: put it back where it came from when that name is free, else leave it
446
+ // where it is. Either way the entry is `failed` and names both paths.
447
+ const why = `the destination hash ${after} differs from the hash ${planned.sha256} taken before the move`;
448
+ let outcome ;
449
+ try {
450
+ assertHeld();
451
+ if (moveNoReplace(fsImpl, to, from).fallback) entry.fallback = 'rename';
452
+ outcome = `the file was moved back to ${planned.from}`;
453
+ } catch (error ) {
454
+ if (error instanceof IntakeUsageError) throw error;
455
+ outcome = error.code === 'ECHANGED'
456
+ ? `${planned.to} was replaced by newer content while it moved back; ${planned.to} holds that newer content and ${planned.from} holds the moved copy; neither was deleted`
457
+ : `the file stays at ${planned.to} (${error.code === 'EEXIST' ? `${planned.from} is taken` : `it could not be moved back to ${planned.from}: ${error.message}`})`;
458
+ }
459
+ fail(`${why}; ${outcome}`);
460
+ continue;
461
+ }
462
+ entry.state = 'done';
463
+ moves.push({ from: planned.from, to: planned.to, category: planned.category, sha256: planned.sha256, bytes: planned.bytes });
464
+ }
465
+ run.finishedAt = now.toISOString();
466
+ writeManifest(contextDir, manifest, fsImpl, assertHeld);
467
+ return { action: 'sort', state: 'sorted', runId: run.id, moves, skipped, unmatched: plan.unmatched, createdDirs: run.createdDirs };
468
+ }, fsImpl);
469
+ }
470
+
471
+ /**
472
+ * IC-003 undo. Entries in state `done` (and `undo-skipped`, when a run is named) are restored newest first.
473
+ * A `failed` entry whose planned copy still sits at its destination is restored only when the run is named and
474
+ * the origin is free; otherwise it is listed in `kept` with both paths and left where it is.
475
+ * A `file-prompt` run has no origin, and undo never deletes, so naming one is refused.
476
+ */
477
+ function undoRun({ contextDir, runId = null, now = new Date(), fsImpl = nodeFs } ) {
478
+ if (contextState(contextDir, fsImpl) === 'absent') return absent('undo', { runId: null, restored: [], skipped: [], kept: [], removedDirs: [] });
479
+ assertIntakeDir(contextDir, fsImpl);
480
+ if (!lstatOrNull(fsImpl, path.join(contextDir, INTAKE_DIR))) {
481
+ if (runId) throw new IntakeUsageError(`no run '${runId}' in the manifest`);
482
+ return nothingToUndo();
483
+ }
484
+ return withManifestLock(contextDir, (assertHeld ) => {
485
+ const manifest = readManifest(contextDir, fsImpl);
486
+ if (reconcile(manifest, contextDir, now, fsImpl, assertHeld)) writeManifest(contextDir, manifest, fsImpl, assertHeld);
487
+ let run ;
488
+ if (runId) {
489
+ run = manifest.runs.find((candidate ) => candidate.id === runId);
490
+ if (!run) throw new IntakeUsageError(`no run '${runId}' in the manifest`);
491
+ if (run.action !== 'sort') throw new IntakeUsageError(`run '${runId}' filed prompt text; it has no origin to restore and undo never deletes a file`);
492
+ } else {
493
+ run = [...manifest.runs].reverse().find((candidate ) => candidate.action === 'sort' && candidate.moves.some((move ) => move.state === 'done'));
494
+ if (!run) return nothingToUndo();
495
+ }
496
+ const restored = [];
497
+ const skipped = [];
498
+ const kept = [];
499
+ const retry = runId ? ['done', 'undo-skipped', 'failed'] : ['done', 'failed'];
500
+ const skip = (move , reason , detail ) => {
501
+ move.state = 'undo-skipped';
502
+ move.reason = reason;
503
+ skipped.push(detail ? { to: move.to, reason, detail } : { to: move.to, reason });
504
+ };
505
+ for (const move of [...run.moves].reverse()) {
506
+ if (!retry.includes(move.state)) continue;
507
+ const from = path.join(contextDir, move.from);
508
+ const to = path.join(contextDir, move.to);
509
+ const toStat = lstatOrNull(fsImpl, to);
510
+ const fromStat = lstatOrNull(fsImpl, from);
511
+ if (move.state === 'failed') {
512
+ // A failed entry is the user's file's to decide about: only one still holding the planned copy at its
513
+ // destination is of interest, and it is restored only when the run is named and the origin is free.
514
+ if (!toStat || !toStat.isFile() || hashFile(fsImpl, to).sha256 !== move.sha256) continue;
515
+ if (!runId || fromStat) {
516
+ kept.push({ from: move.from, to: move.to, reason: move.reason });
517
+ continue;
518
+ }
519
+ }
520
+ if (!toStat) {
521
+ // A restore that died before it was journaled: the origin already holds the recorded content.
522
+ if (fromStat && fromStat.isFile() && hashFile(fsImpl, from).sha256 === move.sha256) {
523
+ move.state = 'undone';
524
+ move.reason = null;
525
+ restored.push({ from: move.from, to: move.to });
526
+ } else {
527
+ skip(move, 'missing');
528
+ }
529
+ } else if (!toStat.isFile() || hashFile(fsImpl, to).sha256 !== move.sha256) {
530
+ skip(move, 'changed');
531
+ } else if (fromStat && !sameFile(fromStat, toStat)) {
532
+ skip(move, 'origin-taken');
533
+ } else {
534
+ const chain = chainOf(contextDir, path.posix.dirname(move.to), fsImpl);
535
+ if (chain.bad || chain.missing.length) { skip(move, 'changed'); } else {
536
+ let taken = false;
537
+ let swapped = false;
538
+ assertHeld();
539
+ try {
540
+ // A restore that died between its link and its unlink (the file under both names) is finished here.
541
+ if (fromStat) unlinkIfSame(fsImpl, to, from);
542
+ else if (moveNoReplace(fsImpl, to, from).fallback) move.undoFallback = 'rename';
543
+ } catch (error ) {
544
+ if (error.code === 'ECHANGED') swapped = true;
545
+ else if (error.code === 'EEXIST') taken = true;
546
+ else throw error;
547
+ }
548
+ if (swapped) {
549
+ skip(move, 'changed', `${move.to} was replaced by newer content while it was restored; ${move.to} holds that newer content and ${move.from} holds the recorded copy; neither was deleted`);
550
+ } else if (taken) {
551
+ skip(move, 'origin-taken');
552
+ } else if (hashFile(fsImpl, from).sha256 === move.sha256) {
553
+ move.state = 'undone';
554
+ move.reason = null;
555
+ restored.push({ from: move.from, to: move.to });
556
+ } else {
557
+ skip(move, 'changed');
558
+ }
559
+ }
560
+ }
561
+ writeManifest(contextDir, manifest, fsImpl, assertHeld);
562
+ }
563
+ // A folder this run created goes only when it is empty now: rmdir cannot delete a file.
564
+ const removedDirs = [];
565
+ for (const dir of [...run.createdDirs].reverse()) {
566
+ const full = path.join(contextDir, dir);
567
+ const stat = lstatOrNull(fsImpl, full);
568
+ if (stat && stat.isDirectory() && fsImpl.readdirSync(full).length === 0) {
569
+ fsImpl.rmdirSync(full);
570
+ removedDirs.push(dir);
571
+ }
572
+ }
573
+ return { action: 'undo', state: 'undone', runId: run.id, restored, skipped, kept, removedDirs };
574
+ }, fsImpl);
575
+ }
576
+
577
+ function nothingToUndo() {
578
+ return { action: 'undo', state: 'nothing-to-undo', runId: null, restored: [], skipped: [], kept: [], removedDirs: [] };
579
+ }
580
+
581
+ /** IC-004 list: every non-hidden regular file, recursively, outside `.intake/`; contents are only hashed. */
582
+ function listFolder({ contextDir, taxonomy, fsImpl = nodeFs } ) {
583
+ if (contextState(contextDir, fsImpl) === 'absent') return absent('list', { files: [] });
584
+ const owners = new Map ();
585
+ for (const category of taxonomy.categories) for (const name of [category.id, ...category.names]) owners.set(name, category.id);
586
+ const text = new Set(taxonomy.textExtensions);
587
+ const files = [];
588
+ const walk = (dir , prefix ) => {
589
+ for (const entry of fsImpl.readdirSync(dir, { withFileTypes: true })) {
590
+ if (entry.name.startsWith('.')) continue;
591
+ const relative = prefix ? rel(prefix, entry.name) : entry.name;
592
+ if (entry.isDirectory()) { walk(path.join(dir, entry.name), relative); continue; }
593
+ if (!entry.isFile()) continue;
594
+ const ext = extOf(entry.name);
595
+ let sha256 ;
596
+ let bytes ;
597
+ try {
598
+ ({ sha256, bytes } = hashFile(fsImpl, path.join(dir, entry.name)));
599
+ } catch (error ) {
600
+ if (!isUnreadable(error)) throw error;
601
+ files.push({ path: relative, category: owners.get(relative.split('/')[0]) ?? null, kind: text.has(ext) ? 'text' : 'binary', bytes: fsImpl.lstatSync(path.join(dir, entry.name)).size, sha256: null, pointer: false, unreadable: true });
602
+ continue;
603
+ }
604
+ // The same test collectPointers applies: a binary saved as .md is neither text nor a pointer.
605
+ const plain = ext !== 'md' || bytes > taxonomy.pointerMaxBytes || isPlainText(fsImpl.readFileSync(path.join(dir, entry.name)));
606
+ files.push({
607
+ path: relative,
608
+ category: owners.get(relative.split('/')[0]) ?? null,
609
+ kind: text.has(ext) && plain ? 'text' : 'binary',
610
+ bytes,
611
+ sha256,
612
+ pointer: ext === 'md' && bytes <= taxonomy.pointerMaxBytes && plain,
613
+ });
614
+ }
615
+ };
616
+ walk(contextDir, '');
617
+ files.sort((a , b ) => byBytes(a.path, b.path));
618
+ return { action: 'list', state: 'listed', files };
619
+ }
620
+
621
+ /**
622
+ * IC-004 file-prompt: writes `bytes` unchanged to `<prompt folder>/prompt-<YYYY-MM-DD>-<NN>.md` and records the
623
+ * provenance in the manifest, never in the file. Creates the context folder when it does not exist.
624
+ */
625
+ function filePrompt({ contextDir, taxonomy, bytes, stage = null, now = new Date(), fsImpl = nodeFs } ) {
626
+ if (!Buffer.isBuffer(bytes) || bytes.length === 0) throw new IntakeUsageError('there is no prompt text to file: the input is empty');
627
+ if (contextState(contextDir, fsImpl) === 'absent') makeDir(fsImpl, contextDir);
628
+ return withManifestLock(contextDir, (assertHeld ) => {
629
+ const manifest = readManifest(contextDir, fsImpl);
630
+ if (reconcile(manifest, contextDir, now, fsImpl, assertHeld)) writeManifest(contextDir, manifest, fsImpl, assertHeld);
631
+ const category = taxonomy.categories.find((candidate ) => candidate.id === taxonomy.promptCategory);
632
+ const dir = categoryFolder(category, rootEntries(contextDir, fsImpl).dirs);
633
+ const chain = chainOf(contextDir, dir, fsImpl);
634
+ if (chain.bad) throw new IntakeUsageError(`the prompt folder ${dir}: ${chain.bad}`);
635
+ const day = now.toISOString().slice(0, 10);
636
+ let to ;
637
+ for (let n = 1; ; n += 1) {
638
+ to = rel(dir, `prompt-${day}-${String(n).padStart(2, '0')}.md`);
639
+ if (!lstatOrNull(fsImpl, path.join(contextDir, to))) break;
640
+ }
641
+ const sha256 = hashBytes(bytes);
642
+ const run = { id: newRunId(now), action: 'file-prompt', startedAt: now.toISOString(), finishedAt: null, source: 'prompt', stage, createdDirs: [], moves: [] };
643
+ const entry = { from: null, to, sha256, bytes: bytes.length, at: now.toISOString(), state: 'planned', reason: null };
644
+ for (const made of chain.missing) if (makeDir(fsImpl, path.join(contextDir, made))) run.createdDirs.push(made);
645
+ run.moves.push(entry);
646
+ manifest.runs.push(run);
647
+ writeManifest(contextDir, manifest, fsImpl, assertHeld); // journal: planned
648
+ // Written beside the manifest, then renamed into place, so the file is whole or absent.
649
+ const tmp = path.join(contextDir, INTAKE_DIR, `prompt.${process.pid}.tmp`);
650
+ let wrote = false;
651
+ try {
652
+ fsImpl.writeFileSync(tmp, bytes, { flag: 'wx' });
653
+ wrote = true;
654
+ assertHeld();
655
+ if (moveNoReplace(fsImpl, tmp, path.join(contextDir, to)).fallback) entry.fallback = 'rename';
656
+ } catch (error ) {
657
+ // A temp file this call did not create is not ours to remove.
658
+ if (wrote) { try { fsImpl.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ } }
659
+ const why = error.code !== 'EEXIST' ? error.message
660
+ : wrote ? 'the destination appeared before the write'
661
+ : `a stale prompt temp file exists at ${rel(INTAKE_DIR, path.basename(tmp))}; remove it and run again`;
662
+ entry.state = 'failed';
663
+ entry.reason = `write failed: ${why}`;
664
+ run.finishedAt = now.toISOString();
665
+ writeManifest(contextDir, manifest, fsImpl, assertHeld);
666
+ throw new IntakeUsageError(`could not file the prompt text: ${why}`);
667
+ }
668
+ entry.state = hashFile(fsImpl, path.join(contextDir, to)).sha256 === sha256 ? 'done' : 'failed';
669
+ if (entry.state === 'failed') entry.reason = 'the filed file does not hold the input bytes';
670
+ run.finishedAt = now.toISOString();
671
+ writeManifest(contextDir, manifest, fsImpl, assertHeld);
672
+ if (entry.state === 'failed') throw new IntakeUsageError(`could not file the prompt text: ${entry.reason}`);
673
+ return { action: 'file-prompt', path: to, sha256, bytes: bytes.length, runId: run.id };
674
+ }, fsImpl);
675
+ }
676
+
677
+ module.exports = { previewSort, sortFolder, undoRun, listFolder, filePrompt, planSort, reconcile, hashFile, CONTEXT_DIR };