doctrina-cli 0.20.0 → 0.22.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/README.md +7 -7
- package/package.json +1 -1
- package/src/commands/adapter.js +10 -0
- package/src/commands/change.js +202 -27
- package/src/commands/clarify.js +3 -206
- package/src/commands/close.js +1 -1
- package/src/commands/context.js +33 -2
- package/src/commands/contract.js +1 -0
- package/src/commands/coverage.js +69 -6
- package/src/commands/delivery.js +135 -17
- package/src/commands/doctor.js +83 -5
- package/src/commands/handoff.js +11 -2
- package/src/commands/hooks.js +56 -11
- package/src/commands/index-rebuild.js +47 -1
- package/src/commands/init.js +64 -8
- package/src/commands/intake.js +16 -5
- package/src/commands/metrics.js +16 -1
- package/src/commands/next.js +41 -6
- package/src/commands/onboard.js +113 -0
- package/src/commands/prime.js +53 -4
- package/src/commands/review.js +40 -4
- package/src/commands/search.js +2 -2
- package/src/commands/skill.js +2 -1
- package/src/commands/status.js +28 -0
- package/src/commands/templates.js +2 -1
- package/src/commands/triage.js +16 -1
- package/src/commands/ui.js +512 -0
- package/src/commands/upgrade.js +62 -4
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +35 -7
- package/src/commands/why.js +1 -1
- package/src/commands/work.js +90 -42
- package/src/commands/workspace.js +89 -66
- package/src/index.js +107 -62
- package/src/lib/actions.js +62 -3
- package/src/lib/adapters.js +83 -1
- package/src/lib/agent-bridge.js +400 -0
- package/src/lib/applied-deltas.js +82 -0
- package/src/lib/args.js +34 -2
- package/src/lib/change-numbers.js +115 -0
- package/src/lib/change-ops.js +2 -2
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +594 -104
- package/src/lib/commands.js +58 -4
- package/src/lib/config.js +27 -8
- package/src/lib/conflict-markers.js +61 -0
- package/src/lib/console-model.js +668 -0
- package/src/lib/console-prefs.js +67 -0
- package/src/lib/console-room.js +201 -0
- package/src/lib/console-state.js +150 -0
- package/src/lib/console-view.js +557 -0
- package/src/lib/context-slice.js +34 -3
- package/src/lib/coverage-model.js +29 -6
- package/src/lib/delivery.js +90 -12
- package/src/lib/docs-impact.js +37 -2
- package/src/lib/fs-ops.js +103 -12
- package/src/lib/gates.js +7 -1
- package/src/lib/git.js +18 -0
- package/src/lib/index-json.js +10 -1
- package/src/lib/index-merge.js +90 -0
- package/src/lib/install.js +80 -0
- package/src/lib/intake-model.js +5 -2
- package/src/lib/json-out.js +9 -1
- package/src/lib/ledger.js +2 -3
- package/src/lib/lock.js +58 -0
- package/src/lib/merge-setup.js +55 -0
- package/src/lib/metrics-model.js +2 -2
- package/src/lib/onboard.js +197 -0
- package/src/lib/preset.js +177 -0
- package/src/lib/prompt.js +1 -1
- package/src/lib/properties.js +149 -0
- package/src/lib/runtime.js +19 -7
- package/src/lib/scan.js +13 -8
- package/src/lib/shell.js +45 -0
- package/src/lib/snapshot.js +5 -1
- package/src/lib/spec-ops.js +2 -2
- package/src/lib/task-card.js +101 -8
- package/src/lib/templates-model.js +3 -2
- package/src/lib/triage-model.js +75 -0
- package/src/lib/validation-model.js +23 -5
- package/src/lib/views.js +5 -4
- package/src/lib/work-model.js +8 -2
- package/src/lib/workspace-agents.js +67 -20
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +104 -17
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +83 -131
- package/templates/AGENTS.md.template +9 -5
- package/templates/README.md +4 -2
- package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
- package/templates/adapters/kiro/README.md +22 -0
- package/templates/playbooks/absorb.md.template +39 -16
- package/templates/playbooks/bootstrap.md.template +18 -6
- package/templates/playbooks/delivery.md.template +12 -6
package/src/commands/upgrade.js
CHANGED
|
@@ -5,11 +5,15 @@ import { exists } from "../lib/fs-ops.js";
|
|
|
5
5
|
import * as idx from "../lib/index-json.js";
|
|
6
6
|
import { deriveIndex, indexesMatch } from "../lib/scan.js";
|
|
7
7
|
import { cliVersion } from "../lib/version.js";
|
|
8
|
-
import { flagBool } from "../lib/args.js";
|
|
8
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
9
9
|
import { c } from "../lib/colors.js";
|
|
10
10
|
import { today } from "../lib/dates.js";
|
|
11
|
-
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
11
|
+
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
|
+
import { applyPreset, describePreset, planPreset, presetPending, readPreset } from "../lib/preset.js";
|
|
12
13
|
import { planSkillExport, applySkillExport } from "../lib/skill-export.js";
|
|
14
|
+
import { applyAdapterMigration, planAdapterMigration } from "../lib/adapters.js";
|
|
15
|
+
import { projectName } from "../lib/project.js";
|
|
16
|
+
import { relinkShadow, shadowRootOf } from "../lib/workspace.js";
|
|
13
17
|
import { collectFindings } from "../lib/templates-model.js";
|
|
14
18
|
import * as templates from "./templates.js";
|
|
15
19
|
import * as validate from "./validate.js";
|
|
@@ -27,7 +31,7 @@ import * as validate from "./validate.js";
|
|
|
27
31
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
28
32
|
// adding a command never requires editing the entrypoint — the gap that
|
|
29
33
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
30
|
-
export const flags = { boolean: ["json", "write"], string: [] };
|
|
34
|
+
export const flags = { boolean: ["json", "write"], string: ["preset"] };
|
|
31
35
|
|
|
32
36
|
export async function run(_positional, flags) {
|
|
33
37
|
const projectRoot = process.cwd();
|
|
@@ -55,6 +59,27 @@ export async function run(_positional, flags) {
|
|
|
55
59
|
|
|
56
60
|
let pending = 0;
|
|
57
61
|
|
|
62
|
+
// 0. The organisation's preset, when one is named (change 0253): laid
|
|
63
|
+
// over the project before the scaffold is measured, so the steps below
|
|
64
|
+
// check the project as it will be.
|
|
65
|
+
const presetDir = flagString(flags, "preset");
|
|
66
|
+
if (flags.has("preset")) {
|
|
67
|
+
let preset;
|
|
68
|
+
try {
|
|
69
|
+
if (!presetDir) throw new Error("--preset needs the preset's folder");
|
|
70
|
+
preset = readPreset(presetDir);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error(c.red("error:") + ` ${/** @type {Error} */ (err).message}`);
|
|
73
|
+
return EXIT.USAGE;
|
|
74
|
+
}
|
|
75
|
+
const plan = planPreset(projectRoot, preset);
|
|
76
|
+
if (writeMode) applyPreset(projectRoot, preset, plan);
|
|
77
|
+
else if (presetPending(plan) > 0) pending += 1;
|
|
78
|
+
console.log(c.gray("──── preset"));
|
|
79
|
+
for (const line of describePreset(preset, plan, writeMode)) console.log(line);
|
|
80
|
+
console.log("");
|
|
81
|
+
}
|
|
82
|
+
|
|
58
83
|
// 2. Additive template updates (missing recommended sections in AGENTS.md /
|
|
59
84
|
// product.md, missing index.json fields). Reuses `templates update`
|
|
60
85
|
// verbatim: preview without --write, apply with it.
|
|
@@ -78,6 +103,8 @@ export async function run(_positional, flags) {
|
|
|
78
103
|
// The skill exports (change 0212) follow the index: regenerated by the
|
|
79
104
|
// write, reported by the preview.
|
|
80
105
|
const exportPlan = planSkillExport(projectRoot);
|
|
106
|
+
// Adapter files that moved (change 0238): Cursor's slash commands became skills.
|
|
107
|
+
const movePlan = planAdapterMigration(projectRoot, { PROJECT_NAME: projectName(projectRoot), PROJECT_DESCRIPTION: "", DATE: today() }, shadowRootOf(projectRoot) ?? projectRoot);
|
|
81
108
|
const exportChanges = exportPlan.writes.length + exportPlan.removes.length;
|
|
82
109
|
|
|
83
110
|
// 3. Index: rebuild from the tree, which also migrates the framework stamp.
|
|
@@ -96,6 +123,29 @@ export async function run(_positional, flags) {
|
|
|
96
123
|
pending += 1;
|
|
97
124
|
}
|
|
98
125
|
}
|
|
126
|
+
const moves = movePlan.writes.length + movePlan.removes.length;
|
|
127
|
+
// What the installed adapters gained since this project added them.
|
|
128
|
+
if (movePlan.added.length > 0) {
|
|
129
|
+
const names = movePlan.added.map((a) => a.relativePath).join(", ");
|
|
130
|
+
if (writeMode) console.log(c.green("added ") + ` ${movePlan.added.length} adapter file${movePlan.added.length === 1 ? "" : "s"} this CLI ships now: ${names}`);
|
|
131
|
+
else {
|
|
132
|
+
console.log(c.yellow("would ") + `add ${movePlan.added.length} adapter file${movePlan.added.length === 1 ? "" : "s"} this CLI ships now: ${names}`);
|
|
133
|
+
pending += 1;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (moves > 0 || (writeMode && movePlan.added.length > 0)) {
|
|
137
|
+
if (writeMode && moves === 0) applyAdapterMigration(shadowRootOf(projectRoot) ?? projectRoot, movePlan);
|
|
138
|
+
else if (writeMode) {
|
|
139
|
+
const gone = applyAdapterMigration(shadowRootOf(projectRoot) ?? projectRoot, movePlan);
|
|
140
|
+
relinkShadow(projectRoot, gone);
|
|
141
|
+
console.log(c.green("moved ") + ` ${movePlan.writes.length} adapter file${movePlan.writes.length === 1 ? "" : "s"} to the skills the agent reads now` +
|
|
142
|
+
(movePlan.removes.length ? `, ${movePlan.removes.length} retired cop${movePlan.removes.length === 1 ? "y" : "ies"} removed` : ""));
|
|
143
|
+
} else {
|
|
144
|
+
console.log(c.yellow("would ") + `move ${movePlan.removes.length || movePlan.writes.length} Cursor command${(movePlan.removes.length || movePlan.writes.length) === 1 ? "" : "s"} to .cursor/skills/ (Cursor reads skills now)`);
|
|
145
|
+
pending += 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const rel of movePlan.kept) console.log(c.yellow("kept ") + ` ${rel} — edited since install; its skill replaces it, so move your edits and remove it`);
|
|
99
149
|
if (!drifted && !staleStamp) {
|
|
100
150
|
console.log(c.green("ok") + " index.json matches the tree and the stamp is current");
|
|
101
151
|
} else if (writeMode) {
|
|
@@ -155,7 +205,7 @@ export async function run(_positional, flags) {
|
|
|
155
205
|
}
|
|
156
206
|
|
|
157
207
|
export const help = `
|
|
158
|
-
Usage: doctrina upgrade [--write]
|
|
208
|
+
Usage: doctrina upgrade [--write] [--preset <dir>]
|
|
159
209
|
|
|
160
210
|
Bring an existing project up to the installed CLI after an npm update.
|
|
161
211
|
The project keeps the scaffold of the version that init-ed it; this is
|
|
@@ -174,6 +224,14 @@ the one command that closes the gap, orchestrating the existing pieces:
|
|
|
174
224
|
3. validate (--fix) — surface anything the upgrade cannot fix
|
|
175
225
|
(hand-authored drift, new validate checks)
|
|
176
226
|
|
|
227
|
+
--preset <dir> first lays an organisation's preset over the project: a
|
|
228
|
+
folder with preset.json ({ "name", "description", "config" }) and optional
|
|
229
|
+
templates/, decisions/, skills/ and verify.json. Additive — a file the
|
|
230
|
+
project has is kept, a config key it declares is kept, an ADR number it
|
|
231
|
+
uses is not overwritten — and the plan names each one. A preset's
|
|
232
|
+
verify.json is copied only when the project has none, and the commands it
|
|
233
|
+
would run are printed.
|
|
234
|
+
|
|
177
235
|
Preview by default (exits 1 when steps are pending or a manual repair is
|
|
178
236
|
left); --write applies. The preview is what \`templates check\` reported
|
|
179
237
|
and --write what \`templates update --write\` did — both deprecated.
|
package/src/commands/validate.js
CHANGED
|
@@ -7,6 +7,7 @@ import { c } from "../lib/colors.js";
|
|
|
7
7
|
import { emitJson } from "../lib/json-out.js";
|
|
8
8
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
9
9
|
import { collectValidation } from "../lib/validation-model.js";
|
|
10
|
+
import { contextRouting, workspaceActions, workspaceFindings, workspaceHandoff, workspaceRootAt, workspaceSnapshot, workspaceStatus } from "../lib/workspace-view.js";
|
|
10
11
|
|
|
11
12
|
// The structural gate. The checks themselves live in lib/validation-model.js
|
|
12
13
|
// so a driver can ASK for them instead of spawning this binary and parsing
|
|
@@ -27,6 +28,19 @@ export async function run(_positional, flags) {
|
|
|
27
28
|
// Outside a Doctrina project this is a PRECONDITION, not a pile of
|
|
28
29
|
// gate failures about missing files: the remedy is `doctrina init`,
|
|
29
30
|
// not editing artifacts that do not exist yet (C7).
|
|
31
|
+
// At a workspace root: the workspace's own integrity — registry, shadow
|
|
32
|
+
// links, deliveries (change 0230). Each project validates inside itself.
|
|
33
|
+
const wsRoot = workspaceRootAt(projectRoot);
|
|
34
|
+
if (wsRoot) {
|
|
35
|
+
const { errors, warnings } = workspaceFindings(workspaceSnapshot(wsRoot));
|
|
36
|
+
for (const e of errors) console.log(c.red("error: ") + e);
|
|
37
|
+
for (const w of warnings) console.log(c.yellow("warn: ") + w);
|
|
38
|
+
console.log("");
|
|
39
|
+
console.log(errors.length === 0
|
|
40
|
+
? c.green("ok") + ` the workspace is consistent${warnings.length ? ` (${warnings.length} warning${warnings.length === 1 ? "" : "s"})` : ""} — each project validates inside itself: \`cd <path> && doctrina validate\``
|
|
41
|
+
: c.red("fail") + ` ${errors.length} workspace error${errors.length === 1 ? "" : "s"}`);
|
|
42
|
+
return errors.length > 0 || (flagBool(flags, "strict", false) && warnings.length > 0) ? 1 : 0;
|
|
43
|
+
}
|
|
30
44
|
if (!isDir(path.join(projectRoot, ".doctrina"))) throw notADoctrinaProject();
|
|
31
45
|
|
|
32
46
|
const { errors, warnings, fixes } = collectValidation(projectRoot, {
|
|
@@ -82,6 +96,10 @@ Run schema, artifact-existence, and structural checks against the
|
|
|
82
96
|
.doctrina/ tree in the current working directory. Exits 0 if no errors
|
|
83
97
|
(warnings allowed), 1 otherwise. With --strict, a warning fails it too.
|
|
84
98
|
|
|
99
|
+
At a workspace root it checks the workspace instead — registered
|
|
100
|
+
projects and their dependencies, shadow links, deliveries — and leaves
|
|
101
|
+
each project's validation to \`cd <path> && doctrina validate\`.
|
|
102
|
+
|
|
85
103
|
Two structural checks worth naming:
|
|
86
104
|
|
|
87
105
|
Pipeline a spec's optional "### Pipeline" block declares ordered
|
package/src/commands/verify.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readdirSync } from "node:fs";
|
|
|
5
5
|
import { wantsJson } from "../lib/json-out.js";
|
|
6
6
|
import { spawn, spawnSync } from "node:child_process";
|
|
7
7
|
import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
|
|
8
|
+
import { inventory } from "../lib/code-inventory.js";
|
|
8
9
|
import { flagBool, flagString } from "../lib/args.js";
|
|
9
10
|
import { today } from "../lib/dates.js";
|
|
10
11
|
import { c } from "../lib/colors.js";
|
|
@@ -38,6 +39,16 @@ const STARTER = {
|
|
|
38
39
|
],
|
|
39
40
|
};
|
|
40
41
|
|
|
42
|
+
const SEEDED_COMMENT =
|
|
43
|
+
"doctrina verify runs each check below, in order, through your shell. " +
|
|
44
|
+
"These were seeded from the project's own manifests by the code " +
|
|
45
|
+
"inventory — confirm each one runs what its name says, and remove or add " +
|
|
46
|
+
"checks until this file is the project's executable definition of 'the " +
|
|
47
|
+
"code actually works'. A non-zero exit fails the gate. A check may add an " +
|
|
48
|
+
"\"expect\" block — {\"fail_if_output_matches\": \"<regex>\"} and/or " +
|
|
49
|
+
"{\"require_output_matches\": \"<regex>\"} — so that a run which exits 0 " +
|
|
50
|
+
"having executed NOTHING fails the gate instead of passing it.";
|
|
51
|
+
|
|
41
52
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
42
53
|
// adding a command never requires editing the entrypoint — the gap that
|
|
43
54
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
@@ -69,9 +80,24 @@ export async function run(_positional, flags) {
|
|
|
69
80
|
console.error(c.red("error:") + ` ${CONFIG_REL} already exists (pass --force to overwrite)`);
|
|
70
81
|
return 1;
|
|
71
82
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
83
|
+
// Seeded from the code inventory when it found the project's own build
|
|
84
|
+
// and test commands (change 0228): the absorb playbook says "declare the
|
|
85
|
+
// build gate from the inventory's candidate commands", and the inventory
|
|
86
|
+
// lists them under that heading — while --init wrote `exit 1` anyway,
|
|
87
|
+
// leaving the agent to copy by hand what the CLI had just read. With no
|
|
88
|
+
// candidate the starter stays fail-closed: a gate nobody declared must
|
|
89
|
+
// not pass.
|
|
90
|
+
// A check that belongs to one unit of a monorepo runs there (`cwd`).
|
|
91
|
+
const seeded = inventory(projectRoot).checks.map((ch) => ({ name: ch.name, run: ch.command, ...(ch.cwd ? { cwd: ch.cwd } : {}) }));
|
|
92
|
+
const config = seeded.length > 0
|
|
93
|
+
? { ...STARTER, $comment: SEEDED_COMMENT, checks: seeded }
|
|
94
|
+
: STARTER;
|
|
95
|
+
write(configPath, JSON.stringify(config, null, 2) + "\n", { force: true });
|
|
96
|
+
console.log(c.green("created") + ` ${CONFIG_REL}` +
|
|
97
|
+
(seeded.length > 0 ? c.gray(` — seeded from the code inventory: ${seeded.map((s) => s.cwd ? `${s.run} (in ${s.cwd}/)` : s.run).join(", ")}`) : ""));
|
|
98
|
+
console.log(c.gray(seeded.length > 0
|
|
99
|
+
? "Confirm each check runs what its name says (add an \"expect\" block where a run could execute nothing), then run `doctrina verify`."
|
|
100
|
+
: "Edit the `checks` array with your real commands, then run `doctrina verify`."));
|
|
75
101
|
return 0;
|
|
76
102
|
}
|
|
77
103
|
|
|
@@ -223,8 +249,8 @@ export async function run(_positional, flags) {
|
|
|
223
249
|
results.push({ name, ok: true, kind: "manual", signoff: "fresh" });
|
|
224
250
|
} else if (verdict.state === "expired") {
|
|
225
251
|
console.log(c.yellow(`○ ${name} — sign-off expired`) +
|
|
226
|
-
c.gray(` (signed ${so.date} at ${short(so.sha)}; changed since: ${verdict.changed.slice(0, 3).join(", ")}` +
|
|
227
|
-
`${verdict.changed.length > 3 ? `, +${verdict.changed.length - 3} more` : ""})`) + resign);
|
|
252
|
+
c.gray(` (signed ${so.date} at ${short(so.sha)}; changed since: ${(verdict.changed ?? []).slice(0, 3).join(", ")}` +
|
|
253
|
+
`${(verdict.changed ?? []).length > 3 ? `, +${(verdict.changed ?? []).length - 3} more` : ""})`) + resign);
|
|
228
254
|
results.push({ name, ok: false, kind: "manual", pending: true, signoff: "expired" });
|
|
229
255
|
} else if (verdict.state === "unverifiable") {
|
|
230
256
|
console.log(c.yellow(`○ ${name} — sign-off cannot be verified`) +
|
|
@@ -294,7 +320,7 @@ export async function run(_positional, flags) {
|
|
|
294
320
|
const verdict = judgeOutput(expectation, output);
|
|
295
321
|
if (!verdict.ok) {
|
|
296
322
|
ok = false;
|
|
297
|
-
expectationNote = verdict.reason;
|
|
323
|
+
expectationNote = verdict.reason ?? null;
|
|
298
324
|
}
|
|
299
325
|
}
|
|
300
326
|
|
|
@@ -488,7 +514,9 @@ copy accumulates for the match. An "expect" pattern that is not a valid
|
|
|
488
514
|
regular expression fails at config time, not silently.
|
|
489
515
|
|
|
490
516
|
Flags:
|
|
491
|
-
--init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force)
|
|
517
|
+
--init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force):
|
|
518
|
+
the build and test commands the code inventory finds in the
|
|
519
|
+
project's manifests, or a fail-closed example when it finds none
|
|
492
520
|
--list Print the configured checks without running them
|
|
493
521
|
--clean Don't run checks; lint the project's package.json files for
|
|
494
522
|
reproducibility footguns (entry points in a build dir with no
|
package/src/commands/why.js
CHANGED
|
@@ -174,7 +174,7 @@ function whyAnchor(projectRoot, id, specsDir, known) {
|
|
|
174
174
|
for (const capName of known.sort()) {
|
|
175
175
|
const text = read(path.join(specsDir, capName, "spec.md"));
|
|
176
176
|
const realizesRaw = specHeader(text, "Realizes");
|
|
177
|
-
const ids = realizesRaw ? (realizesRaw.match(/[A-Z]+\d+/g) ?? []) : [];
|
|
177
|
+
const ids = realizesRaw ? (realizesRaw.match(/[A-Z]+\d+/g) ?? /** @type {string[]} */ ([])) : /** @type {string[]} */ ([]);
|
|
178
178
|
if (ids.includes(id)) realizing.push({ cap: capName, text });
|
|
179
179
|
}
|
|
180
180
|
|
package/src/commands/work.js
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import {
|
|
5
|
-
import { exists, isDir, isFile, mkdirp, read, relPath, write } from "../lib/fs-ops.js";
|
|
4
|
+
import { eolOf, exists, isDir, isFile, mkdirp, read, relPath, withEol, write } from "../lib/fs-ops.js";
|
|
6
5
|
import { flagString, flagBool } from "../lib/args.js";
|
|
7
6
|
import * as idx from "../lib/index-json.js";
|
|
8
7
|
import { c } from "../lib/colors.js";
|
|
9
8
|
import { assessBrief } from "../lib/clarity.js";
|
|
10
9
|
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
11
10
|
import { findWorkspace, projectAt, readWorkspace } from "../lib/workspace.js";
|
|
12
|
-
import { readDelivery, resolveDeliveryId } from "../lib/delivery.js";
|
|
11
|
+
import { readDelivery, resolveDeliveryId, sectionText } from "../lib/delivery.js";
|
|
13
12
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
|
+
import { listHeader } from "../lib/scan.js";
|
|
14
14
|
import { changeNew, reindexChange, addAffectedSpec } from "../lib/change-ops.js";
|
|
15
|
-
import { classify } from "../lib/triage-model.js";
|
|
15
|
+
import { classify, directSignals, trackLines } from "../lib/triage-model.js";
|
|
16
16
|
import { printPlaybookTemplate } from "../lib/playbook.js";
|
|
17
17
|
import { specShape } from "../lib/spec-ops.js";
|
|
18
18
|
import { changedFiles } from "../lib/git.js";
|
|
19
19
|
import { slugFromPrompt, fold, CONFIDENT_MARGIN } from "../lib/lexicon.js";
|
|
20
20
|
import { rankCapabilities, rankCapabilitiesByDiff, similarChanges, SIMILAR_CHANGE_DAYS } from "../lib/work-model.js";
|
|
21
21
|
import { isChangeId, refuseChangeRef } from "../lib/project.js";
|
|
22
|
+
import { nextChangeNumber } from "../lib/change-numbers.js";
|
|
23
|
+
import { withProjectLock } from "../lib/lock.js";
|
|
22
24
|
export { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
|
|
23
25
|
|
|
24
26
|
// `work` is the second half of the no-ceremony path (ADR 0005): a brief
|
|
@@ -67,6 +69,7 @@ export async function run(positional, flags) {
|
|
|
67
69
|
// workspace delivery. Checked BEFORE anything is scaffolded, so a typo or a
|
|
68
70
|
// repository the delivery was never routed to opens nothing.
|
|
69
71
|
let delivery = null;
|
|
72
|
+
let deliveryStory = "";
|
|
70
73
|
if (flags.has("delivery")) {
|
|
71
74
|
const resolved = resolveDeliveryFor(projectRoot, flagString(flags, "delivery"));
|
|
72
75
|
if (resolved.error) {
|
|
@@ -75,13 +78,14 @@ export async function run(positional, flags) {
|
|
|
75
78
|
return resolved.code;
|
|
76
79
|
}
|
|
77
80
|
delivery = resolved.id;
|
|
81
|
+
deliveryStory = resolved.story ?? "";
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
// --resume <id>: reprint the playbook for an existing open change rather than
|
|
81
85
|
// open a new one (review G1 — `work "continue"` used to slug the word and
|
|
82
86
|
// fabricate a junk `NNNN-continue` change the operator had to delete).
|
|
83
87
|
if (flags.has("resume")) {
|
|
84
|
-
return resumeChange(projectRoot, flagString(flags, "resume"));
|
|
88
|
+
return resumeChange(projectRoot, flagString(flags, "resume"), delivery);
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
// --from-diff backfills from code already written (review F8): the
|
|
@@ -172,6 +176,26 @@ export async function run(positional, flags) {
|
|
|
172
176
|
}
|
|
173
177
|
}
|
|
174
178
|
|
|
179
|
+
// THE DIRECT TRACK (change 0255). An edit that changes nothing a spec says
|
|
180
|
+
// opens nothing: the answer is the three tracks and the two commands that
|
|
181
|
+
// remain. Only the words that cannot mean behaviour decide it, and naming
|
|
182
|
+
// a capability, a delivery or an id says the author already chose.
|
|
183
|
+
if (prompt && verdict && !fromDiff && !chore && !forced &&
|
|
184
|
+
!flags.has("capability") && !flags.has("delivery") && !flags.has("id")) {
|
|
185
|
+
const hits = directSignals(prompt, verdict);
|
|
186
|
+
if (hits.length > 0) {
|
|
187
|
+
console.log(c.green("direct:") + ` no change to open — this reads as an edit that changes nothing a spec says ${c.gray(`(signals: ${[...new Set(hits)].slice(0, 6).join(", ")})`)}`);
|
|
188
|
+
console.log(` Edit it, run ${c.cyan("doctrina verify")}, and commit. No proposal, no delta, no close.`);
|
|
189
|
+
console.log("");
|
|
190
|
+
console.log("The tracks, by what the request changes:");
|
|
191
|
+
for (const line of trackLines()) console.log(line);
|
|
192
|
+
console.log("");
|
|
193
|
+
console.log(c.gray("If it does change behaviour, or deserves a record: ") +
|
|
194
|
+
c.cyan(`doctrina work --force "<request>"`) + c.gray(" · ") + c.cyan(`doctrina work --chore "<request>"`));
|
|
195
|
+
return EXIT.OK;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
175
199
|
const pinned = flagString(flags, "capability");
|
|
176
200
|
if (pinned && !/^[a-z][a-z0-9-]*$/.test(pinned)) {
|
|
177
201
|
console.error(c.red("error:") + ` invalid capability "${pinned}" (lowercase letters, digits, hyphens)`);
|
|
@@ -192,14 +216,19 @@ export async function run(positional, flags) {
|
|
|
192
216
|
const slug = flags.has("title")
|
|
193
217
|
? slugify(title)
|
|
194
218
|
: (prompt ? slugFromPrompt(prompt) : "backfill");
|
|
195
|
-
|
|
196
|
-
|
|
219
|
+
// The number is read and the folder created under one lock (change 0250):
|
|
220
|
+
// read apart, eight parallel `work` opened three changes numbered 0003.
|
|
221
|
+
const opened = withProjectLock(projectRoot, () => {
|
|
222
|
+
const candidate = flagString(flags, "id") ?? `${nextChangeNumber(projectRoot)}-${slug}`;
|
|
223
|
+
if (!isChangeId(candidate)) return { id: candidate, code: null };
|
|
224
|
+
return { id: candidate, code: changeNew([candidate, title], flags) };
|
|
225
|
+
});
|
|
226
|
+
const id = opened.id;
|
|
227
|
+
if (opened.code === null) {
|
|
197
228
|
console.error(c.red("error:") + ` invalid change id "${id}" (lowercase letters, digits, hyphens)`);
|
|
198
229
|
return 2;
|
|
199
230
|
}
|
|
200
|
-
|
|
201
|
-
const code = changeNew([id, title], flags);
|
|
202
|
-
if (code !== 0) return code;
|
|
231
|
+
if (opened.code !== 0) return opened.code;
|
|
203
232
|
|
|
204
233
|
// The CLI reduces a prompt to an id deterministically; it cannot write a
|
|
205
234
|
// good short name, and ADR 0005 says it must not try. So it says how to get
|
|
@@ -237,14 +266,17 @@ export async function run(positional, flags) {
|
|
|
237
266
|
if (delivery) {
|
|
238
267
|
updated = updated.replace(/^(-[ \t]+\*\*Lane:\*\*[^\r\n]*)/m, `$1\n- **Delivery:** ${delivery}`);
|
|
239
268
|
}
|
|
240
|
-
if (updated !== txt) write(proposalPath, updated, { force: true });
|
|
269
|
+
if (updated !== txt) write(proposalPath, withEol(updated, eolOf(txt)), { force: true });
|
|
241
270
|
}
|
|
242
271
|
|
|
243
272
|
// Capability hint: term overlap for a prompt, changed-file overlap for a diff
|
|
244
273
|
// (review F10 — rank by what the working tree touched, not just prompt words).
|
|
245
274
|
const allChanged = fromDiff ? files : changedFiles(projectRoot).files;
|
|
246
275
|
const diffMatches = pinned ? [] : rankCapabilitiesByDiff(projectRoot, allChanged);
|
|
247
|
-
|
|
276
|
+
// A delivery's change is ranked by its story too (second audit, change
|
|
277
|
+
// 0233): the title of a tracker item rarely names the capability — "lock
|
|
278
|
+
// the account after 3 wrong passwords" — while its story says "login".
|
|
279
|
+
const promptMatches = pinned || fromDiff ? [] : rankCapabilities(projectRoot, deliveryStory ? `${effPrompt}\n${deliveryStory}` : effPrompt);
|
|
248
280
|
const matches = pinned ? [] : (fromDiff ? diffMatches : promptMatches);
|
|
249
281
|
const capability = pinned ?? matches[0]?.id ?? null;
|
|
250
282
|
const clarity = assessBrief(effPrompt, { kind: "prompt" });
|
|
@@ -323,7 +355,7 @@ export async function run(positional, flags) {
|
|
|
323
355
|
// see beats a missing file nothing reports.
|
|
324
356
|
//
|
|
325
357
|
// Returns what was written, or null when the file already exists.
|
|
326
|
-
function scaffoldDelta(projectRoot, { id, capability, guess = null, runnerUp = null }) {
|
|
358
|
+
function scaffoldDelta(projectRoot, { id, capability, guess = /** @type {any} */ (null), runnerUp = /** @type {any} */ (null) }) {
|
|
327
359
|
const deltaPath = path.join(projectRoot, ".doctrina", "changes", id, "specs", capability, "delta.md");
|
|
328
360
|
if (exists(deltaPath)) return null;
|
|
329
361
|
const tpl = read(path.join(locateTemplatesDir(), "change", "spec-delta.md.template"));
|
|
@@ -399,7 +431,7 @@ function resolveDeliveryFor(projectRoot, ref) {
|
|
|
399
431
|
code: EXIT.PRECONDITION,
|
|
400
432
|
};
|
|
401
433
|
}
|
|
402
|
-
return { id };
|
|
434
|
+
return { id, story: sectionText(d.text, "Story") ?? "" };
|
|
403
435
|
}
|
|
404
436
|
|
|
405
437
|
function writeLane(projectRoot, id, verdict, opts) {
|
|
@@ -408,8 +440,8 @@ function writeLane(projectRoot, id, verdict, opts) {
|
|
|
408
440
|
const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
|
|
409
441
|
if (!isFile(proposalPath)) return;
|
|
410
442
|
const text = read(proposalPath);
|
|
411
|
-
// CRLF-safe:
|
|
412
|
-
// silently matches nothing against
|
|
443
|
+
// CRLF-safe: a scaffolded proposal may be CRLF, and a `.*$` pattern
|
|
444
|
+
// silently matches nothing against one — the `check-the-line-ending-before-patching`
|
|
413
445
|
// skill exists for exactly this mistake.
|
|
414
446
|
const updated = text.replace(/^(-[ \t]+\*\*Lane:\*\*)[^\r\n]*/m, `$1 ${line}`);
|
|
415
447
|
if (updated !== text) write(proposalPath, updated, { force: true });
|
|
@@ -417,29 +449,6 @@ function writeLane(projectRoot, id, verdict, opts) {
|
|
|
417
449
|
|
|
418
450
|
// Next sequential NNNN across open changes and the archive, so work-driven
|
|
419
451
|
// ids stay ordered no matter how earlier changes were named.
|
|
420
|
-
function nextChangeNumber(projectRoot) {
|
|
421
|
-
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
422
|
-
let max = 0;
|
|
423
|
-
const harvest = (name) => {
|
|
424
|
-
const m = name.match(/^(\d{1,4})-/) ?? name.match(/^(\d{1,4})$/);
|
|
425
|
-
if (m) max = Math.max(max, Number(m[1]));
|
|
426
|
-
};
|
|
427
|
-
if (isDir(changesDir)) {
|
|
428
|
-
for (const entry of readdirSync(changesDir)) {
|
|
429
|
-
if (entry === "archive" || entry.startsWith(".")) continue;
|
|
430
|
-
if (isDir(path.join(changesDir, entry))) harvest(entry);
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
const archiveDir = path.join(changesDir, "archive");
|
|
434
|
-
if (isDir(archiveDir)) {
|
|
435
|
-
for (const entry of readdirSync(archiveDir)) {
|
|
436
|
-
if (!isDir(path.join(archiveDir, entry))) continue;
|
|
437
|
-
harvest(entry.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
return String(max + 1).padStart(4, "0");
|
|
441
|
-
}
|
|
442
|
-
|
|
443
452
|
// Open (non-archived) changes from the index — the set `work --resume` and
|
|
444
453
|
// the thin-prompt guard offer to continue.
|
|
445
454
|
function openChanges(projectRoot) {
|
|
@@ -481,7 +490,7 @@ function printResumeSuggestion(open) {
|
|
|
481
490
|
|
|
482
491
|
// Reprint the work playbook for an already-open change, ranking capabilities
|
|
483
492
|
// from its recorded title (the original prompt). Creates nothing.
|
|
484
|
-
function resumeChange(projectRoot, resumeId) {
|
|
493
|
+
function resumeChange(projectRoot, resumeId, delivery = null) {
|
|
485
494
|
const open = openChanges(projectRoot);
|
|
486
495
|
if (!resumeId || resumeId === true) {
|
|
487
496
|
console.error(c.red("error:") + " --resume needs a change id");
|
|
@@ -496,6 +505,25 @@ function resumeChange(projectRoot, resumeId) {
|
|
|
496
505
|
if (open.length) console.error(c.gray("open: ") + open.map((ch) => ch.id).join(", "));
|
|
497
506
|
return 1;
|
|
498
507
|
}
|
|
508
|
+
// --resume <id> --delivery <id> (change 0226): a change opened before its
|
|
509
|
+
// delivery existed — or without the flag — joins it, so the delivery's
|
|
510
|
+
// status and check see it. A change already in another delivery is not
|
|
511
|
+
// moved silently.
|
|
512
|
+
if (delivery) {
|
|
513
|
+
const proposalPath = path.join(projectRoot, ".doctrina", "changes", resumeId, "proposal.md");
|
|
514
|
+
const text = isFile(proposalPath) ? read(proposalPath) : "";
|
|
515
|
+
const current = (listHeader(text, "Delivery") ?? "").trim();
|
|
516
|
+
if (current && current !== delivery) {
|
|
517
|
+
console.error(c.red("error:") + ` ${resumeId} already belongs to delivery ${current}`);
|
|
518
|
+
return EXIT.PRECONDITION;
|
|
519
|
+
}
|
|
520
|
+
if (!current) {
|
|
521
|
+
const stamped = text.replace(/^(-[ \t]+\*\*Lane:\*\*[^\r\n]*)/m, `$1\n- **Delivery:** ${delivery}`);
|
|
522
|
+
const updated = stamped !== text ? stamped : text.replace(/^(-[ \t]+\*\*Status:\*\*[^\r\n]*)/m, `$1\n- **Delivery:** ${delivery}`);
|
|
523
|
+
write(proposalPath, withEol(updated, eolOf(text)), { force: true });
|
|
524
|
+
console.log(c.green("joined") + ` ${resumeId} to delivery ${delivery}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
499
527
|
const prompt = entry?.title ?? resumeId;
|
|
500
528
|
const matches = rankCapabilities(projectRoot, prompt);
|
|
501
529
|
const capability = matches[0]?.id ?? null;
|
|
@@ -560,7 +588,7 @@ function printChorePlaybook(projectRoot, { id, prompt }) {
|
|
|
560
588
|
printPlaybookTemplate(projectRoot, "chore", { CHANGE_ID: id, PROMPT: prompt });
|
|
561
589
|
}
|
|
562
590
|
|
|
563
|
-
function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity, scaffolded = null, fromDiff = false, diffMatches = [] }) {
|
|
591
|
+
function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity, scaffolded = /** @type {any} */ (null), fromDiff = false, diffMatches = /** @type {any[]} */ ([]) }) {
|
|
564
592
|
printPlaybookTemplate(projectRoot, "work", {
|
|
565
593
|
TITLE: fromDiff ? "Backfill playbook" : "Work playbook",
|
|
566
594
|
CHANGE_ID: id,
|
|
@@ -610,7 +638,9 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
610
638
|
// The prompt as a `context --for` argument: one line, no double quotes, short
|
|
611
639
|
// enough to read — the slicer only needs its content words.
|
|
612
640
|
export function forQuery(prompt) {
|
|
613
|
-
|
|
641
|
+
// `$` and `\\` too (change 0235): between the double quotes the playbook
|
|
642
|
+
// prints, `$(…)` in a prompt still ran.
|
|
643
|
+
const flat = String(prompt ?? "").replace(/["`$\\]/g, "").replace(/\s+/g, " ").trim();
|
|
614
644
|
return flat.length > 80 ? flat.slice(0, 80).replace(/\s+\S*$/, "") : flat;
|
|
615
645
|
}
|
|
616
646
|
|
|
@@ -667,6 +697,8 @@ Options:
|
|
|
667
697
|
(backlog entry; reprint later with --resume <id>)
|
|
668
698
|
--id <id> Override the derived change id
|
|
669
699
|
--resume <id> Reprint the playbook for an open change; create nothing
|
|
700
|
+
--delivery <id> Stamp the change with the workspace delivery it is part
|
|
701
|
+
of (ADR 0030); with --resume, join an open change to it
|
|
670
702
|
--from-diff Backfill: scaffold from the working-tree changes (no
|
|
671
703
|
prompt needed) and print a code-first playbook (F8)
|
|
672
704
|
--consolidate <cap> Open a change that consolidates <cap>'s spec — merge
|
|
@@ -685,6 +717,22 @@ A request that mostly repeats a change already open, or archived in the
|
|
|
685
717
|
last 14 days, is HELD the same way (exit 3), naming it: the same work is
|
|
686
718
|
resumed, or reported as done where it is archived; --force opens it anyway.
|
|
687
719
|
|
|
720
|
+
TRACKS. How much ceremony a request earns is decided by what it changes,
|
|
721
|
+
not by the size of the diff:
|
|
722
|
+
|
|
723
|
+
direct nothing a spec says — a typo, a comment, formatting, a fix that
|
|
724
|
+
makes the code do what the spec already says
|
|
725
|
+
edit · doctrina verify · commit (no change is opened)
|
|
726
|
+
chore the build, the tooling or the docs, worth a record — no spec
|
|
727
|
+
changes: doctrina work --chore "<request>"
|
|
728
|
+
change behaviour — a sentence in a spec becomes false, or one is
|
|
729
|
+
missing: doctrina work "<request>"
|
|
730
|
+
|
|
731
|
+
A prompt whose words can only mean a direct edit (a typo, whitespace, a
|
|
732
|
+
stale comment, dead code) opens nothing: work prints the tracks and exits
|
|
733
|
+
0. --force opens the change anyway, --chore records it, and naming
|
|
734
|
+
--capability, --delivery or --id skips the reading.
|
|
735
|
+
|
|
688
736
|
A prompt that reads as a RUNTIME problem — a workflow, an env value, a run
|
|
689
737
|
that executed nothing — is HELD before anything is scaffolded, and pointed
|
|
690
738
|
at \`doctrina triage\` instead (exit 3: a precondition, not a bad prompt).
|