doctrina-cli 0.17.0 → 0.18.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.
@@ -8,9 +8,10 @@ import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
8
8
  import { flagBool, flagString } from "../lib/args.js";
9
9
  import { today } from "../lib/dates.js";
10
10
  import { c } from "../lib/colors.js";
11
- import { loadSignoffs, saveSignoffs, recordSignoff, signoffState, short, SIGNOFF_REL } from "../lib/signoff.js";
11
+ import { loadSignoffs, saveSignoffs, recordSignoff, signoffState, short, SIGNOFF_REL, declaredPaths, workingChanges } from "../lib/signoff.js";
12
12
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
13
13
  import { collectReproducibility } from "../lib/reproducibility.js";
14
+ import { verifyFingerprint, recordGreen, reusableGreen } from "../lib/verify-cache.js";
14
15
 
15
16
  // The build/verify gate `validate` is not. `validate` checks the shape of
16
17
  // the artifact tree; it never runs the project, so a repo that does not
@@ -40,7 +41,7 @@ const STARTER = {
40
41
  // Flags this command accepts. Declared HERE, with the command, so
41
42
  // adding a command never requires editing the entrypoint — the gap that
42
43
  // let six flags ship undeclared and silently swallow a positional (C3).
43
- export const flags = { boolean: ["json", "clean", "force", "init", "list", "strict"], string: ["signoff"] };
44
+ export const flags = { boolean: ["json", "clean", "force", "init", "list", "strict", "reuse", "affected"], string: ["signoff"] };
44
45
 
45
46
  export async function run(_positional, flags) {
46
47
  const projectRoot = process.cwd();
@@ -182,6 +183,22 @@ export async function run(_positional, flags) {
182
183
  }
183
184
 
184
185
  const strict = flagBool(flags, "strict", false);
186
+ const affected = flagBool(flags, "affected", false);
187
+ // The fingerprint of the code these checks are about to run on, taken
188
+ // BEFORE they run (change 0203): what a green run proves is the tree it
189
+ // started from. Null outside git — then nothing is remembered or reused.
190
+ const fingerprint = verifyFingerprint(projectRoot, read(configPath));
191
+ if (flagBool(flags, "reuse", false) && fingerprint !== null) {
192
+ const green = reusableGreen(projectRoot, fingerprint);
193
+ if (green) {
194
+ console.log(c.bold("doctrina verify") + c.gray(` — ${checks.length} check${checks.length === 1 ? "" : "s"} from ${CONFIG_REL}`));
195
+ console.log("");
196
+ console.log(c.green("ok") + ` reused the green run of ${green.at.replace("T", " ").slice(0, 16)} UTC — ` +
197
+ `the code and ${CONFIG_REL} are byte-identical to what it ran on (${green.checks.join(", ")})`);
198
+ console.log(c.gray(" `doctrina verify` runs the checks again"));
199
+ return 0;
200
+ }
201
+ }
185
202
  console.log(c.bold("doctrina verify") + c.gray(` — ${checks.length} check${checks.length === 1 ? "" : "s"} from ${CONFIG_REL}`));
186
203
  const results = [];
187
204
  for (const ch of checks) {
@@ -220,6 +237,20 @@ export async function run(_positional, flags) {
220
237
  continue;
221
238
  }
222
239
 
240
+ // --affected (change 0204): a check that declares the "paths" it covers
241
+ // runs only when the uncommitted work touches one of them. The inner
242
+ // loop of a change — not the close, which always runs every check. A
243
+ // check that declares no paths covers everything and always runs; a
244
+ // question git cannot answer runs the check.
245
+ if (affected && declaredPaths(ch).length > 0) {
246
+ const touched = workingChanges(projectRoot, declaredPaths(ch));
247
+ if (touched !== null && touched.length === 0) {
248
+ console.log(c.gray(`──── ${name}: `) + c.gray(`skip — the uncommitted work touches none of ${declaredPaths(ch).join(", ")}`));
249
+ results.push({ name, ok: true, kind: "command", skipped: true });
250
+ continue;
251
+ }
252
+ }
253
+
223
254
  const where = ch.cwd ? c.gray(` [${ch.cwd}]`) : "";
224
255
  console.log(c.gray(`──── ${name}: `) + ch.run + where);
225
256
  // Run through the shell so declared commands can use pipes, &&, env,
@@ -287,9 +318,18 @@ export async function run(_positional, flags) {
287
318
  // manual check still awaiting sign-off. Pending manual checks are otherwise
288
319
  // a non-blocking report.
289
320
  const failGate = cmdFailed.length > 0 || (strict && pending.length > 0);
290
- const pendNote = pending.length > 0 ? c.gray(` (${pending.length} manual pending)`) : "";
321
+ const skipped = results.filter((r) => r.skipped);
322
+ const pendNote = (pending.length > 0 ? c.gray(` (${pending.length} manual pending)`) : "") +
323
+ (skipped.length > 0 ? c.gray(` (${skipped.length} skipped by --affected: ${skipped.map((r) => r.name).join(", ")})`) : "");
291
324
  if (!failGate) {
292
- console.log(c.green("ok") + ` ${passed}/${results.length} checks passed${pendNote}`);
325
+ const ran = passed - skipped.length;
326
+ console.log(c.green("ok") + ` ${ran}/${results.length - skipped.length} checks passed${pendNote}`);
327
+ // Remembered only when EVERY check passed — a run green with a manual
328
+ // check still pending is a report, not something a close may stand on,
329
+ // and neither is a run --affected cut short.
330
+ if (fingerprint !== null && pending.length === 0 && skipped.length === 0) {
331
+ recordGreen(projectRoot, fingerprint, results.map((r) => r.name));
332
+ }
293
333
  return 0;
294
334
  }
295
335
  const reasons = [];
@@ -403,7 +443,7 @@ function renderReproducibility(projectRoot) {
403
443
  }
404
444
 
405
445
  export const help = `
406
- Usage: doctrina verify [--list] [--init] [--clean] [--strict] [--signoff "<name>=<note>"] [--force]
446
+ Usage: doctrina verify [--list] [--init] [--clean] [--strict] [--reuse] [--affected] [--signoff "<name>=<note>"] [--force]
407
447
 
408
448
  Run the project-declared build/verify checks from ${CONFIG_REL} in order,
409
449
  streaming their output, and exit non-zero if any check fails. This is the
@@ -414,13 +454,18 @@ root, targets a sub-package in a monorepo; a "manual" check is the
414
454
  qualitative gate — judged by a human/eval, recorded as a sign-off, not run):
415
455
  {
416
456
  "checks": [
417
- { "name": "typecheck", "run": "tsc --noEmit", "cwd": "packages/api" },
457
+ { "name": "typecheck", "run": "tsc --noEmit", "cwd": "packages/api",
458
+ "paths": ["packages/api"] },
418
459
  { "name": "test", "run": "npm test" },
419
460
  { "name": "build", "run": "npm run build" },
420
461
  { "name": "chronicle", "type": "manual", "rubric": "is the chronicle enjoyable to read?" }
421
462
  ]
422
463
  }
423
464
 
465
+ "paths" (git pathspecs, relative to the project root) says what a check
466
+ covers: --affected runs it only when the uncommitted work touches one, and a
467
+ manual check's sign-off expires when one changes.
468
+
424
469
  A manual check passes when it has been signed off and is otherwise reported
425
470
  as "pending" — non-blocking by default, failing only under --strict (so CI
426
471
  can require the sign-off). Sign-offs are stored in ${SIGNOFF_REL}.
@@ -450,6 +495,14 @@ Flags:
450
495
  prepare/prepack; a Prisma dep with no generate on install) so
451
496
  "verify green" can't hide a clean checkout that won't build.
452
497
  --strict Fail the gate when a manual check is still pending sign-off
498
+ --reuse Skip the checks when the last fully green run covered this exact
499
+ code: the working tree (tracked, untracked, edits) minus
500
+ .doctrina/, plus ${CONFIG_REL}, are byte-identical. \`close\` runs
501
+ verify this way; a plain \`doctrina verify\` always runs them.
502
+ --affected Run a check that declares "paths" only when the uncommitted work
503
+ (edits against HEAD, untracked files) touches one of them —
504
+ the inner loop of a change. Checks with no "paths" always run;
505
+ \`close\` always runs every check. Never remembered by --reuse.
453
506
  --signoff "<name>=<note>"
454
507
  Record today's sign-off for a manual check, then exit
455
508
  --force With --init, overwrite an existing config
@@ -9,9 +9,10 @@ import { c } from "../lib/colors.js";
9
9
  import { assessBrief } from "../lib/clarity.js";
10
10
  import { locateTemplatesDir, substitute } from "../lib/templates.js";
11
11
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
12
- import { changeNew, reindexChange } from "../lib/change-ops.js";
12
+ import { changeNew, reindexChange, addAffectedSpec } from "../lib/change-ops.js";
13
13
  import { classify } from "../lib/triage-model.js";
14
14
  import { printPlaybookTemplate } from "../lib/playbook.js";
15
+ import { specShape } from "../lib/spec-ops.js";
15
16
  import { changedFiles } from "../lib/git.js";
16
17
  import { slugFromPrompt, fold, CONFIDENT_MARGIN } from "../lib/lexicon.js";
17
18
  import { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
@@ -29,14 +30,36 @@ export { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
29
30
  // Flags this command accepts. Declared HERE, with the command, so
30
31
  // adding a command never requires editing the entrypoint — the gap that
31
32
  // let six flags ship undeclared and silently swallow a positional (C3).
32
- export const flags = { boolean: ["json", "chore", "design", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "id", "resume", "title"] };
33
+ export const flags = { boolean: ["json", "chore", "design", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "consolidate", "id", "resume", "title"] };
33
34
 
34
35
  export async function run(positional, flags) {
35
- const prompt = positional.join(" ").trim();
36
36
  const projectRoot = process.cwd();
37
37
  if (!exists(path.join(projectRoot, ".doctrina"))) {
38
38
  throw notADoctrinaProject();
39
39
  }
40
+ // --consolidate <cap> (change 0206): a spec only ever grew — every change
41
+ // appended, nothing took a requirement out — and a field review of 0.17
42
+ // asked for "a command to consolidate a spec from time to time". It is an
43
+ // ordinary change pinned to the capability, with its own playbook: the
44
+ // agent decides what is redundant or superseded (ADR 0005), and the ops
45
+ // take it out.
46
+ const consolidate = flags.has("consolidate") ? flagString(flags, "consolidate") : null;
47
+ if (consolidate !== null) {
48
+ if (!consolidate || /** @type {unknown} */ (consolidate) === true || !/^[a-z][a-z0-9-]*$/.test(consolidate)) {
49
+ console.error(c.red("error:") + " --consolidate needs the capability whose spec to consolidate");
50
+ return EXIT.USAGE;
51
+ }
52
+ if (!isFile(path.join(projectRoot, ".doctrina", "specs", consolidate, "spec.md"))) {
53
+ console.error(c.red("error:") + ` capability "${consolidate}" has no spec at .doctrina/specs/${consolidate}/spec.md`);
54
+ return EXIT.USAGE;
55
+ }
56
+ flags.set("capability", consolidate);
57
+ flags.set("force", true);
58
+ if (!flags.has("title")) flags.set("title", `consolidate ${consolidate}`);
59
+ }
60
+ const prompt = consolidate
61
+ ? `Consolidate the ${consolidate} spec: merge requirements and criteria that say the same thing, drop what a later one superseded — no behaviour changes.`
62
+ : positional.join(" ").trim();
40
63
 
41
64
  // --resume <id>: reprint the playbook for an existing open change rather than
42
65
  // open a new one (review G1 — `work "continue"` used to slug the word and
@@ -205,6 +228,12 @@ export async function run(positional, flags) {
205
228
  ? null
206
229
  : scaffoldDelta(projectRoot, { id, capability, guess, runnerUp: promptMatches[1] ?? null });
207
230
 
231
+ // A guessed delta names the capability as surely as a pinned one: the
232
+ // header says what the folder says (change 0207). It stayed empty for
233
+ // every unpinned `work`, so the proposal claimed no spec while its delta
234
+ // targeted one.
235
+ if (scaffolded) addAffectedSpec(projectRoot, id, scaffolded.capability);
236
+
208
237
  // The proposal is written in two passes — `changeNew` scaffolds it, the
209
238
  // lines above stamp the lane and the pinned specs — so its index entry is
210
239
  // re-derived HERE, from the finished file. Indexing at the end of the first
@@ -223,7 +252,9 @@ export async function run(positional, flags) {
223
252
  }
224
253
 
225
254
  console.log("");
226
- if (chore) {
255
+ if (consolidate) {
256
+ printConsolidatePlaybook(projectRoot, { id, capability: consolidate });
257
+ } else if (chore) {
227
258
  printChorePlaybook(projectRoot, { id, prompt: effPrompt });
228
259
  } else {
229
260
  printPlaybook(projectRoot, {
@@ -440,6 +471,21 @@ export function slugify(text) {
440
471
  // part that is not prose: computing the variable blocks. Each is PRE-RENDERED
441
472
  // into a token, so `substitute` stays a plain string map and the template
442
473
  // stays a document rather than a language with conditionals in it.
474
+ // The consolidation playbook: the spec's shape, measured, then the steps.
475
+ // Measuring is the CLI's part; deciding which requirement is redundant is
476
+ // the agent's (ADR 0005).
477
+ function printConsolidatePlaybook(projectRoot, { id, capability }) {
478
+ const text = read(path.join(projectRoot, ".doctrina", "specs", capability, "spec.md"));
479
+ const shape = specShape(text);
480
+ const sections = Object.entries(shape.sections).map(([k, n]) => `${k} ${n}`).join(" · ");
481
+ printPlaybookTemplate(projectRoot, "consolidate", {
482
+ CHANGE_ID: id,
483
+ CAPABILITY: capability,
484
+ SHAPE: `${shape.lines} lines · ${shape.requirements} requirements (${sections || "none"}) · ` +
485
+ `${shape.criteria} acceptance criteria`,
486
+ });
487
+ }
488
+
443
489
  function printChorePlaybook(projectRoot, { id, prompt }) {
444
490
  printPlaybookTemplate(projectRoot, "chore", { CHANGE_ID: id, PROMPT: prompt });
445
491
  }
@@ -543,6 +589,10 @@ Options:
543
589
  --resume <id> Reprint the playbook for an open change; create nothing
544
590
  --from-diff Backfill: scaffold from the working-tree changes (no
545
591
  prompt needed) and print a code-first playbook (F8)
592
+ --consolidate <cap> Open a change that consolidates <cap>'s spec — merge
593
+ what says the same thing, remove what was superseded
594
+ (remove-requirement / remove-criterion) — with its own
595
+ playbook; no prompt needed, no behaviour changes
546
596
  --chore, --no-spec Open a spec-less chore change (infra/docs/build) with a
547
597
  playbook that skips the spec-delta steps (F9)
548
598
  --design Also scaffold design.md, for a change with non-trivial
@@ -199,9 +199,13 @@ export function computeActions(projectRoot, gates = {}) {
199
199
  }
200
200
  const status = listHeader(read(proposalPath), "Status") ?? "proposed";
201
201
  if (status === "applied") {
202
+ // The close, not the archive (change 0201). 0.17 retired the
203
+ // hand-driven tail — `close` skips the apply it already did and
204
+ // still runs verify, coverage and the docs gate — but this line kept
205
+ // teaching `change archive`, the one path past every one of them.
202
206
  actions.push(action({
203
207
  id: "change-archive-pending",
204
- command: "change archive",
208
+ command: "close",
205
209
  args: [id],
206
210
  gate: "archive",
207
211
  runnable: true,
@@ -239,11 +243,11 @@ export function computeActions(projectRoot, gates = {}) {
239
243
  } else {
240
244
  actions.push(action({
241
245
  id: "change-deltas-missing",
242
- command: "change apply",
246
+ command: "close",
243
247
  args: [id],
244
- why: `add spec deltas under .doctrina/changes/${id}/specs/, or apply as metadata-only`,
248
+ why: `add spec deltas under .doctrina/changes/${id}/specs/, or close it as metadata-only`,
245
249
  text: `add spec deltas under .doctrina/changes/${id}/specs/, ` +
246
- `or apply as metadata-only: doctrina change apply ${id}`,
250
+ `or close it as metadata-only: doctrina close ${id}`,
247
251
  }));
248
252
  }
249
253
  }
@@ -15,6 +15,10 @@ import { flagBool, flagString } from "./args.js";
15
15
  import { c } from "./colors.js";
16
16
  import { ensureDoctrinaProject, isChangeId, resolveWithinProject } from "./project.js";
17
17
  import { changeEntry } from "./scan.js";
18
+ import { parseOp, insertOps, extractOps, applyOps } from "./spec-ops.js";
19
+ import { parseOperation } from "./doc-model.js";
20
+ import { substitute } from "./templates.js";
21
+ import { EXIT } from "./exit-codes.js";
18
22
  export function changeNew(args, flags) {
19
23
  const id = args[0];
20
24
  const title = args.slice(1).join(" ").trim();
@@ -131,3 +135,91 @@ export function reindexChange(projectRoot, id, date = today()) {
131
135
  idx.touch(index, date);
132
136
  idx.save(projectRoot, index);
133
137
  }
138
+
139
+ /**
140
+ * Add op lines to a change's MODIFIED delta for one capability (change 0205).
141
+ *
142
+ * A field review of 0.17 asked for "a command to add a requirement and a
143
+ * criterion without writing the ops blocks by hand". The grammar is
144
+ * `apply`'s (ADR 0021): every line is parsed by the same parser, and the
145
+ * whole block — what was there plus what is added — is dry-run against the
146
+ * current spec before anything is written, so an op that would not apply is
147
+ * refused now rather than at the close. Creates the delta from the template
148
+ * when the change has none for the capability yet; a second `bump-version`
149
+ * replaces the first instead of bumping twice.
150
+ *
151
+ * @param {string} projectRoot
152
+ * @param {string} id change id (already refused/resolved by the caller)
153
+ * @param {string} capability
154
+ * @param {string[]} lines op lines, one operation each
155
+ * @returns {{ code: number, error?: string, hint?: string, details?: string[],
156
+ * deltaRel?: string, created?: boolean, block?: string[] }}
157
+ */
158
+ export function addDeltaOps(projectRoot, id, capability, lines) {
159
+ if (lines.length === 0) {
160
+ return { code: EXIT.USAGE, error: "nothing to add",
161
+ hint: "pass --requirement \"<section>: <text>\", --criterion \"<text>\", --bump <level> or an op line" };
162
+ }
163
+ const bad = lines.map((l) => ({ l, op: parseOp(l) })).filter((x) => x.op.error);
164
+ if (bad.length > 0) {
165
+ return { code: EXIT.USAGE, error: "not an operation `apply` can read", details: bad.map((x) => x.op.error) };
166
+ }
167
+ const specPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
168
+ if (!isFile(specPath)) {
169
+ return { code: EXIT.USAGE, error: `capability "${capability}" has no spec at ${relPath(projectRoot, specPath)}`,
170
+ hint: `a NEW capability is an ADDED delta with the full body — \`doctrina spec new ${capability}\`, then write it` };
171
+ }
172
+ const deltaPath = path.join(projectRoot, ".doctrina", "changes", id, "specs", capability, "delta.md");
173
+ const created = !isFile(deltaPath);
174
+ let text;
175
+ if (created) {
176
+ const tpl = read(path.join(locateTemplatesDir(), "change", "spec-delta.md.template"));
177
+ text = substitute(tpl, { CAPABILITY: capability }).replace(/^\*\*Operation:\*\*.*$/m, "**Operation:** MODIFIED");
178
+ } else {
179
+ text = read(deltaPath);
180
+ const op = parseOperation(text);
181
+ if (op && op !== "MODIFIED") {
182
+ return { code: EXIT.PRECONDITION, error: `${relPath(projectRoot, deltaPath)} is an ${op} delta — ops apply only to a MODIFIED one`,
183
+ hint: op === "ADDED" ? "write the requirement into the ADDED body itself" : "a REMOVED delta deletes the spec; there is nothing to add to" };
184
+ }
185
+ }
186
+ const next = insertOps(text, lines);
187
+ const dry = applyOps(read(specPath), extractOps(next));
188
+ if (dry.errors.length > 0) {
189
+ return { code: EXIT.GATE, error: `the ops block would not apply to ${relPath(projectRoot, specPath)} — nothing written`, details: dry.errors };
190
+ }
191
+ mkdirp(path.dirname(deltaPath));
192
+ write(deltaPath, next, { force: true });
193
+ addAffectedSpec(projectRoot, id, capability);
194
+ return { code: EXIT.OK, deltaRel: relPath(projectRoot, deltaPath), created, block: extractOps(next).map((o) => o.verb) };
195
+ }
196
+
197
+ /**
198
+ * Name `capability` in the proposal's `Affects specs:` header when the
199
+ * header does not already (change 0207). A change whose delta targets a
200
+ * capability affects that spec, and a header left empty while the delta
201
+ * folder said otherwise was the one fact `prime`, `status` and the review
202
+ * of the proposal disagreed on. A chore's "(none — chore)" is left alone:
203
+ * that is a declaration, and the structure gate is where a chore carrying a
204
+ * delta gets questioned.
205
+ *
206
+ * @param {string} projectRoot
207
+ * @param {string} id
208
+ * @param {string} capability
209
+ * @returns {boolean} whether the header changed
210
+ */
211
+ export function addAffectedSpec(projectRoot, id, capability) {
212
+ const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
213
+ if (!isFile(proposalPath)) return false;
214
+ const text = read(proposalPath);
215
+ const m = text.match(/^(-[ \t]+\*\*Affects specs:\*\*)([^\r\n]*)/m);
216
+ if (!m) return false;
217
+ const current = m[2].trim();
218
+ if (/^\(none/i.test(current)) return false;
219
+ const named = current.split(",").map((s) => s.trim().replace(/`/g, "")).filter(Boolean);
220
+ if (named.includes(capability)) return false;
221
+ const value = [...named, capability].join(", ");
222
+ write(proposalPath, text.replace(m[0], `${m[1]} ${value}`), { force: true });
223
+ reindexChange(projectRoot, id);
224
+ return true;
225
+ }
@@ -47,6 +47,7 @@ export const OPERATIONS = [
47
47
  ["change check", "Pre-close dry-run: everything close would refuse, listed first"],
48
48
  ["change tick", "List/tick the unchecked boxes (tasks + verification; --all)"],
49
49
  ["change abandon", "Discard an open change cleanly (recorded in the ledger)"],
50
+ ["change delta", "Add a requirement/criterion/bump to a change's delta (ops written for you)"],
50
51
  ["contract new", "Own the integration surface (ports, env, interfaces)"],
51
52
  ["contract list", "List contracts with status and last-updated date"],
52
53
  ["contract check", "Verify port collisions, env drift, referenced specs"],
@@ -516,6 +517,13 @@ export const AGENT_CHANGELOG = {
516
517
  // Each entry lists only ITS OWN delta. The series carries the rest
517
518
  // forward (see agentChangelogEntries), so a patch never has to restate
518
519
  // the minor that introduced the commands, and never silently erases it.
520
+ "0.18.0": [
521
+ "`doctrina change delta <id> <capability> --requirement \"<section>: <text>\" --criterion \"<text>\" --bump minor` writes the ops lines for you and dry-runs them against the spec — stop hand-writing ops blocks.",
522
+ "`close` runs `verify --reuse`: running `doctrina verify` yourself right before `doctrina close` is no longer paid twice, and a batch close runs the suite once. Use `verify --affected` while working when checks declare `\"paths\"`.",
523
+ "The review inside `close` counts the change's own deltas as spec updates and reports code under other capabilities in ONE line — read it: either this change forgot a delta or other work is lying in the tree.",
524
+ "`close` ends by listing other open changes that look finished or are a week old; close them with the `doctrina close <id>` it prints. `next` no longer suggests `change archive`.",
525
+ "A spec can shrink: `remove-requirement <section> <n>` / `remove-criterion <n>`, and `doctrina work --consolidate <cap>` when a spec has grown past what it needs to say.",
526
+ ],
519
527
  "0.17.0": [
520
528
  "Preview a close with `doctrina change check <id>` (it exits 1 while a Verification box is open), then run `doctrina close <id>` — do not run `analyze`, `change apply` or `change archive` by hand; a chore closes the same way.",
521
529
  "Deprecated names still run with a notice — use the replacement: `analyze` → `change check`, `report` → `status --view report`, `skill sync` → `index rebuild`, `templates check|update` → `upgrade [--write]`. `constitution` and `change diff` are removed (exit 2).",
package/src/lib/git.js CHANGED
@@ -1,5 +1,10 @@
1
1
  // @ts-check
2
2
  import { spawnSync } from "node:child_process";
3
+ import { copyFileSync, existsSync, rmSync } from "node:fs";
4
+ import { createHash } from "node:crypto";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import process from "node:process";
3
8
 
4
9
  // One place that knows how to ask git a question and how to interpret not
5
10
  // getting an answer (audit item C8).
@@ -32,8 +37,13 @@ export const GIT_STATE = Object.freeze({
32
37
 
33
38
  // Run a git command. Never throws. Returns
34
39
  // { state, stdout, lines, stderr, code }.
35
- export function git(cwd, args) {
36
- const r = spawnSync("git", args, { cwd, encoding: "utf8" });
40
+ /**
41
+ * @param {string} cwd
42
+ * @param {string[]} args
43
+ * @param {{ env?: Record<string, string> }} [opts]
44
+ */
45
+ export function git(cwd, args, { env } = {}) {
46
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", ...(env ? { env: { ...process.env, ...env } } : {}) });
37
47
  if (r.error) {
38
48
  // ENOENT here means the binary is missing, not that the command failed.
39
49
  return { state: GIT_STATE.ABSENT, stdout: "", lines: [], stderr: String(r.error.message ?? ""), code: null };
@@ -227,3 +237,50 @@ export function lastTagDate(cwd) {
227
237
  const date = tags[0] ?? "";
228
238
  return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null;
229
239
  }
240
+
241
+ /**
242
+ * A content fingerprint of the working tree under `cwd` — tracked AND
243
+ * untracked files, .gitignore respected, uncommitted edits included — with
244
+ * the top-level entries named in `exclude` left out (change 0203). Null when
245
+ * git cannot answer.
246
+ *
247
+ * Built from git's own object model rather than by reading files: the real
248
+ * index is copied to a scratch one, `git add -A` stages the whole tree into
249
+ * the copy (reusing the stat cache, so only changed files are hashed), and
250
+ * `git write-tree` names the result. The real index, HEAD and the working
251
+ * tree are never touched; the only side effect is loose objects for changed
252
+ * files, which `git gc` reclaims.
253
+ *
254
+ * @param {string} cwd
255
+ * @param {{ exclude?: string[] }} [opts]
256
+ * @returns {string | null}
257
+ */
258
+ export function worktreeFingerprint(cwd, { exclude = [] } = {}) {
259
+ const top = git(cwd, ["rev-parse", "--show-toplevel"]);
260
+ const dir = git(cwd, ["rev-parse", "--absolute-git-dir"]);
261
+ if (top.state !== GIT_STATE.OK || dir.state !== GIT_STATE.OK) return null;
262
+ const root = top.stdout.trim();
263
+ const scratch = path.join(os.tmpdir(), `doctrina-fingerprint-${process.pid}-${Date.now()}`);
264
+ try {
265
+ const real = path.join(dir.stdout.trim(), "index");
266
+ if (existsSync(real)) copyFileSync(real, scratch);
267
+ const env = { GIT_INDEX_FILE: scratch };
268
+ if (git(root, ["add", "-A", "--", "."], { env }).state !== GIT_STATE.OK) return null;
269
+ const tree = git(root, ["write-tree"], { env });
270
+ if (tree.state !== GIT_STATE.OK) return null;
271
+ // Asked of git, never computed with path.relative: on macOS the tmpdir
272
+ // is `/var/…`, a symlink to `/private/var/…`, and git answers with the
273
+ // resolved toplevel — the relative path between the two spellings is
274
+ // `../..`, which names nothing in the tree (the 0.18 macOS CI run).
275
+ const prefixed = git(cwd, ["rev-parse", "--show-prefix"]);
276
+ if (prefixed.state !== GIT_STATE.OK) return null;
277
+ const prefix = prefixed.stdout.trim().replace(/\/$/, "");
278
+ const listing = git(root, ["ls-tree", prefix ? `${tree.stdout.trim()}:${prefix}` : tree.stdout.trim()]);
279
+ if (listing.state !== GIT_STATE.OK) return null;
280
+ const skip = new Set(exclude);
281
+ const kept = listing.lines.filter((l) => !skip.has(l.split("\t")[1]));
282
+ return createHash("sha256").update(kept.join("\n")).digest("hex");
283
+ } finally {
284
+ rmSync(scratch, { force: true });
285
+ }
286
+ }
@@ -94,4 +94,4 @@ export function printPlaybookTemplate(projectRoot, name, tokens = {}) {
94
94
  }
95
95
 
96
96
  /** The playbooks the CLI ships and `templates check` expects to resolve. */
97
- export const PLAYBOOKS = ["work", "chore", "bootstrap"];
97
+ export const PLAYBOOKS = ["work", "chore", "bootstrap", "consolidate"];
@@ -631,6 +631,12 @@ export function expandBraces(glob) {
631
631
  // `{a,b}` alternates. Enough for the "where do my selectors live" and "which
632
632
  // code is mine" declarations, with no dependency and no surprises.
633
633
  export function globToRegExp(glob) {
634
+ // A pattern ending in `/` names a DIRECTORY, and a directory claims what
635
+ // is under it (change 0199). Compiled as written it matched only the
636
+ // literal path `src/health/` — which no file has — so the most natural way
637
+ // to declare a module's ownership claimed nothing, and `review` reported
638
+ // every file in it as ownerless.
639
+ if (/\/$/.test(String(glob))) glob = `${glob}**`;
634
640
  // Always compile the EXPANSION, never the original. Using it only when it
635
641
  // produced more than one alternative left `src/{a}.js` compiling with its
636
642
  // braces as literal characters, so a one-element group matched nothing
package/src/lib/scan.js CHANGED
@@ -599,3 +599,16 @@ function artifactPathOf(file) {
599
599
  if (m) return m[1];
600
600
  return null;
601
601
  }
602
+
603
+ // The capabilities a change's deltas target, sorted — the change's own scope.
604
+ // `close` gates coverage on it and `review --change` reads it as "these specs
605
+ // are being updated by this change", because the delta merges at apply.
606
+ export function deltaCapabilities(changeDir) {
607
+ const caps = new Set();
608
+ for (const deltaPath of walk(path.join(changeDir, "specs"))) {
609
+ if (!deltaPath.endsWith("delta.md")) continue;
610
+ const cap = parseCapabilityFromDelta(read(deltaPath), deltaPath);
611
+ if (cap) caps.add(cap);
612
+ }
613
+ return [...caps].sort();
614
+ }
@@ -96,6 +96,30 @@ export function changedSince(projectRoot, sha, paths) {
96
96
  return [...out].sort();
97
97
  }
98
98
 
99
+ /**
100
+ * Which of `paths` the uncommitted work touches — edits against HEAD plus
101
+ * untracked files, `.gitignore` respected (change 0204). The same pathspec
102
+ * reading a sign-off's "paths" gets, so "what this check covers" means one
103
+ * thing in verify.json. Null when git cannot answer: a caller then runs the
104
+ * check rather than reading "nothing changed" out of a question it never
105
+ * got to ask.
106
+ *
107
+ * @param {string} projectRoot
108
+ * @param {string[]} paths
109
+ * @returns {string[] | null}
110
+ */
111
+ export function workingChanges(projectRoot, paths) {
112
+ const tracked = git(projectRoot, ["diff", "--name-only", "HEAD", "--", ...paths]);
113
+ // No commit yet: every file is new, so what is staged counts as changed.
114
+ const staged = tracked.state === GIT_STATE.EMPTY
115
+ ? git(projectRoot, ["ls-files", "--cached", "--", ...paths])
116
+ : tracked;
117
+ if (staged.state !== GIT_STATE.OK) return null;
118
+ const untracked = git(projectRoot, ["ls-files", "--others", "--exclude-standard", "--", ...paths]);
119
+ if (untracked.state !== GIT_STATE.OK) return null;
120
+ return [...new Set([...staged.lines, ...untracked.lines].filter(Boolean))].sort();
121
+ }
122
+
99
123
  /** The commit a signature is anchored to, or null outside a repository. */
100
124
  export function currentSha(projectRoot) {
101
125
  const r = git(projectRoot, ["rev-parse", "HEAD"]);