doctrina-cli 0.20.0 → 0.21.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 +2 -2
- package/package.json +1 -1
- package/src/commands/adapter.js +10 -0
- package/src/commands/change.js +92 -14
- package/src/commands/clarify.js +1 -204
- package/src/commands/context.js +33 -2
- package/src/commands/contract.js +1 -0
- package/src/commands/delivery.js +135 -17
- package/src/commands/doctor.js +21 -1
- package/src/commands/handoff.js +11 -2
- package/src/commands/init.js +13 -1
- package/src/commands/intake.js +16 -5
- package/src/commands/metrics.js +15 -0
- package/src/commands/next.js +41 -6
- package/src/commands/prime.js +53 -4
- package/src/commands/review.js +39 -3
- package/src/commands/skill.js +2 -1
- package/src/commands/status.js +28 -0
- package/src/commands/upgrade.js +18 -0
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +32 -4
- package/src/commands/work.js +43 -8
- package/src/commands/workspace.js +73 -63
- package/src/index.js +98 -62
- package/src/lib/actions.js +43 -3
- package/src/lib/adapters.js +63 -1
- package/src/lib/args.js +33 -1
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +576 -103
- package/src/lib/commands.js +8 -0
- package/src/lib/context-slice.js +34 -3
- package/src/lib/delivery.js +88 -10
- package/src/lib/docs-impact.js +36 -1
- package/src/lib/fs-ops.js +23 -0
- package/src/lib/intake-model.js +1 -1
- package/src/lib/json-out.js +9 -1
- package/src/lib/shell.js +45 -0
- package/src/lib/snapshot.js +5 -1
- package/src/lib/task-card.js +100 -7
- package/src/lib/views.js +3 -3
- package/src/lib/work-model.js +8 -2
- package/src/lib/workspace-agents.js +65 -18
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +63 -16
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +82 -130
- package/templates/AGENTS.md.template +1 -1
- package/templates/README.md +2 -1
- 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/{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/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/review.js
CHANGED
|
@@ -6,6 +6,7 @@ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
|
|
|
6
6
|
import { flagBool, flagString } from "../lib/args.js";
|
|
7
7
|
import { c } from "../lib/colors.js";
|
|
8
8
|
import { rankCapabilitiesByDiff } from "../lib/work-model.js";
|
|
9
|
+
import { applyOps, extractOps } from "../lib/spec-ops.js";
|
|
9
10
|
import { readLedger, churnByCapability } from "../lib/ledger.js";
|
|
10
11
|
import { changedFiles, windowCutoff, historyState, refExists } from "../lib/git.js";
|
|
11
12
|
import { dependentsOf, deltaCapabilities } from "../lib/scan.js";
|
|
@@ -67,6 +68,8 @@ export async function run(_positional, flags) {
|
|
|
67
68
|
const changeId = flagString(flags, "change");
|
|
68
69
|
/** @type {Set<string> | null} */
|
|
69
70
|
let changeCaps = null;
|
|
71
|
+
/** @type {Map<string, string>} the change's delta text per capability */
|
|
72
|
+
const pending = new Map();
|
|
70
73
|
if (changeId !== undefined) {
|
|
71
74
|
const refused = refuseChangeRef(changeId);
|
|
72
75
|
if (refused !== null) return refused;
|
|
@@ -76,6 +79,19 @@ export async function run(_positional, flags) {
|
|
|
76
79
|
return EXIT.USAGE;
|
|
77
80
|
}
|
|
78
81
|
changeCaps = new Set(deltaCapabilities(changeDir));
|
|
82
|
+
// The specs as this change leaves them: an ops delta applied in memory,
|
|
83
|
+
// as `change check` dry-runs it; a delta without ops (a manual merge)
|
|
84
|
+
// read beside the spec, since nothing can apply it.
|
|
85
|
+
for (const cap of changeCaps) {
|
|
86
|
+
const delta = path.join(changeDir, "specs", cap, "delta.md");
|
|
87
|
+
const spec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
|
|
88
|
+
if (!isFile(delta)) continue;
|
|
89
|
+
const deltaText = read(delta);
|
|
90
|
+
const current = isFile(spec) ? read(spec) : "";
|
|
91
|
+
const ops = extractOps(deltaText);
|
|
92
|
+
const after = ops.length > 0 ? applyOps(current, ops) : null;
|
|
93
|
+
pending.set(cap, after && after.errors.length === 0 ? after.text : `${current}\n${deltaText}`);
|
|
94
|
+
}
|
|
79
95
|
}
|
|
80
96
|
|
|
81
97
|
const { all, sourceFiles } = reviewScope(projectRoot, against);
|
|
@@ -102,11 +118,19 @@ export async function run(_positional, flags) {
|
|
|
102
118
|
all.filter((f) => /(^|\/)\.doctrina\/specs\/[^/]+\/spec\.md$/.test(f.replace(/\\/g, "/")))
|
|
103
119
|
.map((f) => f.replace(/\\/g, "/").match(/\.doctrina\/specs\/([^/]+)\/spec\.md$/)[1]),
|
|
104
120
|
);
|
|
105
|
-
const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles, { limit: Infinity });
|
|
121
|
+
const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles, { limit: Infinity, pending });
|
|
106
122
|
const touched = new Set([...specChanged, ...ranked.map((m) => m.id)]);
|
|
107
123
|
const stale = ranked.map((m) => m.id).filter((id) => !specChanged.has(id) && !changeCaps?.has(id));
|
|
124
|
+
// Without --change, an OPEN change that carries a delta for the capability
|
|
125
|
+
// updates its spec at close (fifth audit, change 0236): the bare review
|
|
126
|
+
// reported "its spec was not updated" for exactly the work a planner had
|
|
127
|
+
// in flight — the real agent read it as a break and went to fix the spec.
|
|
128
|
+
const inFlight = changeCaps === null ? openDeltaOwners(projectRoot) : new Map();
|
|
108
129
|
if (changeCaps === null) {
|
|
109
|
-
for (const id of stale) {
|
|
130
|
+
for (const id of stale.filter((x) => inFlight.has(x))) {
|
|
131
|
+
notes.push(`code under capability "${id}" changed; its spec is updated when ${inFlight.get(id)?.join(", ")} closes (\`doctrina review --change <id>\` reviews it as that close does)`);
|
|
132
|
+
}
|
|
133
|
+
for (const id of stale.filter((x) => !inFlight.has(x))) {
|
|
110
134
|
breaks.push(`code under capability "${id}" changed, but its spec was not updated — confirm \`.doctrina/specs/${id}/spec.md\` still describes the code (update it, or note why not)`);
|
|
111
135
|
}
|
|
112
136
|
} else if (stale.length > 0) {
|
|
@@ -151,7 +175,7 @@ export async function run(_positional, flags) {
|
|
|
151
175
|
// file in the change. A review of an adapter change reported
|
|
152
176
|
// "Capabilities touched: docs" and said nothing about the adapter.
|
|
153
177
|
const orphans = sourceFiles.filter(
|
|
154
|
-
(f) => rankCapabilitiesByDiff(projectRoot, [f], { limit: 1 }).length === 0,
|
|
178
|
+
(f) => rankCapabilitiesByDiff(projectRoot, [f], { limit: 1, pending }).length === 0,
|
|
155
179
|
);
|
|
156
180
|
if (orphans.length > 0) {
|
|
157
181
|
const shown = orphans.slice(0, ORPHAN_LIST_LIMIT).map((f) => `\`${f}\``).join(", ");
|
|
@@ -274,3 +298,15 @@ from specs that declare no Source.
|
|
|
274
298
|
Read-only. Exits 0 as a report; with --strict, exits 1 when any hard break
|
|
275
299
|
exists (CI gate). Notes (advisory) never fail the gate.
|
|
276
300
|
`;
|
|
301
|
+
|
|
302
|
+
/** Capability → the open changes that carry a delta for it. */
|
|
303
|
+
function openDeltaOwners(projectRoot) {
|
|
304
|
+
const out = new Map();
|
|
305
|
+
const changes = path.join(projectRoot, ".doctrina", "changes");
|
|
306
|
+
if (!isDir(changes)) return out;
|
|
307
|
+
for (const id of readdirSync(changes).sort()) {
|
|
308
|
+
if (id === "archive" || id.startsWith(".") || !isDir(path.join(changes, id))) continue;
|
|
309
|
+
for (const cap of deltaCapabilities(path.join(changes, id))) out.set(cap, [...(out.get(cap) ?? []), id]);
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
package/src/commands/skill.js
CHANGED
|
@@ -455,7 +455,8 @@ knowledge loaded only when relevant.
|
|
|
455
455
|
|
|
456
456
|
Subcommands:
|
|
457
457
|
new <name> Scaffold a new skill from the template and index
|
|
458
|
-
it. Name must match [a-z][a-z0-9-]*.
|
|
458
|
+
it. Name must match [a-z][a-z0-9-]*. --force
|
|
459
|
+
overwrites an existing skill.
|
|
459
460
|
list Print one line per skill with name + description.
|
|
460
461
|
Read-only.
|
|
461
462
|
sync Copy each skill's frontmatter description into
|
package/src/commands/status.js
CHANGED
|
@@ -11,6 +11,7 @@ import { renderView, VIEWS } from "../lib/views.js";
|
|
|
11
11
|
import { draftAgentChangelog, renderDraft } from "../lib/agent-changelog.js";
|
|
12
12
|
import { cliVersion } from "../lib/version.js";
|
|
13
13
|
import { suggest } from "../lib/suggest.js";
|
|
14
|
+
import { contextRouting, workspaceActions, workspaceFindings, workspaceHandoff, workspaceRootAt, workspaceSnapshot, workspaceStatus } from "../lib/workspace-view.js";
|
|
14
15
|
|
|
15
16
|
// One-glance project health (review 2026-06-27 passive-user feature #1): a
|
|
16
17
|
// dashboard that answers "where do things stand?" in a single read, so neither
|
|
@@ -38,6 +39,8 @@ const STATUS_VIEWS = [...VIEWS, "agent-changelog"];
|
|
|
38
39
|
|
|
39
40
|
export async function run(_positional, flags) {
|
|
40
41
|
const projectRoot = process.cwd();
|
|
42
|
+
const wsRoot = workspaceRootAt(projectRoot);
|
|
43
|
+
if (wsRoot) return workspaceStatusView(wsRoot, flags);
|
|
41
44
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
42
45
|
throw notADoctrinaProject();
|
|
43
46
|
}
|
|
@@ -90,6 +93,9 @@ signals (index drift, framework stamp, coverage %, trace anchors, whether
|
|
|
90
93
|
verify is configured) and the artifact counts (specs by implementation
|
|
91
94
|
state, open changes, decisions, skills). Read-only; always exits 0.
|
|
92
95
|
|
|
96
|
+
At a workspace root it prints the workspace instead: projects ready,
|
|
97
|
+
pending and missing, open changes, deliveries open and closed, tools.
|
|
98
|
+
|
|
93
99
|
Flags:
|
|
94
100
|
--view <name> Render a different shape of the same snapshot:
|
|
95
101
|
dashboard the default one-glance health board
|
|
@@ -116,3 +122,25 @@ It is a fast summary, not the authoritative gate: run \`doctrina validate\`
|
|
|
116
122
|
and \`doctrina verify\` for the full structural and build checks, and
|
|
117
123
|
\`doctrina next\` for the recommended next action.
|
|
118
124
|
`;
|
|
125
|
+
|
|
126
|
+
// At a workspace root: one glance at the workspace (change 0230).
|
|
127
|
+
function workspaceStatusView(wsRoot, flags) {
|
|
128
|
+
const snap = workspaceSnapshot(wsRoot);
|
|
129
|
+
const st = workspaceStatus(snap);
|
|
130
|
+
if (flagBool(flags, "json", false)) {
|
|
131
|
+
emitJson("status", { workspace: wsRoot, ...st });
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
console.log(c.bold(`Doctrina workspace "${st.name}"`) + c.gray(` — ${wsRoot}`));
|
|
135
|
+
console.log("");
|
|
136
|
+
console.log(`Projects ${st.ready}/${st.projects} ready` + (st.pending.length ? c.yellow(` · pending: ${st.pending.join(", ")}`) : "")
|
|
137
|
+
+ (st.missing.length ? c.red(` · missing: ${st.missing.join(", ")}`) : "") + ` · ${st.openChanges} open change${st.openChanges === 1 ? "" : "s"}`);
|
|
138
|
+
console.log(`Deliveries ${st.deliveries.open} open · ${st.deliveries.closed} closed`);
|
|
139
|
+
console.log(`Tools ${st.tools} declared`);
|
|
140
|
+
const first = workspaceActions(snap)[0];
|
|
141
|
+
if (first) {
|
|
142
|
+
console.log("");
|
|
143
|
+
console.log(c.gray("next: ") + `${first.text} — ` + c.cyan(first.command));
|
|
144
|
+
}
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
package/src/commands/upgrade.js
CHANGED
|
@@ -10,6 +10,9 @@ import { c } from "../lib/colors.js";
|
|
|
10
10
|
import { today } from "../lib/dates.js";
|
|
11
11
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
12
|
import { planSkillExport, applySkillExport } from "../lib/skill-export.js";
|
|
13
|
+
import { applyAdapterMigration, planAdapterMigration } from "../lib/adapters.js";
|
|
14
|
+
import { projectName } from "../lib/project.js";
|
|
15
|
+
import { relinkShadow, shadowRootOf } from "../lib/workspace.js";
|
|
13
16
|
import { collectFindings } from "../lib/templates-model.js";
|
|
14
17
|
import * as templates from "./templates.js";
|
|
15
18
|
import * as validate from "./validate.js";
|
|
@@ -78,6 +81,8 @@ export async function run(_positional, flags) {
|
|
|
78
81
|
// The skill exports (change 0212) follow the index: regenerated by the
|
|
79
82
|
// write, reported by the preview.
|
|
80
83
|
const exportPlan = planSkillExport(projectRoot);
|
|
84
|
+
// Adapter files that moved (change 0238): Cursor's slash commands became skills.
|
|
85
|
+
const movePlan = planAdapterMigration(projectRoot, { PROJECT_NAME: projectName(projectRoot), PROJECT_DESCRIPTION: "", DATE: today() }, shadowRootOf(projectRoot) ?? projectRoot);
|
|
81
86
|
const exportChanges = exportPlan.writes.length + exportPlan.removes.length;
|
|
82
87
|
|
|
83
88
|
// 3. Index: rebuild from the tree, which also migrates the framework stamp.
|
|
@@ -96,6 +101,19 @@ export async function run(_positional, flags) {
|
|
|
96
101
|
pending += 1;
|
|
97
102
|
}
|
|
98
103
|
}
|
|
104
|
+
const moves = movePlan.writes.length + movePlan.removes.length;
|
|
105
|
+
if (moves > 0) {
|
|
106
|
+
if (writeMode) {
|
|
107
|
+
const gone = applyAdapterMigration(shadowRootOf(projectRoot) ?? projectRoot, movePlan);
|
|
108
|
+
relinkShadow(projectRoot, gone);
|
|
109
|
+
console.log(c.green("moved ") + ` ${movePlan.writes.length} adapter file${movePlan.writes.length === 1 ? "" : "s"} to the skills the agent reads now` +
|
|
110
|
+
(movePlan.removes.length ? `, ${movePlan.removes.length} retired cop${movePlan.removes.length === 1 ? "y" : "ies"} removed` : ""));
|
|
111
|
+
} else {
|
|
112
|
+
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)`);
|
|
113
|
+
pending += 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
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
117
|
if (!drifted && !staleStamp) {
|
|
100
118
|
console.log(c.green("ok") + " index.json matches the tree and the stamp is current");
|
|
101
119
|
} else if (writeMode) {
|
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
|
|
|
@@ -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/work.js
CHANGED
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { readdirSync } from "node:fs";
|
|
5
|
-
import { exists, isDir, isFile, mkdirp, read, relPath, write } from "../lib/fs-ops.js";
|
|
5
|
+
import { eolOf, exists, isDir, isFile, mkdirp, read, relPath, withEol, write } from "../lib/fs-ops.js";
|
|
6
6
|
import { flagString, flagBool } from "../lib/args.js";
|
|
7
7
|
import * as idx from "../lib/index-json.js";
|
|
8
8
|
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 { findWorkspace, projectAt, readWorkspace } from "../lib/workspace.js";
|
|
12
|
-
import { readDelivery, resolveDeliveryId } from "../lib/delivery.js";
|
|
12
|
+
import { readDelivery, resolveDeliveryId, sectionText } from "../lib/delivery.js";
|
|
13
|
+
import { parseLedger } from "../lib/ledger.js";
|
|
13
14
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
15
|
+
import { listHeader } from "../lib/scan.js";
|
|
14
16
|
import { changeNew, reindexChange, addAffectedSpec } from "../lib/change-ops.js";
|
|
15
17
|
import { classify } from "../lib/triage-model.js";
|
|
16
18
|
import { printPlaybookTemplate } from "../lib/playbook.js";
|
|
@@ -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
|
|
@@ -237,14 +241,17 @@ export async function run(positional, flags) {
|
|
|
237
241
|
if (delivery) {
|
|
238
242
|
updated = updated.replace(/^(-[ \t]+\*\*Lane:\*\*[^\r\n]*)/m, `$1\n- **Delivery:** ${delivery}`);
|
|
239
243
|
}
|
|
240
|
-
if (updated !== txt) write(proposalPath, updated, { force: true });
|
|
244
|
+
if (updated !== txt) write(proposalPath, withEol(updated, eolOf(txt)), { force: true });
|
|
241
245
|
}
|
|
242
246
|
|
|
243
247
|
// Capability hint: term overlap for a prompt, changed-file overlap for a diff
|
|
244
248
|
// (review F10 — rank by what the working tree touched, not just prompt words).
|
|
245
249
|
const allChanged = fromDiff ? files : changedFiles(projectRoot).files;
|
|
246
250
|
const diffMatches = pinned ? [] : rankCapabilitiesByDiff(projectRoot, allChanged);
|
|
247
|
-
|
|
251
|
+
// A delivery's change is ranked by its story too (second audit, change
|
|
252
|
+
// 0233): the title of a tracker item rarely names the capability — "lock
|
|
253
|
+
// the account after 3 wrong passwords" — while its story says "login".
|
|
254
|
+
const promptMatches = pinned || fromDiff ? [] : rankCapabilities(projectRoot, deliveryStory ? `${effPrompt}\n${deliveryStory}` : effPrompt);
|
|
248
255
|
const matches = pinned ? [] : (fromDiff ? diffMatches : promptMatches);
|
|
249
256
|
const capability = pinned ?? matches[0]?.id ?? null;
|
|
250
257
|
const clarity = assessBrief(effPrompt, { kind: "prompt" });
|
|
@@ -399,7 +406,7 @@ function resolveDeliveryFor(projectRoot, ref) {
|
|
|
399
406
|
code: EXIT.PRECONDITION,
|
|
400
407
|
};
|
|
401
408
|
}
|
|
402
|
-
return { id };
|
|
409
|
+
return { id, story: sectionText(d.text, "Story") ?? "" };
|
|
403
410
|
}
|
|
404
411
|
|
|
405
412
|
function writeLane(projectRoot, id, verdict, opts) {
|
|
@@ -437,6 +444,11 @@ function nextChangeNumber(projectRoot) {
|
|
|
437
444
|
harvest(entry.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
|
|
438
445
|
}
|
|
439
446
|
}
|
|
447
|
+
// An abandoned change leaves no folder, only its ledger line (third audit,
|
|
448
|
+
// change 0234): its number was handed out again, and the ledger then named
|
|
449
|
+
// two different changes by one id — the abandoned part of a delivery among them.
|
|
450
|
+
const ledger = path.join(archiveDir, "LEDGER.md");
|
|
451
|
+
if (isFile(ledger)) for (const e of parseLedger(read(ledger)).entries) harvest(e.id);
|
|
440
452
|
return String(max + 1).padStart(4, "0");
|
|
441
453
|
}
|
|
442
454
|
|
|
@@ -481,7 +493,7 @@ function printResumeSuggestion(open) {
|
|
|
481
493
|
|
|
482
494
|
// Reprint the work playbook for an already-open change, ranking capabilities
|
|
483
495
|
// from its recorded title (the original prompt). Creates nothing.
|
|
484
|
-
function resumeChange(projectRoot, resumeId) {
|
|
496
|
+
function resumeChange(projectRoot, resumeId, delivery = null) {
|
|
485
497
|
const open = openChanges(projectRoot);
|
|
486
498
|
if (!resumeId || resumeId === true) {
|
|
487
499
|
console.error(c.red("error:") + " --resume needs a change id");
|
|
@@ -496,6 +508,25 @@ function resumeChange(projectRoot, resumeId) {
|
|
|
496
508
|
if (open.length) console.error(c.gray("open: ") + open.map((ch) => ch.id).join(", "));
|
|
497
509
|
return 1;
|
|
498
510
|
}
|
|
511
|
+
// --resume <id> --delivery <id> (change 0226): a change opened before its
|
|
512
|
+
// delivery existed — or without the flag — joins it, so the delivery's
|
|
513
|
+
// status and check see it. A change already in another delivery is not
|
|
514
|
+
// moved silently.
|
|
515
|
+
if (delivery) {
|
|
516
|
+
const proposalPath = path.join(projectRoot, ".doctrina", "changes", resumeId, "proposal.md");
|
|
517
|
+
const text = isFile(proposalPath) ? read(proposalPath) : "";
|
|
518
|
+
const current = (listHeader(text, "Delivery") ?? "").trim();
|
|
519
|
+
if (current && current !== delivery) {
|
|
520
|
+
console.error(c.red("error:") + ` ${resumeId} already belongs to delivery ${current}`);
|
|
521
|
+
return EXIT.PRECONDITION;
|
|
522
|
+
}
|
|
523
|
+
if (!current) {
|
|
524
|
+
const stamped = text.replace(/^(-[ \t]+\*\*Lane:\*\*[^\r\n]*)/m, `$1\n- **Delivery:** ${delivery}`);
|
|
525
|
+
const updated = stamped !== text ? stamped : text.replace(/^(-[ \t]+\*\*Status:\*\*[^\r\n]*)/m, `$1\n- **Delivery:** ${delivery}`);
|
|
526
|
+
write(proposalPath, withEol(updated, eolOf(text)), { force: true });
|
|
527
|
+
console.log(c.green("joined") + ` ${resumeId} to delivery ${delivery}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
499
530
|
const prompt = entry?.title ?? resumeId;
|
|
500
531
|
const matches = rankCapabilities(projectRoot, prompt);
|
|
501
532
|
const capability = matches[0]?.id ?? null;
|
|
@@ -610,7 +641,9 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
610
641
|
// The prompt as a `context --for` argument: one line, no double quotes, short
|
|
611
642
|
// enough to read — the slicer only needs its content words.
|
|
612
643
|
export function forQuery(prompt) {
|
|
613
|
-
|
|
644
|
+
// `$` and `\\` too (change 0235): between the double quotes the playbook
|
|
645
|
+
// prints, `$(…)` in a prompt still ran.
|
|
646
|
+
const flat = String(prompt ?? "").replace(/["`$\\]/g, "").replace(/\s+/g, " ").trim();
|
|
614
647
|
return flat.length > 80 ? flat.slice(0, 80).replace(/\s+\S*$/, "") : flat;
|
|
615
648
|
}
|
|
616
649
|
|
|
@@ -667,6 +700,8 @@ Options:
|
|
|
667
700
|
(backlog entry; reprint later with --resume <id>)
|
|
668
701
|
--id <id> Override the derived change id
|
|
669
702
|
--resume <id> Reprint the playbook for an open change; create nothing
|
|
703
|
+
--delivery <id> Stamp the change with the workspace delivery it is part
|
|
704
|
+
of (ADR 0030); with --resume, join an open change to it
|
|
670
705
|
--from-diff Backfill: scaffold from the working-tree changes (no
|
|
671
706
|
prompt needed) and print a code-first playbook (F8)
|
|
672
707
|
--consolidate <cap> Open a change that consolidates <cap>'s spec — merge
|