@opum-ai/lore 0.1.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.
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/bin/lore.cjs +109 -0
- package/package.json +67 -0
- package/src/adapters/backlog.ts +1084 -0
- package/src/adapters/git.ts +221 -0
- package/src/cli.ts +667 -0
- package/src/commands/agent.ts +301 -0
- package/src/commands/agents.ts +302 -0
- package/src/commands/args.ts +209 -0
- package/src/commands/changed.ts +70 -0
- package/src/commands/check.ts +1031 -0
- package/src/commands/codex-bridge.ts +49 -0
- package/src/commands/concurrency.ts +48 -0
- package/src/commands/context.ts +292 -0
- package/src/commands/discover.ts +89 -0
- package/src/commands/explorer.ts +253 -0
- package/src/commands/export.ts +93 -0
- package/src/commands/fswrite.ts +928 -0
- package/src/commands/graph.ts +291 -0
- package/src/commands/help.ts +151 -0
- package/src/commands/impact.ts +59 -0
- package/src/commands/init.ts +583 -0
- package/src/commands/instructions.ts +91 -0
- package/src/commands/link.ts +929 -0
- package/src/commands/new.ts +476 -0
- package/src/commands/orphans.ts +457 -0
- package/src/commands/path.ts +67 -0
- package/src/commands/provenance.ts +68 -0
- package/src/commands/query.ts +312 -0
- package/src/commands/reconcile-shared.ts +280 -0
- package/src/commands/rename.ts +585 -0
- package/src/commands/replace.ts +320 -0
- package/src/commands/scaffold.ts +346 -0
- package/src/commands/schema.ts +293 -0
- package/src/commands/snapshot.ts +130 -0
- package/src/commands/supersede.ts +400 -0
- package/src/commands/sync.ts +371 -0
- package/src/commands/tasks.ts +271 -0
- package/src/commands/traversal.ts +151 -0
- package/src/commands/validate.ts +226 -0
- package/src/config.ts +598 -0
- package/src/core/agent-bridge.ts +287 -0
- package/src/core/agent-context.ts +498 -0
- package/src/core/agent-profile.ts +447 -0
- package/src/core/bundle.ts +893 -0
- package/src/core/check.ts +853 -0
- package/src/core/codex-bridge.ts +100 -0
- package/src/core/concept.ts +597 -0
- package/src/core/consumer-scaffold.ts +433 -0
- package/src/core/context.ts +271 -0
- package/src/core/explorer-contract.ts +441 -0
- package/src/core/explorer-qualification.ts +58 -0
- package/src/core/explorer.ts +518 -0
- package/src/core/finding.ts +31 -0
- package/src/core/graph.ts +201 -0
- package/src/core/indexes.ts +436 -0
- package/src/core/instructions.ts +209 -0
- package/src/core/ladybug-driver.ts +1795 -0
- package/src/core/ladybug-lifecycle.ts +1178 -0
- package/src/core/ladybug-native.ts +95 -0
- package/src/core/ladybug-source.ts +667 -0
- package/src/core/links.ts +681 -0
- package/src/core/log.ts +253 -0
- package/src/core/managed-block.ts +540 -0
- package/src/core/manifest.ts +718 -0
- package/src/core/order.ts +13 -0
- package/src/core/profile.ts +1007 -0
- package/src/core/projection.ts +195 -0
- package/src/core/query.ts +542 -0
- package/src/core/reconcile.ts +236 -0
- package/src/core/replace.ts +419 -0
- package/src/core/retrieval.ts +213 -0
- package/src/core/rewrite.ts +940 -0
- package/src/core/scaffold.ts +255 -0
- package/src/core/schema.ts +366 -0
- package/src/core/snapshot-runtime.ts +52 -0
- package/src/core/snapshot-store.ts +287 -0
- package/src/core/snapshot.ts +711 -0
- package/src/core/template.ts +429 -0
- package/src/core/traversal.ts +487 -0
- package/src/core/validate.ts +517 -0
- package/src/core/workspace-contract.ts +473 -0
- package/src/core/workspace-projection.ts +365 -0
- package/src/core/workspace-retrieval.ts +196 -0
- package/src/core/workspace-source.ts +174 -0
- package/src/errors.ts +697 -0
- package/src/meta.ts +7 -0
- package/src/output.ts +589 -0
- package/src/scripts/upstream-backlog-watch.ts +288 -0
- package/src/state.ts +390 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands/sync.ts — `lore sync [paths…] [--dry-run] [--no-index]` (LORE-26, cli-surface §sync).
|
|
3
|
+
*
|
|
4
|
+
* The **write** counterpart to `lore check`. For every concept linking Backlog tasks via its
|
|
5
|
+
* `tasks:` frontmatter: resolves each linked task's live status (`BacklogAdapter.viewTask`),
|
|
6
|
+
* recomputes the concept's `status` (`core/reconcile.ts`, honoring `[reconcile.overrides]`) and
|
|
7
|
+
* rewrites it when it changed, and regenerates the `<!-- lore:tasks -->` managed region
|
|
8
|
+
* (`core/managed-block.ts`) from the same live data. Then — unless `--no-index` — regenerates every
|
|
9
|
+
* bundle `index.md` (`core/indexes.ts`) and the git-history-derived `log.md` (`core/log.ts`, via the
|
|
10
|
+
* real `git log`-shelling adapter in `adapters/git.ts`). Every write is a byte-diff against the
|
|
11
|
+
* current on-disk content first, so a clean tree is a true no-op (AC#1) — and, unless `--dry-run`,
|
|
12
|
+
* the whole write set is committed **all-or-nothing** via {@link writeManyAtomicOrRollback}
|
|
13
|
+
* (LORE-120): each file is still written atomically ({@link writeFileAtomic}), but if any write in
|
|
14
|
+
* the set throws partway through, every file already written *in that same run* is rolled back to
|
|
15
|
+
* its pre-run bytes (or removed, if it did not exist before) rather than left in a mixed old/new
|
|
16
|
+
* state. Since this is the one command that can write many files in a single invocation, it is also
|
|
17
|
+
* the one that needs this cross-file guarantee, on top of `writeFileAtomic`'s own per-file one.
|
|
18
|
+
*
|
|
19
|
+
|
|
20
|
+
* **A linked task id that no longer resolves aborts the whole run before any write** (`not_found`,
|
|
21
|
+
* exit 3) — every linked task, across every scoped concept, is resolved up front, mirroring
|
|
22
|
+
* `commands/link.ts`'s "validate before write" precedent: a doc's `status` and managed block must
|
|
23
|
+
* never be computed from a partially-resolved task set.
|
|
24
|
+
*
|
|
25
|
+
* **`lore` is the sole committer of `backlog/`** (ADR-0012, design §2.4): after its own `docs/`
|
|
26
|
+
* writes, `sync` calls `state.ts`'s {@link commitBacklogIfDirty} to commit whatever is currently
|
|
27
|
+
* uncommitted under `backlog/`. This is independent of whether `sync` itself changed anything in
|
|
28
|
+
* `docs/`, and (like every write here) is skipped entirely under `--dry-run`. `link`/`unlink`/
|
|
29
|
+
* `rename` already commit their own touched files via `commitBacklogFiles` right after writing them
|
|
30
|
+
* (LORE-49) — nothing is left pending for `sync` on their account. `sync`'s commit step is a
|
|
31
|
+
* catch-all sweep: it picks up whatever is still dirty under `backlog/` from another source (a
|
|
32
|
+
* human's direct `backlog task edit`, or a prior run's commit that failed).
|
|
33
|
+
*
|
|
34
|
+
* A concept with `tasks:` but no managed-block markers is a fail-loud `validation` error
|
|
35
|
+
* (`core/managed-block.ts`'s own contract, ADR-0008) — `sync` never guesses or writes a partial
|
|
36
|
+
* block. A concept with no `tasks:` at all is never touched, and (mirroring `rename.ts`) no
|
|
37
|
+
* {@link BacklogAdapter} is even constructed unless at least one scoped concept links a task.
|
|
38
|
+
*
|
|
39
|
+
* An on-disk `index.md` whose directory no longer holds any concept — directly or via any
|
|
40
|
+
* descendant, e.g. after a manual `rm`/`mv` outside `lore rename` — is an **orphan**
|
|
41
|
+
* (`core/indexes.ts`'s {@link orphanedIndexPaths}, LORE-150): `generateIndexes` never emits an entry
|
|
42
|
+
* for it (only live directories are regenerated), so unlike every other stale file it is never
|
|
43
|
+
* written. It is still surfaced, distinctly from an "updated" file, in `SyncReport.orphanedIndexes`
|
|
44
|
+
* and the rendered report — left untouched on disk (deleting a hand-authored file is not this
|
|
45
|
+
* command's call to make unprompted) but no longer silently unmentioned.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { dirname, join } from "node:path";
|
|
49
|
+
import type { BacklogAdapter } from "../adapters/backlog";
|
|
50
|
+
import { realGitAdapter, resolveHeadSha } from "../adapters/git";
|
|
51
|
+
import { type BundleGraph, loadBundle } from "../core/bundle";
|
|
52
|
+
import { type Concept, idFromPath, parseConcept, serializeConcept } from "../core/concept";
|
|
53
|
+
import { generateIndexes, orphanedIndexPaths } from "../core/indexes";
|
|
54
|
+
import { buildLog, type GitAdapter, generateLog } from "../core/log";
|
|
55
|
+
import { regenerateTaskBlock } from "../core/managed-block";
|
|
56
|
+
import { loadProfile, type Profile } from "../core/profile";
|
|
57
|
+
import { type ReconciledStatus, validateReconcileInputs } from "../core/reconcile";
|
|
58
|
+
import { DOCS_DIR } from "../core/scaffold";
|
|
59
|
+
import { EXIT_OK, LoreError, readFileIfPresent, WarningCollector, type Writer } from "../errors";
|
|
60
|
+
import { emit, type OutputContext, type Renderable } from "../output";
|
|
61
|
+
import {
|
|
62
|
+
type BacklogCommitResult,
|
|
63
|
+
bunGitSpawn,
|
|
64
|
+
commitBacklogIfDirty,
|
|
65
|
+
type GitSpawn,
|
|
66
|
+
renderBacklogCommitLine,
|
|
67
|
+
} from "../state";
|
|
68
|
+
import { parseCommandArgs } from "./args";
|
|
69
|
+
import { readIndexBytes, readSource } from "./discover";
|
|
70
|
+
import { type AtomicRollbackWrite, assertNoSymlinkInAnyPath, ensureDir, writeManyAtomicOrRollback } from "./fswrite";
|
|
71
|
+
import { gatherReconciliation, linkedConcepts, readReconcileConfig } from "./reconcile-shared";
|
|
72
|
+
|
|
73
|
+
/** The reserved log file name, excluded from concept scanning (mirrors `rename.ts`'s index handling). */
|
|
74
|
+
const LOG_FILE = "log.md";
|
|
75
|
+
|
|
76
|
+
/** Options for {@link runSync}; `root`, the streams, and the adapters/seams are injectable for tests. */
|
|
77
|
+
export interface SyncOptions {
|
|
78
|
+
/** The repo root the `docs/` bundle (and `backlog/`) resolve against. */
|
|
79
|
+
root: string;
|
|
80
|
+
/** The resolved output mode/color (from `output.ts`). */
|
|
81
|
+
output: OutputContext;
|
|
82
|
+
/** The command's normalized positional + flag tokens from Commander. */
|
|
83
|
+
args: readonly string[];
|
|
84
|
+
/** stdout sink; defaults to `process.stdout`. */
|
|
85
|
+
stdout?: Writer;
|
|
86
|
+
/** stderr sink for bundle-load advisories; defaults to `process.stderr`. */
|
|
87
|
+
stderr?: Writer;
|
|
88
|
+
/** The Backlog adapter; defaults to the real `backlog` binary on PATH. Only constructed when at least one scoped concept links a task. */
|
|
89
|
+
adapter?: BacklogAdapter;
|
|
90
|
+
/** The git-history adapter (`core/log.ts`) for `log.md`; defaults to the real `git log`-shelling adapter. */
|
|
91
|
+
gitAdapter?: GitAdapter;
|
|
92
|
+
/** Resolves `HEAD` to a sha (or `null` with no commits yet); defaults to the real `git rev-parse HEAD`. */
|
|
93
|
+
resolveHead?: (root: string) => string | null;
|
|
94
|
+
/** The git-write seam (`state.ts`) for committing `backlog/`; defaults to the real `git` binary. */
|
|
95
|
+
gitSpawn?: GitSpawn;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The parsed form of `lore sync`'s arguments. */
|
|
99
|
+
interface SyncArgs {
|
|
100
|
+
/** Concept ids/paths to scope reconciliation + managed-block regen to; empty means every concept. Index/log regeneration is always whole-bundle. */
|
|
101
|
+
paths: string[];
|
|
102
|
+
/** `--dry-run`: report what would change, write nothing (docs/ or backlog/). */
|
|
103
|
+
dryRun: boolean;
|
|
104
|
+
/** `--no-index`: skip both index.md and log.md regeneration. */
|
|
105
|
+
noIndex: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One written (or, under `--dry-run`, would-be-written) file, for the report. */
|
|
109
|
+
interface ChangedFile {
|
|
110
|
+
/** Repo-relative POSIX path of the file. */
|
|
111
|
+
readonly path: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The `sync.result` payload. */
|
|
115
|
+
export interface SyncReport {
|
|
116
|
+
/** Every `docs/` file that changed (or would change), ascending. */
|
|
117
|
+
readonly files: readonly ChangedFile[];
|
|
118
|
+
/** How many files changed (== `files.length`). */
|
|
119
|
+
readonly filesChanged: number;
|
|
120
|
+
/** The `backlog/` commit outcome — always `{committed: false, files: []}` under `--dry-run`. */
|
|
121
|
+
readonly backlogCommit: BacklogCommitResult;
|
|
122
|
+
/** Whether this was a `--dry-run` (nothing was written). */
|
|
123
|
+
readonly dryRun: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Repo-relative paths of on-disk `index.md` files whose directory no longer holds any concept,
|
|
126
|
+
* directly or via any descendant (LORE-150) — e.g. after a manual `rm`/`mv` outside `lore rename`.
|
|
127
|
+
* Reported distinctly from `files` (not counted in `filesChanged`): the file is left untouched on
|
|
128
|
+
* disk, since deleting a hand-authored file is not this command's call to make unprompted, but it
|
|
129
|
+
* is no longer silently unmentioned either. Always `[]` when `--no-index` skipped index regen.
|
|
130
|
+
* Ascending.
|
|
131
|
+
*/
|
|
132
|
+
readonly orphanedIndexes: readonly string[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Run `lore sync`: reconcile every scoped concept's `status` and managed task block from live
|
|
137
|
+
* Backlog data, regenerate `index.md`/`log.md` (unless `--no-index`), write every changed file
|
|
138
|
+
* (unless `--dry-run`), commit any dirty `backlog/` changes, emit the `sync.result`, and return the
|
|
139
|
+
* exit code.
|
|
140
|
+
*
|
|
141
|
+
* @returns `0` on success (a fully clean tree is still `0` — idempotent). Throws (never returns) a
|
|
142
|
+
* `not_found` {@link LoreError} (exit `3`) when a linked task id no longer exists, or `validation`/
|
|
143
|
+
* `drift` (exit `6`) when reconciliation, the managed block, or the `backlog/` commit fails.
|
|
144
|
+
*/
|
|
145
|
+
export async function runSync(options: SyncOptions): Promise<number> {
|
|
146
|
+
const parsed = parseSyncArgs(options.args);
|
|
147
|
+
const docsRoot = join(options.root, DOCS_DIR);
|
|
148
|
+
const advisories = new WarningCollector();
|
|
149
|
+
// Loaded unconditionally, before the bundle: loadBundle validates every concept's frontmatter
|
|
150
|
+
// against this profile (LORE-84), and it runs regardless of reconciliation eligibility, so the
|
|
151
|
+
// profile can no longer be deferred to the eligibility-gated block below.
|
|
152
|
+
const profile = loadProfile({ root: options.root });
|
|
153
|
+
const graph = loadBundle(docsRoot, { warnings: advisories, profile });
|
|
154
|
+
advisories.flush({ color: options.output.color, stderr: options.stderr });
|
|
155
|
+
|
|
156
|
+
const scoped = scopeConcepts(graph, parsed.paths);
|
|
157
|
+
// This command's precedence for when MULTIPLE local config sources are simultaneously broken:
|
|
158
|
+
// a malformed .lore/profile.toml now surfaces FIRST, unconditionally — profile loads above,
|
|
159
|
+
// before loadBundle, so its own parse failure throws before docsRoot is even walked (LORE-84
|
|
160
|
+
// superseded the pre-LORE-27 "profile loads only when reconciliation is eligible" precedence:
|
|
161
|
+
// loadBundle needs the same profile for every sync run, eligible or not). backlog/config.yml/
|
|
162
|
+
// .lore/config.toml SYNTAX errors (readReconcileConfig, a plain read — no semantic check yet)
|
|
163
|
+
// surface next, then validateReconcileInputs's SEMANTIC checks (a duplicate flow entry, an
|
|
164
|
+
// invalid override target) — both still conditioned on eligibility (mirrors gatherReconciliation's
|
|
165
|
+
// own check) so a bundle with nothing to reconcile never pays for either. The resolved,
|
|
166
|
+
// already-validated config is then passed straight into gatherReconciliation so it is never
|
|
167
|
+
// read/validated a second time.
|
|
168
|
+
const eligible = linkedConcepts(scoped).length > 0;
|
|
169
|
+
const config = eligible ? readReconcileConfig(options.root) : undefined;
|
|
170
|
+
if (config !== undefined) {
|
|
171
|
+
validateReconcileInputs(config.flow, config.overrides);
|
|
172
|
+
}
|
|
173
|
+
const targets = await gatherReconciliation(options.root, scoped, options.adapter, config);
|
|
174
|
+
|
|
175
|
+
// bundle-relative path -> { before: pre-run bytes (undefined if the file didn't exist), after: new
|
|
176
|
+
// bytes }. `before` is captured here, at diff time, alongside `after` — never re-derived later —
|
|
177
|
+
// so a mid-run rollback (LORE-120) always restores exactly what was actually on disk before this
|
|
178
|
+
// run touched it, not a re-read that could itself race against a concurrent edit.
|
|
179
|
+
const writes = new Map<string, { before: string | undefined; after: string }>();
|
|
180
|
+
for (const { concept, newStatus, rows } of targets) {
|
|
181
|
+
const docPath = `${DOCS_DIR}/${concept.path}`;
|
|
182
|
+
const original = readSource(join(docsRoot, concept.path), docPath);
|
|
183
|
+
// Derived from the FRESHLY re-read `original` bytes, not the stale in-memory `concept` object
|
|
184
|
+
// captured (in `targets`, via `gatherReconciliation`) before the async Backlog round-trip: a
|
|
185
|
+
// concurrent on-disk edit landing on this doc during that round-trip must survive a
|
|
186
|
+
// status-changing sync write, not be silently discarded in favor of a pre-round-trip snapshot
|
|
187
|
+
// (LORE-119).
|
|
188
|
+
const base =
|
|
189
|
+
newStatus !== null && newStatus !== concept.frontmatter.status
|
|
190
|
+
? withUpdatedStatus(concept.path, original, newStatus, profile)
|
|
191
|
+
: original;
|
|
192
|
+
|
|
193
|
+
const final = regenerateTaskBlock(base, rows, { docPath });
|
|
194
|
+
if (final !== original) {
|
|
195
|
+
writes.set(concept.path, { before: original, after: final });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const orphanedIndexes = parsed.noIndex ? [] : regenerateIndexAndLog(options, docsRoot, graph, writes);
|
|
200
|
+
|
|
201
|
+
if (!parsed.dryRun) {
|
|
202
|
+
// Swept as a whole BEFORE any write starts (LORE-93 AC#5): ensureDir's own per-call guard
|
|
203
|
+
// below is reactive — in this loop, it would only refuse once it REACHES a bad target, by
|
|
204
|
+
// which point earlier targets in the same `writes` map may already be on disk. A single
|
|
205
|
+
// preflight over every planned path makes the write either fully proceed or refuse before
|
|
206
|
+
// touching anything.
|
|
207
|
+
assertNoSymlinkInAnyPath(
|
|
208
|
+
options.root,
|
|
209
|
+
[...writes.keys()].map((path) => `${DOCS_DIR}/${path}`),
|
|
210
|
+
);
|
|
211
|
+
// All-or-nothing across the whole set (LORE-120): every parent directory is created up front
|
|
212
|
+
// (mkdir -p is itself idempotent — nothing to roll back there), then the actual byte writes go
|
|
213
|
+
// through writeManyAtomicOrRollback, which undoes every write already applied in this same run
|
|
214
|
+
// if a later one throws, rather than leaving an arbitrary prefix of `writes` committed and the
|
|
215
|
+
// rest not.
|
|
216
|
+
const rollbackWrites: AtomicRollbackWrite[] = [];
|
|
217
|
+
for (const [path, { before, after }] of writes) {
|
|
218
|
+
ensureDir(options.root, dirname(`${DOCS_DIR}/${path}`));
|
|
219
|
+
rollbackWrites.push({ abs: join(docsRoot, path), relPath: `${DOCS_DIR}/${path}`, before, after });
|
|
220
|
+
}
|
|
221
|
+
writeManyAtomicOrRollback(rollbackWrites);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let backlogCommit: BacklogCommitResult = { committed: false, files: [] };
|
|
225
|
+
if (!parsed.dryRun) {
|
|
226
|
+
const gitSpawn = options.gitSpawn ?? bunGitSpawn(options.root);
|
|
227
|
+
backlogCommit = await commitBacklogIfDirty(gitSpawn);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const files = [...writes.keys()].sort().map((path) => ({ path: `${DOCS_DIR}/${path}` }));
|
|
231
|
+
const report: SyncReport = {
|
|
232
|
+
files,
|
|
233
|
+
filesChanged: files.length,
|
|
234
|
+
backlogCommit,
|
|
235
|
+
dryRun: parsed.dryRun,
|
|
236
|
+
orphanedIndexes: orphanedIndexes.map((path) => `${DOCS_DIR}/${path}`),
|
|
237
|
+
};
|
|
238
|
+
emit(reportRenderable(report), options.output, options.stdout);
|
|
239
|
+
return EXIT_OK;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Re-parse `raw` — the freshly re-read on-disk bytes for the concept at `path` — and re-serialize it
|
|
244
|
+
* with `status` applied to its frontmatter. Every other frontmatter key and the body come straight
|
|
245
|
+
* from `raw` itself, never from an earlier in-memory snapshot, so a concurrent on-disk edit made to
|
|
246
|
+
* the doc between the initial bundle load and this status-changing write survives it (LORE-119). Only
|
|
247
|
+
* `status` is overwritten — sync's `newStatus` is always the authoritative, live-reconciled value, so
|
|
248
|
+
* a concurrent edit to `status` itself (were one to land) is still resolved to what Backlog reports,
|
|
249
|
+
* exactly as an uncontended sync would resolve it.
|
|
250
|
+
*/
|
|
251
|
+
function withUpdatedStatus(path: string, raw: string, status: ReconciledStatus, profile: Profile): string {
|
|
252
|
+
const fresh = parseConcept(path, raw, { profile });
|
|
253
|
+
return serializeConcept({ ...fresh, frontmatter: { ...fresh.frontmatter, status } }, { profile });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Index + log regeneration ────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Regenerate every `index.md` and `log.md`, adding an entry to `writes` for each one whose bytes
|
|
260
|
+
* actually changed, and return the bundle-relative paths of any **orphaned** on-disk `index.md`
|
|
261
|
+
* files — one whose directory no longer holds a concept, directly or via any descendant (LORE-150) —
|
|
262
|
+
* so the caller can report them distinctly instead of `generateIndexes` silently never mentioning
|
|
263
|
+
* them (it only ever emits entries for live directories; a stale disk entry outside that set is
|
|
264
|
+
* simply absent from its returned map, indistinguishable from "unchanged" without this comparison).
|
|
265
|
+
* Always whole-bundle, regardless of `[paths…]` scoping — both index regeneration and orphan
|
|
266
|
+
* detection are inherently global (a hub lists its whole directory; the log is derived from all of
|
|
267
|
+
* git history).
|
|
268
|
+
*/
|
|
269
|
+
function regenerateIndexAndLog(
|
|
270
|
+
options: SyncOptions,
|
|
271
|
+
docsRoot: string,
|
|
272
|
+
graph: BundleGraph,
|
|
273
|
+
writes: Map<string, { before: string | undefined; after: string }>,
|
|
274
|
+
): readonly string[] {
|
|
275
|
+
const diskIndexBytes = readIndexBytes(docsRoot);
|
|
276
|
+
const regeneratedIndexes = generateIndexes(graph, { existing: diskIndexBytes });
|
|
277
|
+
for (const [path, bytes] of regeneratedIndexes) {
|
|
278
|
+
const before = diskIndexBytes.get(path);
|
|
279
|
+
if (bytes !== before) {
|
|
280
|
+
writes.set(path, { before, after: bytes });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const orphaned = orphanedIndexPaths(graph, diskIndexBytes);
|
|
284
|
+
|
|
285
|
+
const resolveHead = options.resolveHead ?? resolveHeadSha;
|
|
286
|
+
const headSha = resolveHead(options.root);
|
|
287
|
+
const logBytes =
|
|
288
|
+
headSha === null
|
|
289
|
+
? generateLog([], { root: DOCS_DIR })
|
|
290
|
+
: buildLog(options.gitAdapter ?? realGitAdapter(options.root), { to: headSha }, { root: DOCS_DIR });
|
|
291
|
+
const existingLog = readFileIfPresent(join(docsRoot, LOG_FILE), `${DOCS_DIR}/${LOG_FILE}`);
|
|
292
|
+
if (logBytes !== existingLog) {
|
|
293
|
+
writes.set(LOG_FILE, { before: existingLog, after: logBytes });
|
|
294
|
+
}
|
|
295
|
+
return orphaned;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── Scoping ────────────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Filter `graph`'s concepts to those under one of `paths` (each resolved to a concept id via
|
|
302
|
+
* {@link idFromPath}, matched as an exact id or a directory prefix); an empty `paths` scopes to
|
|
303
|
+
* every concept. Index/log regeneration is never scoped this way (see {@link regenerateIndexAndLog}).
|
|
304
|
+
*/
|
|
305
|
+
function scopeConcepts(graph: BundleGraph, paths: readonly string[]): Concept[] {
|
|
306
|
+
const all = [...graph.concepts.values()];
|
|
307
|
+
if (paths.length === 0) {
|
|
308
|
+
return all;
|
|
309
|
+
}
|
|
310
|
+
// Strip a trailing slash before deriving the id: idFromPath() preserves it verbatim, and a
|
|
311
|
+
// trailing "/" (natural from shell tab-completion of a directory) would otherwise never match
|
|
312
|
+
// any concept id (`c.id === "stories/foo/"` and `c.id.startsWith("stories/foo//")` are both
|
|
313
|
+
// always false), silently scoping to nothing rather than the whole "stories/foo" directory.
|
|
314
|
+
const prefixes = paths.map((p) => idFromPath(p.replace(/\/+$/, "")));
|
|
315
|
+
for (const prefix of prefixes) {
|
|
316
|
+
if (!all.some((c) => matchesScope(c.id, prefix))) {
|
|
317
|
+
// A path/id that matches nothing (a typo, a trailing slash that still resolves to no
|
|
318
|
+
// directory, a concept that was never linked) is a fail-loud usage error, not a silent
|
|
319
|
+
// empty scope — matching link/unlink/rename's `conceptNotInBundle` precedent: a `lore sync`
|
|
320
|
+
// that quietly reconciled zero concepts and reported "0 files changed" would read as "already
|
|
321
|
+
// in sync" when it never looked at the intended target at all.
|
|
322
|
+
throw new LoreError(
|
|
323
|
+
"not_found",
|
|
324
|
+
`no concept found at or under "${prefix}"`,
|
|
325
|
+
"check the id/path and try again — run `lore query` or `lore graph` to see known concept ids",
|
|
326
|
+
{ path: prefix },
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return all.filter((c) => prefixes.some((prefix) => matchesScope(c.id, prefix)));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Whether `id` is exactly `prefix` or lives under it as a directory prefix. */
|
|
334
|
+
function matchesScope(id: string, prefix: string): boolean {
|
|
335
|
+
return id === prefix || id.startsWith(`${prefix}/`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Argument parsing ───────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
/** Parse `sync`'s tokens into `[paths…]`, `--dry-run`, and `--no-index` via the shared parser. */
|
|
341
|
+
function parseSyncArgs(args: readonly string[]): SyncArgs {
|
|
342
|
+
const { positionals, flags } = parseCommandArgs(args, "sync");
|
|
343
|
+
return { paths: positionals, dryRun: flags.has("dry-run"), noIndex: flags.has("no-index") };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── Output ─────────────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
/** The per-result-type rendering bundle for `sync` (output.ts dispatches on the mode). */
|
|
349
|
+
function reportRenderable(data: SyncReport): Renderable<SyncReport> {
|
|
350
|
+
return { kind: "sync.result", data, pretty: render, plain: render };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* One line per changed file, one line per orphaned index (distinct from "updated": the file is
|
|
355
|
+
* reported but not written, LORE-150), the backlog-commit outcome (if any), then a summary line.
|
|
356
|
+
* (No color: no severities.)
|
|
357
|
+
*/
|
|
358
|
+
function render(data: SyncReport): string {
|
|
359
|
+
const verb = data.dryRun ? "would update" : "updated";
|
|
360
|
+
const lines = data.files.map((f) => `${verb} ${f.path}`);
|
|
361
|
+
for (const path of data.orphanedIndexes) {
|
|
362
|
+
lines.push(`orphaned index ${path} (no concepts remain under this directory; left untouched)`);
|
|
363
|
+
}
|
|
364
|
+
const commitLine = renderBacklogCommitLine(data.backlogCommit);
|
|
365
|
+
if (commitLine !== undefined) {
|
|
366
|
+
lines.push(commitLine);
|
|
367
|
+
}
|
|
368
|
+
const noun = data.filesChanged === 1 ? "file" : "files";
|
|
369
|
+
lines.push(`${data.filesChanged} ${noun} changed${data.dryRun ? " (dry-run)" : ""}`);
|
|
370
|
+
return lines.join("\n");
|
|
371
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands/tasks.ts — `lore tasks <id> [--status <S>]` (cli-surface §tasks).
|
|
3
|
+
*
|
|
4
|
+
* The thin, read-only view of a concept's **live** Backlog task rollup: it loads the
|
|
5
|
+
* `docs/` bundle, resolves the target concept by id, reads that concept's `tasks:`
|
|
6
|
+
* frontmatter list, and fetches each linked task's CURRENT state from the Backlog
|
|
7
|
+
* JSON adapter — the same live data `lore sync` materializes into a concept's managed
|
|
8
|
+
* `<!-- lore:tasks -->` block, but rendered directly here and written nowhere.
|
|
9
|
+
*
|
|
10
|
+
* Output follows the uniform CLI modes: the `{schemaVersion, kind: "tasks.rollup",
|
|
11
|
+
* data}` envelope under `--json` — `data` an object `{ concept, status?, tasks }` whose
|
|
12
|
+
* `tasks` are `{id, title, status}` rows in the concept's own `tasks:` order (wrapped in
|
|
13
|
+
* an object, like every sibling list command, so the contract can grow additively) —
|
|
14
|
+
* and otherwise an aligned text table. `--status <S>` filters the rows to one Backlog
|
|
15
|
+
* status (case-insensitive match on the configured label) and is echoed back on `status`.
|
|
16
|
+
*
|
|
17
|
+
* Failure modes, in the order they can occur:
|
|
18
|
+
* - a bad flag / missing-or-duplicate `<id>` is a `usage` error (exit 2);
|
|
19
|
+
* - an `<id>` absent from the bundle is a `not_found` error (exit 3);
|
|
20
|
+
* - Backlog being unavailable is fail-fast: the adapter's capability probe runs UP FRONT,
|
|
21
|
+
* so a missing binary (exit 3) or a stock, non-`--json` binary (exit 6) is reported
|
|
22
|
+
* before any per-task read. That ordering is what lets a `viewTask` null AFTER a passing
|
|
23
|
+
* probe mean, unambiguously, "this linked id is a dangling reference" — dropped from the
|
|
24
|
+
* rollup with a stderr advisory, exit 0 — rather than "Backlog is broken". `orphans`
|
|
25
|
+
* (LORE-32) is the dedicated dangling-link report; `tasks` only notes them so its rollup
|
|
26
|
+
* shows just the live tasks. A per-task read that *fails* (Backlog drift) still propagates;
|
|
27
|
+
* a per-task read that *succeeds* but answers with a DIFFERENT task's id than requested is
|
|
28
|
+
* also a hard `not_found` error (exit 3), never silently attributed to the requested row —
|
|
29
|
+
* via the same shared guard `commands/link.ts`'s `verifiedViewTask` exports (LORE-183; this
|
|
30
|
+
* module no longer hand-maintains its own copy, LORE-125) — the SAME guard `reconcile-shared.ts`'s
|
|
31
|
+
* `resolveTaskDetails` (originally LORE-122) now also delegates to (LORE-183), so the
|
|
32
|
+
* comparison/`LoreError` lives in exactly one place across `link.ts`/`tasks.ts`/`reconcile-shared.ts`.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import type { BacklogAdapter } from "../adapters/backlog";
|
|
37
|
+
import { conceptNotInBundle, loadBundle, toRefList } from "../core/bundle";
|
|
38
|
+
import { idFromPath } from "../core/concept";
|
|
39
|
+
import { loadProfile } from "../core/profile";
|
|
40
|
+
import { DOCS_DIR } from "../core/scaffold";
|
|
41
|
+
import { ANSI, EXIT_OK, paint, WarningCollector, type Writer } from "../errors";
|
|
42
|
+
import { emit, type OutputContext, type Renderable, renderTaskSummaryRows, type TaskSummaryRow } from "../output";
|
|
43
|
+
import { parseCommandArgs, singleOptionValue, usage } from "./args";
|
|
44
|
+
import { mapWithConcurrency, TASK_DETAILS_CONCURRENCY } from "./concurrency";
|
|
45
|
+
import { dedupeTaskIds, defaultAdapter, verifiedViewTask } from "./link";
|
|
46
|
+
|
|
47
|
+
/** Options for {@link runTasks}; `root`, the streams, and the adapter are injectable for tests. */
|
|
48
|
+
export interface TasksOptions {
|
|
49
|
+
/** The repo root the `docs/` bundle and the Backlog adapter resolve against. */
|
|
50
|
+
root: string;
|
|
51
|
+
/** The resolved output mode/color (from `output.ts`). */
|
|
52
|
+
output: OutputContext;
|
|
53
|
+
/** The command's normalized positional + flag tokens from Commander. */
|
|
54
|
+
args: readonly string[];
|
|
55
|
+
/** stdout sink; defaults to `process.stdout`. */
|
|
56
|
+
stdout?: Writer;
|
|
57
|
+
/** stderr sink for advisory warnings; defaults to `process.stderr`. */
|
|
58
|
+
stderr?: Writer;
|
|
59
|
+
/** The Backlog adapter; defaults to the real `backlog` binary resolved against `root`. Injected for tests. */
|
|
60
|
+
adapter?: BacklogAdapter;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The parsed form of `lore tasks`'s arguments. */
|
|
64
|
+
interface TasksArgs {
|
|
65
|
+
/** The target concept id (positional, already {@link idFromPath}-normalized). */
|
|
66
|
+
id: string;
|
|
67
|
+
/** The `--status` filter, when given (case-insensitively matched against each task's live status). */
|
|
68
|
+
status?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One row of the rollup: a linked task's live identity + current Backlog status, in `tasks:` order. */
|
|
72
|
+
export type TaskRollupRow = TaskSummaryRow;
|
|
73
|
+
|
|
74
|
+
/** The `tasks.rollup` payload: the concept, the applied `--status` filter (when any), and the live rows. */
|
|
75
|
+
export interface TaskRollup {
|
|
76
|
+
/** The resolved concept id whose `tasks:` were rolled up. */
|
|
77
|
+
readonly concept: string;
|
|
78
|
+
/** The applied `--status` filter, canonicalized to the matched rows' Backlog casing (raw input when nothing matched); omitted when no filter was given. */
|
|
79
|
+
readonly status?: string;
|
|
80
|
+
/** The linked tasks' live data, in the concept's own `tasks:` order (dangling ids dropped). */
|
|
81
|
+
readonly tasks: readonly TaskRollupRow[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Run `lore tasks`: parse the arguments, load the bundle, resolve the concept, fetch its
|
|
86
|
+
* linked tasks' live status from Backlog, emit the `tasks.rollup`, and return `0`. Async
|
|
87
|
+
* because it drives the Backlog subprocess. See the module docstring for the failure modes.
|
|
88
|
+
*/
|
|
89
|
+
export async function runTasks(options: TasksOptions): Promise<number> {
|
|
90
|
+
const parsed = parseTasksArgs(options.args);
|
|
91
|
+
const docsRoot = join(options.root, DOCS_DIR);
|
|
92
|
+
const advisories = new WarningCollector();
|
|
93
|
+
const profile = loadProfile({ root: options.root });
|
|
94
|
+
const graph = loadBundle(docsRoot, { warnings: advisories, profile });
|
|
95
|
+
// Flush load warnings before the not_found throw below, so an advisory explaining *why* a
|
|
96
|
+
// file is not a concept survives on exactly the path that most needs it (mirrors `lore context`).
|
|
97
|
+
advisories.flush({ color: options.output.color, stderr: options.stderr });
|
|
98
|
+
|
|
99
|
+
const concept = graph.concepts.get(parsed.id);
|
|
100
|
+
if (concept === undefined) {
|
|
101
|
+
throw conceptNotInBundle(parsed.id);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const linked = dedupeTaskIds(toRefList(concept.frontmatter.tasks));
|
|
105
|
+
const tasks = await resolveRollup(linked, parsed.status, options);
|
|
106
|
+
const status = echoedStatus(parsed.status, tasks);
|
|
107
|
+
const data: TaskRollup = status === undefined ? { concept: parsed.id, tasks } : { concept: parsed.id, status, tasks };
|
|
108
|
+
|
|
109
|
+
emit(tasksRenderable(data), options.output, options.stdout);
|
|
110
|
+
return EXIT_OK;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The `--status` filter to echo on the envelope. `--status` matches case-insensitively, so echoing the
|
|
115
|
+
* user's raw input verbatim could disagree in case with every row it selected (`--status done` →
|
|
116
|
+
* `status: "done"` beside rows whose status is `"Done"`). Canonicalize it to the matched rows' actual
|
|
117
|
+
* Backlog casing so `data.status` always agrees with the tasks it produced; fall back to the raw filter
|
|
118
|
+
* only when nothing matched (no row to canonicalize against). `undefined` when no filter was given.
|
|
119
|
+
*/
|
|
120
|
+
function echoedStatus(filter: string | undefined, tasks: readonly TaskRollupRow[]): string | undefined {
|
|
121
|
+
if (filter === undefined) {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
return tasks[0]?.status ?? filter;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve every linked task id to its live {@link TaskRollupRow}, filtered to `status` when given.
|
|
129
|
+
* Returns `[]` without touching Backlog when the concept links nothing (mirrors
|
|
130
|
+
* `gatherReconciliation` constructing no adapter for an uncoupled concept).
|
|
131
|
+
*
|
|
132
|
+
* The Backlog capability probe runs UP FRONT so a missing/incapable binary fails fast (exit 3/6)
|
|
133
|
+
* before any per-task read — the disambiguation the module docstring relies on. Reads then run
|
|
134
|
+
* concurrently, bounded to {@link TASK_DETAILS_CONCURRENCY} in flight at once via the shared
|
|
135
|
+
* {@link mapWithConcurrency} worker-pool (mirroring `resolveTaskDetails`'s own LORE-111 fix, so a
|
|
136
|
+
* concept whose `tasks:` list links many ids never fans out one Backlog CLI subprocess per id fully
|
|
137
|
+
* concurrently). Each read's outcome is captured into a settled-result array, in `tasks:` order, so a
|
|
138
|
+
* second failing read never escapes as an unhandled rejection: the FIRST read that *fails* — in
|
|
139
|
+
* `tasks:` order — is rethrown as a hard error (Backlog drift), exactly as `lore check`/`sync` treat
|
|
140
|
+
* it, while a clean `null` — a task Backlog does not know — is soft: dropped from the rollup with a
|
|
141
|
+
* stderr advisory (`orphans` owns the dangling-link report).
|
|
142
|
+
*
|
|
143
|
+
* Each read goes through `commands/link.ts`'s exported {@link verifiedViewTask} (LORE-183), not a
|
|
144
|
+
* raw `adapter.viewTask` call: it checks the returned detail's own `id` against the id actually
|
|
145
|
+
* requested at that position (case-insensitively) and throws before this function ever sees a
|
|
146
|
+
* mismatched detail — `viewTask` is keyed by id, but nothing enforces that the detail it returns
|
|
147
|
+
* actually carries that id back, and trusting it blindly would let a mismatch silently attribute
|
|
148
|
+
* another task's title/status to this row. That thrown `LoreError` surfaces here as an ordinary
|
|
149
|
+
* rejected settle, handled identically to any other failed read (a hard `not_found` failure,
|
|
150
|
+
* exactly as before LORE-125 folded this module's own copy of the check into the shared helper).
|
|
151
|
+
* The `--status` filter is applied last, to the live rows only.
|
|
152
|
+
*/
|
|
153
|
+
async function resolveRollup(
|
|
154
|
+
linked: readonly string[],
|
|
155
|
+
status: string | undefined,
|
|
156
|
+
options: TasksOptions,
|
|
157
|
+
): Promise<TaskRollupRow[]> {
|
|
158
|
+
if (linked.length === 0) {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
const adapter = options.adapter ?? defaultAdapter(options.root);
|
|
162
|
+
await adapter.probe();
|
|
163
|
+
|
|
164
|
+
const settled: PromiseSettledResult<Awaited<ReturnType<typeof verifiedViewTask>>>[] = new Array(linked.length);
|
|
165
|
+
await mapWithConcurrency(
|
|
166
|
+
linked.map((id, index) => ({ id, index })),
|
|
167
|
+
TASK_DETAILS_CONCURRENCY,
|
|
168
|
+
async ({ id, index }) => {
|
|
169
|
+
try {
|
|
170
|
+
const value = await verifiedViewTask(adapter, id);
|
|
171
|
+
settled[index] = { status: "fulfilled", value };
|
|
172
|
+
} catch (reason) {
|
|
173
|
+
settled[index] = { status: "rejected", reason };
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
const dangling: string[] = [];
|
|
178
|
+
const rows: TaskRollupRow[] = [];
|
|
179
|
+
for (let i = 0; i < linked.length; i++) {
|
|
180
|
+
const result = settled[i] as PromiseSettledResult<Awaited<ReturnType<typeof verifiedViewTask>>>;
|
|
181
|
+
if (result.status === "rejected") {
|
|
182
|
+
// A read that FAILED — Backlog drift, or `verifiedViewTask`'s own id-mismatch guard refusing
|
|
183
|
+
// an adapter detail that doesn't belong to the requested task — is hard-failed on the first,
|
|
184
|
+
// in tasks: order, before any partial rollup or advisory is emitted.
|
|
185
|
+
throw result.reason;
|
|
186
|
+
}
|
|
187
|
+
if (result.value === null) {
|
|
188
|
+
dangling.push(linked[i] as string);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
rows.push({ id: result.value.id, title: result.value.title, status: result.value.status });
|
|
192
|
+
}
|
|
193
|
+
if (dangling.length > 0) {
|
|
194
|
+
warnDangling(dangling, options);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return status === undefined ? rows : rows.filter((row) => row.status.toLowerCase() === status.toLowerCase());
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Advise (on stderr) that one or more `tasks:` ids no longer resolve to a live Backlog task and were
|
|
202
|
+
* dropped from the rollup — a coupling gap worth surfacing without failing the read (`lore orphans`
|
|
203
|
+
* is the CI-grade report). Routed through the shared {@link WarningCollector} so it renders with the
|
|
204
|
+
* exact `warning:`-prefix format and coloring as this command's load advisories and every other lore
|
|
205
|
+
* warning, rather than a hand-painted line.
|
|
206
|
+
*/
|
|
207
|
+
function warnDangling(ids: readonly string[], options: TasksOptions): void {
|
|
208
|
+
const noun = ids.length === 1 ? "task" : "tasks";
|
|
209
|
+
const advisories = new WarningCollector();
|
|
210
|
+
advisories.add(`${ids.length} linked ${noun} not in Backlog, dropped from the rollup: ${ids.join(", ")}`);
|
|
211
|
+
advisories.flush({ color: options.output.color, stderr: options.stderr });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Argument parsing ───────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Parse `tasks`'s tokens into the required `<id>` positional and the value flag `--status <S>` (also
|
|
218
|
+
* accepting the `--status=value` form). Commander has already resolved Lore's global flags, so a
|
|
219
|
+
* `--`-prefixed token here is a command flag: an unrecognized one, a repeated/value-less `--status`, a
|
|
220
|
+
* missing `<id>`, or a second positional is a `usage` error (exit 2). A `--` ends option parsing. The
|
|
221
|
+
* `<id>` is {@link idFromPath}-normalized so path/`.md`/`./` forms resolve (mirrors `lore context`).
|
|
222
|
+
*/
|
|
223
|
+
function parseTasksArgs(args: readonly string[]): TasksArgs {
|
|
224
|
+
const parsed = parseCommandArgs(args, "tasks");
|
|
225
|
+
const positionals = parsed.positionals;
|
|
226
|
+
const status = singleOptionValue(parsed, "status");
|
|
227
|
+
if (status === "") {
|
|
228
|
+
throw usage("--status needs a value", 'pass a value, e.g. `--status "In Progress"`');
|
|
229
|
+
}
|
|
230
|
+
if (positionals.length === 0) {
|
|
231
|
+
throw usage(
|
|
232
|
+
"`lore tasks` needs a concept id",
|
|
233
|
+
"give the concept whose task rollup to show, e.g. `lore tasks stories/x`",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (positionals.length > 1) {
|
|
237
|
+
throw usage(`unexpected argument "${positionals[1]}"`, "run `lore tasks <id> [--status <S>]`");
|
|
238
|
+
}
|
|
239
|
+
return { id: idFromPath(positionals[0] as string), status };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Output ─────────────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The rendering bundle for `tasks` (output.ts dispatches on the mode). `--json` carries the structured
|
|
246
|
+
* {@link TaskRollup}; the pretty/plain text is an aligned table. The two text modes differ only in the
|
|
247
|
+
* painted header, so they share one renderer.
|
|
248
|
+
*/
|
|
249
|
+
function tasksRenderable(data: TaskRollup): Renderable<TaskRollup> {
|
|
250
|
+
return {
|
|
251
|
+
kind: "tasks.rollup",
|
|
252
|
+
data,
|
|
253
|
+
pretty: (d, opts) => renderTable(d, opts.color),
|
|
254
|
+
plain: (d) => renderTable(d, false),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A human/pipe-stable rollup: a header naming the concept, its live task count, and the `--status`
|
|
260
|
+
* filter (when any), then one ` <id> <status> <title>` row per live task (columns padded to align).
|
|
261
|
+
* An empty rollup renders a single explanatory line. ANSI only on the header, and only when `color`.
|
|
262
|
+
*/
|
|
263
|
+
function renderTable(data: TaskRollup, color: boolean): string {
|
|
264
|
+
const filter = data.status !== undefined ? `, status "${data.status}"` : "";
|
|
265
|
+
const noun = data.tasks.length === 1 ? "task" : "tasks";
|
|
266
|
+
const header = paint(`tasks: ${data.concept} — ${data.tasks.length} ${noun}${filter}`, ANSI.green, color);
|
|
267
|
+
if (data.tasks.length === 0) {
|
|
268
|
+
return `${header}\n${data.status !== undefined ? "(no linked tasks match that status)" : "(no linked tasks)"}`;
|
|
269
|
+
}
|
|
270
|
+
return [header, ...renderTaskSummaryRows(data.tasks)].join("\n");
|
|
271
|
+
}
|