@opum-ai/lore 0.1.0 → 0.1.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 (90) hide show
  1. package/README.md +33 -22
  2. package/bin/lore.cjs +35 -7
  3. package/package.json +17 -17
  4. package/src/adapters/backlog.ts +0 -1084
  5. package/src/adapters/git.ts +0 -221
  6. package/src/cli.ts +0 -667
  7. package/src/commands/agent.ts +0 -301
  8. package/src/commands/agents.ts +0 -302
  9. package/src/commands/args.ts +0 -209
  10. package/src/commands/changed.ts +0 -70
  11. package/src/commands/check.ts +0 -1031
  12. package/src/commands/codex-bridge.ts +0 -49
  13. package/src/commands/concurrency.ts +0 -48
  14. package/src/commands/context.ts +0 -292
  15. package/src/commands/discover.ts +0 -89
  16. package/src/commands/explorer.ts +0 -253
  17. package/src/commands/export.ts +0 -93
  18. package/src/commands/fswrite.ts +0 -928
  19. package/src/commands/graph.ts +0 -291
  20. package/src/commands/help.ts +0 -151
  21. package/src/commands/impact.ts +0 -59
  22. package/src/commands/init.ts +0 -583
  23. package/src/commands/instructions.ts +0 -91
  24. package/src/commands/link.ts +0 -929
  25. package/src/commands/new.ts +0 -476
  26. package/src/commands/orphans.ts +0 -457
  27. package/src/commands/path.ts +0 -67
  28. package/src/commands/provenance.ts +0 -68
  29. package/src/commands/query.ts +0 -312
  30. package/src/commands/reconcile-shared.ts +0 -280
  31. package/src/commands/rename.ts +0 -585
  32. package/src/commands/replace.ts +0 -320
  33. package/src/commands/scaffold.ts +0 -346
  34. package/src/commands/schema.ts +0 -293
  35. package/src/commands/snapshot.ts +0 -130
  36. package/src/commands/supersede.ts +0 -400
  37. package/src/commands/sync.ts +0 -371
  38. package/src/commands/tasks.ts +0 -271
  39. package/src/commands/traversal.ts +0 -151
  40. package/src/commands/validate.ts +0 -226
  41. package/src/config.ts +0 -598
  42. package/src/core/agent-bridge.ts +0 -287
  43. package/src/core/agent-context.ts +0 -498
  44. package/src/core/agent-profile.ts +0 -447
  45. package/src/core/bundle.ts +0 -893
  46. package/src/core/check.ts +0 -853
  47. package/src/core/codex-bridge.ts +0 -100
  48. package/src/core/concept.ts +0 -597
  49. package/src/core/consumer-scaffold.ts +0 -433
  50. package/src/core/context.ts +0 -271
  51. package/src/core/explorer-contract.ts +0 -441
  52. package/src/core/explorer-qualification.ts +0 -58
  53. package/src/core/explorer.ts +0 -518
  54. package/src/core/finding.ts +0 -31
  55. package/src/core/graph.ts +0 -201
  56. package/src/core/indexes.ts +0 -436
  57. package/src/core/instructions.ts +0 -209
  58. package/src/core/ladybug-driver.ts +0 -1795
  59. package/src/core/ladybug-lifecycle.ts +0 -1178
  60. package/src/core/ladybug-native.ts +0 -95
  61. package/src/core/ladybug-source.ts +0 -667
  62. package/src/core/links.ts +0 -681
  63. package/src/core/log.ts +0 -253
  64. package/src/core/managed-block.ts +0 -540
  65. package/src/core/manifest.ts +0 -718
  66. package/src/core/order.ts +0 -13
  67. package/src/core/profile.ts +0 -1007
  68. package/src/core/projection.ts +0 -195
  69. package/src/core/query.ts +0 -542
  70. package/src/core/reconcile.ts +0 -236
  71. package/src/core/replace.ts +0 -419
  72. package/src/core/retrieval.ts +0 -213
  73. package/src/core/rewrite.ts +0 -940
  74. package/src/core/scaffold.ts +0 -255
  75. package/src/core/schema.ts +0 -366
  76. package/src/core/snapshot-runtime.ts +0 -52
  77. package/src/core/snapshot-store.ts +0 -287
  78. package/src/core/snapshot.ts +0 -711
  79. package/src/core/template.ts +0 -429
  80. package/src/core/traversal.ts +0 -487
  81. package/src/core/validate.ts +0 -517
  82. package/src/core/workspace-contract.ts +0 -473
  83. package/src/core/workspace-projection.ts +0 -365
  84. package/src/core/workspace-retrieval.ts +0 -196
  85. package/src/core/workspace-source.ts +0 -174
  86. package/src/errors.ts +0 -697
  87. package/src/meta.ts +0 -7
  88. package/src/output.ts +0 -589
  89. package/src/scripts/upstream-backlog-watch.ts +0 -288
  90. package/src/state.ts +0 -390
package/src/state.ts DELETED
@@ -1,390 +0,0 @@
1
- /**
2
- * state.ts — `.lore/` and git ownership of `backlog/` (design spec §2.4, LORE-26).
3
- *
4
- * lore is the **sole committer** of `backlog/` (ADR-0012): Backlog.md itself never runs `git
5
- * add`/`git commit` (`auto_commit: false`), so whichever `lore` command wrote to a task through the
6
- * Backlog CLI (`task create`/`task edit`) has to be committed by `lore` itself. Two shapes:
7
- * {@link commitBacklogFiles} is the **per-write** commit — `link`/`unlink`/`rename` commit exactly the
8
- * task file(s) they just edited, immediately, staging **only** those paths (ADR-0012 §1: never sweep an
9
- * unrelated in-flight edit into the commit). {@link commitBacklogIfDirty} is the **catch-all** sweep —
10
- * `lore sync` (whose own writes are entirely to `docs/`) commits whatever is *still* uncommitted under
11
- * `backlog/` (a human's direct `backlog task edit`, or anything a prior command missed), leaving
12
- * anything outside `backlog/` untouched. Both are one `lore`-authored commit.
13
- *
14
- * This is the **fourth** injectable determinism seam (after the clock, the Backlog subprocess, and
15
- * the git-history `GitAdapter` in `core/log.ts`; lore-design §8): {@link GitSpawn} mirrors
16
- * `adapters/backlog.ts`'s `BacklogSpawn` pattern exactly — the real implementation shells `git`, and
17
- * tests inject a fake that returns canned output instead of driving a real subprocess.
18
- *
19
- * **Scope (LORE-26).** The design spec's §2.4 envisions `state.ts` more broadly — also reading
20
- * `.lore/config.toml`/`cache/`/`schemas/`/`templates/`. This file ships only the git-ownership half:
21
- * `commands/sync.ts` calls `config.ts`'s `loadConfig` directly rather than through this module, since
22
- * config-loading has nothing to do with git. Widening this file's scope, if ever warranted, is a
23
- * separate concern from the git-write seam it owns today.
24
- *
25
- * Per lore-design §2.1 this module is impure command-layer wiring (like `config.ts`), not `core/`:
26
- * it exists specifically to shell `git`.
27
- */
28
-
29
- import { posix } from "node:path";
30
- import { escapesRoot } from "./core/rewrite";
31
- import { LoreError, stderrHint } from "./errors";
32
-
33
- /** The result of one `git` invocation, mirroring `adapters/backlog.ts`'s `SpawnResult` shape. */
34
- export interface GitSpawnResult {
35
- /** The process exit code (`0` on success). */
36
- readonly exitCode: number;
37
- /** Everything the process wrote to stdout. */
38
- readonly stdout: string;
39
- /** Everything the process wrote to stderr (human diagnostics; never parsed as data). */
40
- readonly stderr: string;
41
- }
42
-
43
- /**
44
- * The injectable git-write seam. `args` are the arguments after `git` (e.g.
45
- * `["status", "--porcelain", "--", "backlog/"]`); the binary and `cwd` are bound inside the real
46
- * implementation. Mirrors `adapters/backlog.ts`'s `BacklogSpawn` exactly, including its "rejects
47
- * only on a failed spawn, resolves with a non-zero `exitCode` on a failed run" contract.
48
- */
49
- export type GitSpawn = (args: readonly string[]) => Promise<GitSpawnResult>;
50
-
51
- /** The real {@link GitSpawn}: shells `git` via `Bun.spawn`, scoped to `cwd`. */
52
- export function bunGitSpawn(cwd: string): GitSpawn {
53
- return async (args: readonly string[]): Promise<GitSpawnResult> => {
54
- const proc = Bun.spawn(["git", ...args], { stdout: "pipe", stderr: "pipe", cwd });
55
- const [stdout, stderr, exitCode] = await Promise.all([
56
- new Response(proc.stdout).text(),
57
- new Response(proc.stderr).text(),
58
- proc.exited,
59
- ]);
60
- return { exitCode, stdout, stderr };
61
- };
62
- }
63
-
64
- /** The outcome of {@link commitBacklogIfDirty} / {@link commitBacklogFiles}. */
65
- export interface BacklogCommitResult {
66
- /** Whether a commit was made (`false` when there was nothing to commit, or a per-write commit failed). */
67
- readonly committed: boolean;
68
- /** The repo-relative paths committed, empty when {@link committed} is `false`. */
69
- readonly files: readonly string[];
70
- /**
71
- * Set only by {@link commitBacklogFiles} when a per-write commit was *attempted but failed* (a
72
- * `drift` git error, e.g. a rejected pre-commit hook): the failure message, captured rather than
73
- * thrown so the calling command can still emit its per-task report (naming every write it made)
74
- * before it exits `drift`. Never set on a success or a nothing-to-commit no-op, and never set by
75
- * {@link commitBacklogIfDirty} (`sync`'s catch-all sweep still throws on a git failure).
76
- */
77
- readonly error?: string;
78
- /**
79
- * Set alongside {@link error} when the underlying `drift` {@link LoreError} carried a `hint` — the
80
- * actual git/hook stderr reason `run()` captured via {@link stderrHint} (e.g. a rejected pre-commit
81
- * hook's real output), or its generic fallback when the failing invocation produced no stderr.
82
- * {@link renderBacklogCommitLine} folds this into its one output line so a caller sees the real
83
- * cause rather than only a bare "`git commit` exited N". Never set without {@link error}.
84
- */
85
- readonly hint?: string;
86
- }
87
-
88
- /** Where Backlog.md's own working state lives, relative to the repo root — the one directory lore commits on its behalf. */
89
- const BACKLOG_DIR = "backlog/";
90
-
91
- /**
92
- * Wrap a path in git's `:(literal)` pathspec magic so git matches it byte-for-byte: a filename
93
- * containing a wildcard (`*`, `?`, `[`…) then matches ONLY itself, never a glob-expanded unrelated
94
- * sibling. Without this the "scoped" commit could sweep in an unrelated `backlog/` file whose name
95
- * happens to satisfy the glob — defeating the very ADR-0012 §1 scoping guarantee. A plain directory
96
- * pathspec (`backlog/`) still recurses into everything under it: `:(literal)` disables only wildcard
97
- * interpretation, not directory-prefix matching (verified against real git).
98
- */
99
- function literalPathspec(path: string): string {
100
- return `:(literal)${path}`;
101
- }
102
-
103
- /**
104
- * The single line a command prints for its `backlog/`-commit outcome: `committed backlog/: N files`
105
- * on success, `backlog/ commit failed: … (hint)` when {@link BacklogCommitResult.error} is set (the
106
- * trailing `(hint)` present only when {@link BacklogCommitResult.hint} is too — the real git/hook
107
- * stderr reason, not just the generic "exited N" message), or `undefined` when nothing was
108
- * committed and nothing failed (a no-op the report omits entirely). Shared by `link`/`unlink`/
109
- * `rename`/`sync` so the one line they all emit for the same event — its label, count, and
110
- * pluralization — stays byte-identical in exactly one place.
111
- */
112
- export function renderBacklogCommitLine(commit: BacklogCommitResult): string | undefined {
113
- if (commit.error !== undefined) {
114
- const hintSuffix = commit.hint !== undefined ? ` (${commit.hint})` : "";
115
- return `backlog/ commit failed: ${commit.error}${hintSuffix}`;
116
- }
117
- if (commit.committed) {
118
- const noun = commit.files.length === 1 ? "file" : "files";
119
- return `committed backlog/: ${commit.files.length} ${noun}`;
120
- }
121
- return undefined;
122
- }
123
-
124
- /**
125
- * If `backlog/` has any uncommitted changes (tracked modifications, or new/untracked files),
126
- * stage exactly those paths and commit them in one `lore`-authored commit; otherwise a no-op.
127
- *
128
- * This is the concrete mechanism behind ADR-0012's "lore is the sole committer of `backlog/`": the
129
- * write itself (a Backlog CLI `task create`/`edit`) already happened elsewhere (`link`, `unlink`,
130
- * `rename`, or a human editing directly); this function only ever discovers and commits whatever is
131
- * sitting dirty under `backlog/` at the moment it runs. Nothing outside `backlog/` is staged, so an
132
- * in-flight, not-yet-committed change elsewhere in the tree (e.g. `sync`'s own `docs/` writes) is
133
- * never swept in.
134
- *
135
- * @throws LoreError `drift` (exit 6) when `git status`, `git add`, or `git commit` fails — surfaced
136
- * clearly rather than left silent (ADR-0012's own documented tradeoff: a `lore` write can succeed
137
- * while its commit fails, e.g. a pre-commit hook rejection).
138
- */
139
- export async function commitBacklogIfDirty(
140
- spawn: GitSpawn,
141
- message: string = DEFAULT_COMMIT_MESSAGE,
142
- pathspecs: readonly string[] = [BACKLOG_DIR],
143
- ): Promise<BacklogCommitResult> {
144
- // An empty pathspec set means "commit nothing" — never a bare `git status` (which would report the
145
- // WHOLE repo, not just backlog/). A caller with no files to commit ({@link commitBacklogFiles}) is
146
- // a no-op here rather than a repo-wide sweep.
147
- if (pathspecs.length === 0) {
148
- return { committed: false, files: [] };
149
- }
150
- const { addPaths, allPaths } = await porcelainPaths(spawn, pathspecs);
151
- if (allPaths.length === 0) {
152
- return { committed: false, files: [] };
153
- }
154
- // addPaths is never empty here: every entry porcelainPaths parses contributes at least one path to
155
- // BOTH addPaths and allPaths (a rename/copy's old path is the only thing added to allPaths alone),
156
- // so allPaths being non-empty (checked above) guarantees addPaths is too.
157
- await run(spawn, ["add", "--", ...addPaths.map(literalPathspec)], "git add");
158
- // Scoped with every touched path (including a staged rename/copy's old path, never passed to
159
- // `add` — see porcelainPaths): `git commit -- <paths>` commits ONLY those paths' content, leaving
160
- // any OTHER already-staged change (e.g. in-progress work a developer staged separately) untouched
161
- // in the index rather than swept into lore's commit — a bare, unscoped `git commit` would instead
162
- // commit the entire index.
163
- await run(spawn, ["commit", "-m", message, "--", ...allPaths.map(literalPathspec)], "git commit");
164
- return { committed: true, files: allPaths };
165
- }
166
-
167
- /** The default commit message when the caller does not supply one. */
168
- const DEFAULT_COMMIT_MESSAGE = "chore(backlog): sync task changes";
169
-
170
- /**
171
- * Commit exactly the `backlog/` `files` a `lore` command just wrote (each repo-relative, e.g.
172
- * `backlog/tasks/lore-1 - x.md`), in one `lore`-authored commit — the **per-write** commit
173
- * `link`/`unlink`/`rename` use so their `doc:<conceptId>` back-reference edit is committed
174
- * immediately rather than left for the next `lore sync`. Unlike {@link commitBacklogIfDirty}'s
175
- * bundle-wide `backlog/` sweep (which `sync` uses as the catch-all), this stages **only** the given
176
- * files' pathspecs (each `:(literal)`-quoted so a wildcard in a filename cannot glob-match a
177
- * sibling), so an unrelated uncommitted edit to a **different** `backlog/` file is never swept in —
178
- * [ADR-0012](../docs/adr/0012-backlog-coexistence-git-ownership.md) §1's "stage only the specific
179
- * task file(s) it intended to change".
180
- *
181
- * Isolation is at **file** granularity — all a `git commit -- <pathspec>` can give. An unrelated,
182
- * uncommitted edit already sitting in the working tree of one of the *same* files being committed is
183
- * part of that file's content and IS included (the only way to hit this is a developer hand-editing
184
- * the very task file lore is about to write). Every path must be under `backlog/`: lore is the sole
185
- * committer of `backlog/` and of nothing else (ADR-0012), so a path escaping it is a caller bug and
186
- * throws rather than committing outside lore's domain.
187
- *
188
- * An empty `files` (the command wrote nothing — `--no-back-ref`, an idempotent no-op, a read-phase
189
- * failure) no-ops inside {@link commitBacklogIfDirty}'s own empty-pathspec guard, never reaching
190
- * `git`. `gitSpawn` defaults to the real `git` binary scoped to `root`; injected in tests. A failed
191
- * `git add`/`commit` (a `drift` git error, e.g. a rejected pre-commit hook) is captured into the
192
- * result's {@link BacklogCommitResult.error} (with its {@link LoreError.hint} riding along in
193
- * {@link BacklogCommitResult.hint}) rather than thrown, so the caller can still emit its per-task
194
- * report before it exits `drift`; any non-`drift` error still propagates.
195
- */
196
- export async function commitBacklogFiles(
197
- files: readonly string[],
198
- opts: { readonly root: string; readonly gitSpawn?: GitSpawn },
199
- message: string,
200
- ): Promise<BacklogCommitResult> {
201
- // Structural scope guard: lore commits `backlog/` and nothing else (ADR-0012). Each path is
202
- // normalized (collapsing `.`/`..` segments) BEFORE the prefix check, and the NORMALIZED form —
203
- // not the raw one — is what gets passed to git from here on. A raw `startsWith` check alone is
204
- // fooled by a pathspec like `backlog/../docs/secret.md`: it textually starts with `backlog/` but
205
- // resolves outside it once git interprets the `..` segment — confirmed live that git honors `..`
206
- // even inside a `:(literal)`-quoted pathspec, so quoting alone does not neutralize it. Validating
207
- // only a normalized copy while still shelling the raw string out would leave the traversal
208
- // exploitable, hence normalizing in place. A NUL byte is rejected outright, BEFORE normalizing:
209
- // `posix.normalize` treats a segment like `"..\0"` as an ordinary (non-`..`) path component and
210
- // leaves it untouched, so it still passes the prefix check — but `Bun.spawn`'s argv is a C string,
211
- // silently truncated at that same NUL when it crosses into the actual `git` process. The two layers
212
- // disagree about where the path ends: normalize validates the full JS string, git only ever sees
213
- // the bytes before the NUL (confirmed live: a payload like `"backlog/.." + "\0" + "/x"` normalizes
214
- // to itself, starts with `backlog/`, and passes — but git receives only `:(literal)backlog/..`,
215
- // which resolves to the repo root). The paths come from Backlog's own `filePathRelative`, always
216
- // under `backlog/` today — this defends the invariant against a future Backlog layout change (or
217
- // an absolute/`../`/NUL-embedded path) rather than silently committing outside lore's domain, which
218
- // the removed bundle-wide sweep made structurally impossible. An empty `files` skips the loop
219
- // entirely (a no-op run).
220
- //
221
- // `posix.normalize` only ever collapses `/`-delimited segments, so a BACKSLASH-delimited `..`
222
- // climb (e.g. `backlog/x\..\..\outside.md` — the shape a platform-native `path.join` would resolve
223
- // outside `backlog/` under win32) survives it as one opaque, unchanged segment and still passes
224
- // the `startsWith` check above. `escapesRoot` (this codebase's shared, already-review-tested
225
- // traversal check — see LORE-69/72/80's convention of validating against the deployment platform,
226
- // not just the POSIX host running the fix) walks segments split on EITHER `/` or `\`, so it catches
227
- // this shape too. It must run on the path with the `backlog/` PREFIX ALREADY STRIPPED: running it
228
- // on the full, prefixed string would let the `backlog` segment itself absorb one level of the `..`
229
- // climb and miss the real payload — `escapesRoot` answers "does this climb above where it starts",
230
- // and the boundary that matters here is `backlog/`, not the string's own first character. Note this
231
- // guard is defense-in-depth rather than a fix for an active break today: downstream, git's own
232
- // `:(literal)` pathspec matching never treats `\` as a separator either, so this exact input shape
233
- // was already a silent no-op (nothing committed, in or out of `backlog/`) rather than an actual
234
- // escape — but the guard's own contract is a hard boundary regardless of what any current caller or
235
- // downstream consumer happens to do with the string, so it is rejected outright here too.
236
- const normalizedFiles = files.map((file) => {
237
- if (file.includes("\0")) {
238
- throw new LoreError(
239
- "drift",
240
- `refusing to commit "${JSON.stringify(file)}": embedded NUL byte`,
241
- "this is a bug — a task path with a NUL byte reached the per-write commit",
242
- { file },
243
- );
244
- }
245
- const normalized = posix.normalize(file);
246
- if (
247
- posix.isAbsolute(normalized) ||
248
- !normalized.startsWith(BACKLOG_DIR) ||
249
- escapesRoot(normalized.slice(BACKLOG_DIR.length))
250
- ) {
251
- throw new LoreError(
252
- "drift",
253
- `refusing to commit "${file}": lore only commits ${BACKLOG_DIR}`,
254
- "this is a bug — a task path outside backlog/ reached the per-write commit",
255
- { file },
256
- );
257
- }
258
- return normalized;
259
- });
260
- const gitSpawn = opts.gitSpawn ?? bunGitSpawn(opts.root);
261
- // Empty `files` is a no-op via commitBacklogIfDirty's own empty-pathspec guard — the single source
262
- // of truth for "nothing to commit", not re-implemented here.
263
- try {
264
- return await commitBacklogIfDirty(gitSpawn, message, normalizedFiles);
265
- } catch (err) {
266
- // Capture a git-side `drift` failure into the result so the caller still emits its report before
267
- // exiting `drift`; anything else is unexpected and propagates unchanged. `err.hint` rides along
268
- // (the actual git/hook stderr `run()` captured, or its generic fallback) so the rendered line
269
- // below isn't left with only a bare "exited N".
270
- if (err instanceof LoreError && err.type === "drift") {
271
- return { committed: false, files: [], error: err.message, hint: err.hint };
272
- }
273
- throw err;
274
- }
275
- }
276
-
277
- /** {@link porcelainPaths}'s result: the paths to `git add`, and the full set to scope the commit to (and report). */
278
- interface PorcelainPaths {
279
- /** Paths to `git add`. Excludes a staged rename/copy's OLD path — see the field below for why. */
280
- readonly addPaths: string[];
281
- /**
282
- * Every touched path, including a staged rename/copy's OLD path. `git commit -- <pathspec>` fills
283
- * in any path it does NOT see in the pathspec from `HEAD`'s tree rather than treating it as
284
- * absent, so a commit scoped to only the new path would resurrect the old file (its staged
285
- * deletion silently discarded, left stranded — staged and uncommitted — after the commit;
286
- * verified against real git). The old path is deliberately excluded from {@link addPaths}: a
287
- * `git mv`-staged rename has already fully removed the old path from the index (it is not a
288
- * pending change `add` can re-apply), so re-adding it fails outright with "did not match any
289
- * files" — it only ever needs to appear in the *commit's* pathspec, never `add`'s.
290
- */
291
- readonly allPaths: string[];
292
- }
293
-
294
- /**
295
- * The **cwd-relative** paths `git status --porcelain -z` reports as changed under `pathspecs`
296
- * (staged, unstaged, or untracked — every porcelain status code). Parses the NUL-delimited machine
297
- * format (`-z`), not the human `->`-separated text format: `-z` disables git's C-style quoting
298
- * entirely (every path is raw bytes, even one containing a space, a literal arrow, or non-ASCII
299
- * characters — no unescaping needed), and a rename/copy entry is two independent NUL-terminated
300
- * fields (`new-path\0old-path\0`, no `" -> "` text token at all) rather than one line joined by a
301
- * literal `" -> "` — which a text-format parser could otherwise mis-split when the path itself
302
- * contains that exact substring. An *unstaged* rename is reported by git as two ordinary
303
- * independent entries (a deletion, an untracked add) rather than one `R` entry at all — both come
304
- * through as ordinary single-field entries either way, so this needs no special casing (and both
305
- * belong in `addPaths` too, since neither is already-staged).
306
- *
307
- * Unlike `adapters/git.ts`'s `git log --relative` fix for the identical concern, `git status` has
308
- * **no** `--relative` flag at all — it always reports paths relative to the repository's top level,
309
- * never `cwd`. Since `git add`/`git commit` (the very next calls, run in the same `cwd`) resolve
310
- * their own pathspecs relative to `cwd`, a nested-bundle checkout (`cwd` below the repo's top level)
311
- * would otherwise feed them a path they can never match (verified against real git: a project nested
312
- * one level down reports `project/backlog/tasks/x.md`, but `git add` from `cwd=.../project` looks
313
- * for `project/project/backlog/tasks/x.md`). `git rev-parse --show-prefix` gives exactly the
314
- * translation needed — the path from the repo's top level down to `cwd` (e.g. `"project/"`, or `""`
315
- * when `cwd` already is the top level) — stripped from every reported path before it is used.
316
- */
317
- async function porcelainPaths(spawn: GitSpawn, pathspecs: readonly string[]): Promise<PorcelainPaths> {
318
- const prefixResult = await run(spawn, ["rev-parse", "--show-prefix"], "git rev-parse --show-prefix");
319
- // `.trim()` would also strip LEADING whitespace, corrupting the prefix when the bundle sits under
320
- // a directory whose name begins with whitespace (e.g. " proj/\n" -> "proj/", losing the leading
321
- // space that `git status` still reports). Strip only the single trailing newline git terminates
322
- // `--show-prefix` output with.
323
- const prefix = prefixResult.stdout.replace(/\r?\n$/, "");
324
- const cwdRelative = (path: string): string =>
325
- prefix !== "" && path.startsWith(prefix) ? path.slice(prefix.length) : path;
326
-
327
- // `--untracked-files=all` expands a brand-new untracked directory into its individual file paths
328
- // (plain `--porcelain` reports only the directory itself, e.g. `?? backlog/`) — scoped to `pathspecs`
329
- // so, unlike a bare repo-wide `-uall`, this never walks more of the tree than lore is committing.
330
- const result = await run(
331
- spawn,
332
- ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ...pathspecs.map(literalPathspec)],
333
- "git status",
334
- );
335
- const tokens = result.stdout.split("\0");
336
- const addPaths: string[] = [];
337
- const allPaths: string[] = [];
338
- for (let i = 0; i < tokens.length; i++) {
339
- const entry = tokens[i];
340
- if (entry === undefined || entry === "") {
341
- continue;
342
- }
343
- const status = entry.slice(0, 2);
344
- const path = cwdRelative(entry.slice(3)); // "XY " is always exactly 3 chars, even in -z mode
345
- addPaths.push(path);
346
- allPaths.push(path);
347
- if (status.includes("R") || status.includes("C")) {
348
- // The OLD path is the next NUL-terminated field — needed in the commit's pathspec (see
349
- // PorcelainPaths.allPaths) but NOT `add`'s (see PorcelainPaths.addPaths).
350
- const oldPath = tokens[++i];
351
- if (oldPath !== undefined && oldPath !== "") {
352
- allPaths.push(cwdRelative(oldPath));
353
- }
354
- }
355
- }
356
- // Last-line defense-in-depth (ADR-0012): every path `git status` reports back for a `backlog/`-
357
- // scoped pathspec must genuinely be under `backlog/` — both this function's callers (the catch-all
358
- // sweep's hardcoded `[BACKLOG_DIR]` default, and the per-write commit's caller-validated files)
359
- // only ever pass pathspecs already confined to `backlog/`, so this should never fire in practice.
360
- // It exists because the per-write caller's OWN validation (commitBacklogFiles) and what git
361
- // actually resolves/reports can diverge in ways that validation alone cannot rule out (LORE-69's
362
- // NUL-byte finding: a value can pass a JS-level check yet resolve differently once it crosses the
363
- // exec boundary into git) — re-checking git's own reported paths here, right before they are used
364
- // to `add`/`commit`, closes that whole class rather than just the one instance found.
365
- for (const path of allPaths) {
366
- if (!path.startsWith(BACKLOG_DIR)) {
367
- throw new LoreError(
368
- "drift",
369
- `refusing to commit "${path}": git reported a path outside ${BACKLOG_DIR} for a backlog/-scoped status`,
370
- "this is a bug — report it; lore's own scope guard did not prevent this",
371
- { path },
372
- );
373
- }
374
- }
375
- return { addPaths, allPaths };
376
- }
377
-
378
- /** Run one `git` invocation through {@link GitSpawn}, mapping a non-zero exit to a `drift` {@link LoreError}. */
379
- async function run(spawn: GitSpawn, args: readonly string[], label: string): Promise<GitSpawnResult> {
380
- const result = await spawn(args);
381
- if (result.exitCode !== 0) {
382
- throw new LoreError(
383
- "drift",
384
- `\`${label}\` exited ${result.exitCode}: lore could not commit backlog/ changes`,
385
- stderrHint(result.stderr) ?? "check the repository's git state (a dirty index, a rejected pre-commit hook, …)",
386
- { exitCode: result.exitCode },
387
- );
388
- }
389
- return result;
390
- }