doctrina-cli 0.18.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 +3 -2
- package/package.json +1 -1
- package/src/commands/adapter.js +84 -0
- package/src/commands/analyze.js +14 -3
- package/src/commands/change.js +164 -17
- package/src/commands/clarify.js +1 -204
- package/src/commands/close.js +18 -1
- package/src/commands/context.js +81 -9
- package/src/commands/contract.js +1 -0
- package/src/commands/delivery.js +356 -0
- package/src/commands/doctor.js +21 -1
- package/src/commands/handoff.js +11 -2
- package/src/commands/index-rebuild.js +23 -0
- package/src/commands/init.js +28 -1
- package/src/commands/intake.js +60 -3
- package/src/commands/metrics.js +43 -3
- package/src/commands/next.js +41 -6
- package/src/commands/prime.js +125 -7
- 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 +33 -0
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +32 -4
- package/src/commands/work.js +126 -7
- package/src/commands/workspace.js +419 -0
- package/src/index.js +100 -66
- package/src/lib/actions.js +44 -4
- package/src/lib/adapters.js +143 -1
- package/src/lib/args.js +33 -1
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +770 -0
- package/src/lib/commands.js +52 -5
- package/src/lib/context-slice.js +149 -0
- package/src/lib/delivery.js +286 -0
- 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/metrics-model.js +23 -1
- package/src/lib/playbook.js +1 -1
- package/src/lib/shell.js +45 -0
- package/src/lib/skill-export.js +95 -0
- package/src/lib/snapshot.js +43 -2
- package/src/lib/task-card.js +195 -0
- package/src/lib/views.js +52 -3
- package/src/lib/work-model.js +66 -2
- package/src/lib/workspace-agents.js +306 -0
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +160 -0
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +323 -0
- package/templates/AGENTS.md.template +17 -14
- package/templates/README.md +11 -1
- package/templates/adapters/claude/.claude/settings.json.template +18 -0
- package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
- 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/delivery.md.template +39 -0
- package/templates/playbooks/absorb.md.template +63 -0
- package/templates/playbooks/bootstrap.md.template +18 -6
- package/templates/playbooks/delivery.md.template +44 -0
- package/templates/playbooks/work.md.template +1 -1
package/src/index.js
CHANGED
|
@@ -11,68 +11,73 @@ import { EXIT, exitCodeHelp } from "./lib/exit-codes.js";
|
|
|
11
11
|
import { recordUsage, operationOf } from "./lib/usage.js";
|
|
12
12
|
import { wantsJson, emitJson, captureOutput, stripAnsi, setDeprecation, deferJson, flushJson } from "./lib/json-out.js";
|
|
13
13
|
|
|
14
|
-
import * as init from "./commands/init.js";
|
|
15
|
-
import * as spec from "./commands/spec.js";
|
|
16
|
-
import * as change from "./commands/change.js";
|
|
17
|
-
import * as decision from "./commands/decision.js";
|
|
18
|
-
import * as validate from "./commands/validate.js";
|
|
19
|
-
import * as hooks from "./commands/hooks.js";
|
|
20
|
-
import * as analyze from "./commands/analyze.js";
|
|
21
|
-
import * as clarify from "./commands/clarify.js";
|
|
22
|
-
import * as templates from "./commands/templates.js";
|
|
23
|
-
import * as skill from "./commands/skill.js";
|
|
24
|
-
import * as indexCmd from "./commands/index-rebuild.js";
|
|
25
|
-
import * as next from "./commands/next.js";
|
|
26
|
-
import * as metrics from "./commands/metrics.js";
|
|
27
|
-
import * as context from "./commands/context.js";
|
|
28
|
-
import * as search from "./commands/search.js";
|
|
29
|
-
import * as intake from "./commands/intake.js";
|
|
30
|
-
import * as work from "./commands/work.js";
|
|
31
|
-
import * as coverage from "./commands/coverage.js";
|
|
32
|
-
import * as verify from "./commands/verify.js";
|
|
33
|
-
import * as contract from "./commands/contract.js";
|
|
34
|
-
import * as trace from "./commands/trace.js";
|
|
35
|
-
import * as status from "./commands/status.js";
|
|
36
|
-
import * as close from "./commands/close.js";
|
|
37
|
-
import * as review from "./commands/review.js";
|
|
38
|
-
import * as watch from "./commands/watch.js";
|
|
39
|
-
import * as why from "./commands/why.js";
|
|
40
|
-
import * as prime from "./commands/prime.js";
|
|
41
|
-
import * as handoff from "./commands/handoff.js";
|
|
42
|
-
import * as show from "./commands/show.js";
|
|
43
|
-
import * as doctor from "./commands/doctor.js";
|
|
44
|
-
import * as report from "./commands/report.js";
|
|
45
|
-
import * as completion from "./commands/completion.js";
|
|
46
|
-
import * as intent from "./commands/intent.js";
|
|
47
|
-
import * as upgrade from "./commands/upgrade.js";
|
|
48
|
-
import * as adapter from "./commands/adapter.js";
|
|
49
|
-
import * as triage from "./commands/triage.js";
|
|
50
|
-
import * as ci from "./commands/ci.js";
|
|
51
14
|
|
|
15
|
+
// Each command is loaded when it is the one invoked (fifth audit, change
|
|
16
|
+
// 0236): the 39 static imports cost ~150 ms before any command ran, and
|
|
17
|
+
// `workspace guard` runs on EVERY MCP call an agent makes — a Playwright
|
|
18
|
+
// session's hundred clicks paid it a hundred times.
|
|
19
|
+
/** @type {Record<string, () => Promise<any>>} */
|
|
52
20
|
const COMMANDS = {
|
|
53
|
-
init
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
21
|
+
init: () => import("./commands/init.js"),
|
|
22
|
+
spec: () => import("./commands/spec.js"),
|
|
23
|
+
change: () => import("./commands/change.js"),
|
|
24
|
+
decision: () => import("./commands/decision.js"),
|
|
25
|
+
validate: () => import("./commands/validate.js"),
|
|
26
|
+
hooks: () => import("./commands/hooks.js"),
|
|
27
|
+
analyze: () => import("./commands/analyze.js"),
|
|
28
|
+
clarify: () => import("./commands/clarify.js"),
|
|
29
|
+
templates: () => import("./commands/templates.js"),
|
|
30
|
+
skill: () => import("./commands/skill.js"),
|
|
31
|
+
index: () => import("./commands/index-rebuild.js"),
|
|
32
|
+
next: () => import("./commands/next.js"),
|
|
33
|
+
metrics: () => import("./commands/metrics.js"),
|
|
34
|
+
context: () => import("./commands/context.js"),
|
|
35
|
+
search: () => import("./commands/search.js"),
|
|
36
|
+
intake: () => import("./commands/intake.js"),
|
|
37
|
+
work: () => import("./commands/work.js"),
|
|
38
|
+
coverage: () => import("./commands/coverage.js"),
|
|
39
|
+
verify: () => import("./commands/verify.js"),
|
|
40
|
+
contract: () => import("./commands/contract.js"),
|
|
41
|
+
trace: () => import("./commands/trace.js"),
|
|
42
|
+
status: () => import("./commands/status.js"),
|
|
43
|
+
close: () => import("./commands/close.js"),
|
|
44
|
+
review: () => import("./commands/review.js"),
|
|
45
|
+
watch: () => import("./commands/watch.js"),
|
|
46
|
+
why: () => import("./commands/why.js"),
|
|
47
|
+
prime: () => import("./commands/prime.js"),
|
|
48
|
+
handoff: () => import("./commands/handoff.js"),
|
|
49
|
+
show: () => import("./commands/show.js"),
|
|
50
|
+
doctor: () => import("./commands/doctor.js"),
|
|
51
|
+
report: () => import("./commands/report.js"),
|
|
52
|
+
completion: () => import("./commands/completion.js"),
|
|
53
|
+
intent: () => import("./commands/intent.js"),
|
|
54
|
+
upgrade: () => import("./commands/upgrade.js"),
|
|
55
|
+
adapter: () => import("./commands/adapter.js"),
|
|
56
|
+
triage: () => import("./commands/triage.js"),
|
|
57
|
+
ci: () => import("./commands/ci.js"),
|
|
58
|
+
workspace: () => import("./commands/workspace.js"),
|
|
59
|
+
delivery: () => import("./commands/delivery.js"),
|
|
59
60
|
};
|
|
60
61
|
|
|
61
|
-
const TOP_HELP = `
|
|
62
|
-
Usage: doctrina <command> [options] (\`doctrina <command> --help\` for its flags)
|
|
62
|
+
const TOP_HELP = `Usage: doctrina <command> [options] (\`doctrina <command> --help\` for its flags)
|
|
63
63
|
|
|
64
64
|
${surfaceHelp()}
|
|
65
65
|
|
|
66
|
-
Global flags:
|
|
67
|
-
--help, -h Show this message (or per-command help if after a command)
|
|
68
|
-
--version, -v Print the version
|
|
69
|
-
--debug On an unexpected error, also print the stack trace
|
|
70
|
-
|
|
66
|
+
Global flags: --help/-h (this, or a command's own) · --version/-v · --debug (stack traces)
|
|
71
67
|
Exit codes (a contract — an agent reads these to decide what to do next):
|
|
72
68
|
${exitCodeHelp()}
|
|
73
69
|
`;
|
|
74
70
|
|
|
75
71
|
async function main(argv) {
|
|
72
|
+
// The MCP guard's own door (fifth audit, change 0236): it answers before
|
|
73
|
+
// every tool call an agent makes, so it loads the guard and its registry
|
|
74
|
+
// reader only. `--help` and `--json` take the ordinary path.
|
|
75
|
+
if (argv[0] === "workspace" && argv[1] === "guard" && !argv.some((a) => /^(--help|-h|--json|--debug)$/.test(a))) {
|
|
76
|
+
const at = argv.indexOf("--hook");
|
|
77
|
+
const hook = at >= 0 ? argv[at + 1] : argv.find((a) => a.startsWith("--hook="))?.slice("--hook=".length);
|
|
78
|
+
const { runGuard } = await import("./lib/workspace-guard.js");
|
|
79
|
+
return runGuard(hook);
|
|
80
|
+
}
|
|
76
81
|
// `--version` is only "print the CLI version" when NO command is given.
|
|
77
82
|
// With a command it is that command's flag to consume — the old global
|
|
78
83
|
// intercept made `doctrina spec set x --version 0.10.0` print the CLI
|
|
@@ -91,7 +96,8 @@ async function main(argv) {
|
|
|
91
96
|
// Declarations now live with the command, so a new command cannot
|
|
92
97
|
// reintroduce the gap by forgetting to edit this file.
|
|
93
98
|
const bootstrap = parseArgs(argv, { boolean: [...GLOBAL_FLAGS.boolean] });
|
|
94
|
-
const
|
|
99
|
+
const loader = Object.hasOwn(COMMANDS, bootstrap.positional[0] ?? "") ? COMMANDS[bootstrap.positional[0]] : null;
|
|
100
|
+
const commandModule = loader ? await loader() : null;
|
|
95
101
|
const spec = commandModule?.flags;
|
|
96
102
|
const { positional, flags } = parseArgs(argv, {
|
|
97
103
|
boolean: [...GLOBAL_FLAGS.boolean, ...(spec?.boolean ?? [])],
|
|
@@ -109,7 +115,8 @@ async function main(argv) {
|
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
const commandName = positional[0];
|
|
112
|
-
const command =
|
|
118
|
+
const command = commandName === bootstrap.positional[0] ? commandModule
|
|
119
|
+
: (Object.hasOwn(COMMANDS, commandName) ? await COMMANDS[commandName]() : null);
|
|
113
120
|
const removed = removalFor(positional);
|
|
114
121
|
if (removed) {
|
|
115
122
|
const name = removed === removalFor([commandName]) ? commandName : positional.slice(0, 2).join(" ");
|
|
@@ -209,12 +216,13 @@ async function main(argv) {
|
|
|
209
216
|
? command.jsonNative(positional.slice(1))
|
|
210
217
|
: command.jsonNative === true;
|
|
211
218
|
if (wantsJson(flags) && !jsonNative) {
|
|
212
|
-
const { code, stdout, stderr } = await captureOutput(
|
|
219
|
+
const { code, stdout, stderr, error } = await captureOutput(
|
|
213
220
|
() => command.run(positional.slice(1), flags),
|
|
214
221
|
);
|
|
215
222
|
emitJson(operationName(positional), {
|
|
216
223
|
stdout: stdout.map(stripAnsi),
|
|
217
224
|
stderr: stderr.map(stripAnsi),
|
|
225
|
+
...(error ? { error: String(error.message ?? error) } : {}),
|
|
218
226
|
}, { ok: code === EXIT.OK, exitCode: code, args: operationArgs(positional) });
|
|
219
227
|
return code;
|
|
220
228
|
}
|
|
@@ -224,27 +232,53 @@ async function main(argv) {
|
|
|
224
232
|
if (wantsJson(flags)) {
|
|
225
233
|
deferJson();
|
|
226
234
|
let code = EXIT.OK;
|
|
235
|
+
let thrown = null;
|
|
227
236
|
try {
|
|
228
237
|
code = (await command.run(positional.slice(1), flags)) ?? EXIT.OK;
|
|
229
|
-
}
|
|
230
|
-
|
|
238
|
+
} catch (err) {
|
|
239
|
+
thrown = err;
|
|
240
|
+
code = err.exitCode ?? EXIT.GATE;
|
|
241
|
+
}
|
|
242
|
+
// A native command that failed before building its payload still
|
|
243
|
+
// answers in JSON (third audit, change 0234): "not a Doctrina project"
|
|
244
|
+
// or an unknown delivery printed English only, and a loop parsing the
|
|
245
|
+
// payload read nothing at all.
|
|
246
|
+
if (!flushJson(code)) {
|
|
247
|
+
emitJson(operationName(positional), {
|
|
248
|
+
error: thrown ? String(thrown.message) : "the command stopped before it built its payload — stderr says why",
|
|
249
|
+
...(thrown?.remedy ? { remedy: thrown.remedy } : {}),
|
|
250
|
+
}, { ok: code === EXIT.OK, exitCode: code, args: operationArgs(positional) });
|
|
231
251
|
}
|
|
252
|
+
if (thrown) await reportError(thrown, flags);
|
|
232
253
|
return code;
|
|
233
254
|
}
|
|
234
255
|
return await command.run(positional.slice(1), flags);
|
|
235
256
|
} catch (err) {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
257
|
+
return await reportError(err, flags);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The human report of a thrown error, and the exit class it carries. */
|
|
262
|
+
async function reportError(err, flags) {
|
|
263
|
+
console.error(c.red("error:") + ` ${err.message}`);
|
|
264
|
+
// A clone of a shadowed repository is not a project to init (change 0234).
|
|
265
|
+
// Loaded only on this error path, like the commands (change 0236).
|
|
266
|
+
const view = /^not a Doctrina project/.test(String(err.message)) ? await import("./lib/workspace-view.js") : null;
|
|
267
|
+
const clone = view ? view.shadowedCloneAt(process.cwd()) : null;
|
|
268
|
+
if (clone) {
|
|
269
|
+
console.error(c.gray("hint: ") + (clone.same
|
|
270
|
+
? `this repository is the workspace's shadowed "${clone.name}" and its links are missing; never \`doctrina init\` here — link them again: ${c.cyan(view?.attachCommand(clone, process.cwd()))}`
|
|
271
|
+
: `this repository is the workspace's shadowed "${clone.name}" (registered at ${clone.path}); never \`doctrina init\` here — to work from this clone, link its context from the workspace: ${c.cyan(view?.attachCommand(clone, process.cwd()))}`));
|
|
272
|
+
} else if (err.remedy) {
|
|
273
|
+
console.error(c.gray("hint: ") + `run ${c.cyan(err.remedy)} first, then retry`);
|
|
274
|
+
}
|
|
275
|
+
if (flags.get("debug") && err.stack) {
|
|
276
|
+
console.error(c.gray(err.stack));
|
|
247
277
|
}
|
|
278
|
+
// A typed error carries its own class (precondition vs environment).
|
|
279
|
+
// An untyped throw is an unexpected failure of the work itself, which
|
|
280
|
+
// is the GATE class: something is wrong here, fix it and retry (C7).
|
|
281
|
+
return err.exitCode ?? EXIT.GATE;
|
|
248
282
|
}
|
|
249
283
|
|
|
250
284
|
// The OPERATION an invocation names, and the arguments it carries — the two
|
package/src/lib/actions.js
CHANGED
|
@@ -4,10 +4,13 @@ import { readdirSync } from "node:fs";
|
|
|
4
4
|
import { isDir, isFile, read, relPath, walk } from "./fs-ops.js";
|
|
5
5
|
import * as idx from "./index-json.js";
|
|
6
6
|
import { deriveIndex, indexesMatch, listHeader, specHeader } from "./scan.js";
|
|
7
|
+
import { checklistProgress } from "./doc-model.js";
|
|
7
8
|
import { collectRuntimeFindings } from "./runtime.js";
|
|
8
9
|
import { FIX_SHAPED } from "./lexicon.js";
|
|
9
10
|
import { summarize as coverageSummary } from "./coverage-model.js";
|
|
10
11
|
import { summarize as traceSummary } from "./trace-model.js";
|
|
12
|
+
import { projectWorkspaceLine } from "./workspace-view.js";
|
|
13
|
+
import { cdThen, qarg } from "./shell.js";
|
|
11
14
|
|
|
12
15
|
// What comes next, as DATA (change 0032).
|
|
13
16
|
//
|
|
@@ -151,7 +154,7 @@ export function computeActions(projectRoot, gates = {}) {
|
|
|
151
154
|
severity: "blocking",
|
|
152
155
|
why: "no capability is specced yet, so nothing states what this project must do",
|
|
153
156
|
text: 'doctrina intake --text "<what this project is>" — no capability is specced yet; ' +
|
|
154
|
-
"for an existing codebase, `doctrina work --from-diff` backfills from
|
|
157
|
+
"for an existing codebase, `doctrina intake --from-code` takes its inventory as the intake (`doctrina work --from-diff` backfills one change from code already written)",
|
|
155
158
|
}));
|
|
156
159
|
}
|
|
157
160
|
|
|
@@ -214,9 +217,19 @@ export function computeActions(projectRoot, gates = {}) {
|
|
|
214
217
|
continue;
|
|
215
218
|
}
|
|
216
219
|
const tasksPath = path.join(changesDir, id, "tasks.md");
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
+
const progress = isFile(tasksPath) ? checklistProgress(read(tasksPath)) : { total: 0, done: 0, open: [], placeholders: 0 };
|
|
221
|
+
const unchecked = progress.total - progress.done;
|
|
222
|
+
if (progress.placeholders > 0) {
|
|
223
|
+
// Scaffold boxes are not open work (third audit, change 0234): "complete 3
|
|
224
|
+
// open tasks" sent an agent to do work no one had written down.
|
|
225
|
+
actions.push(action({
|
|
226
|
+
id: "change-tasks-unwritten",
|
|
227
|
+
command: "work",
|
|
228
|
+
args: ["--resume", id],
|
|
229
|
+
why: `write the ${unchecked} open task${unchecked === 1 ? "" : "s"} in .doctrina/changes/${id}/tasks.md first — ${progress.placeholders === unchecked ? (unchecked === 1 ? "it is" : "all are") : `${progress.placeholders} are`} still the scaffold's placeholder${progress.placeholders === 1 ? "" : "s"}, and the delta with them`,
|
|
230
|
+
}));
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
220
233
|
if (unchecked > 0) {
|
|
221
234
|
// Deliberately NOT runnable, and deliberately without a command:
|
|
222
235
|
// the only thing that clears this is doing the work. `change tick`
|
|
@@ -253,6 +266,33 @@ export function computeActions(projectRoot, gates = {}) {
|
|
|
253
266
|
}
|
|
254
267
|
}
|
|
255
268
|
|
|
269
|
+
// A delivery open on this repository (second audit, change 0233). `prime`
|
|
270
|
+
// named it, `next` did not: after the close of this repository's part,
|
|
271
|
+
// "Next: doctrina next" answered with this project's own chores, and the
|
|
272
|
+
// planner lost the delivery that was still going on at the workspace.
|
|
273
|
+
let inWorkspace = null;
|
|
274
|
+
try {
|
|
275
|
+
inWorkspace = projectWorkspaceLine(projectRoot);
|
|
276
|
+
} catch {
|
|
277
|
+
inWorkspace = null;
|
|
278
|
+
}
|
|
279
|
+
for (const d of inWorkspace?.deliveries ?? []) {
|
|
280
|
+
if (d.changes.length === 0) {
|
|
281
|
+
actions.push(action({
|
|
282
|
+
id: "delivery-change-missing",
|
|
283
|
+
why: `delivery ${d.id} is routed to this repository and has no change here yet`,
|
|
284
|
+
text: `doctrina work ${qarg(d.title)} --delivery ${d.id} — delivery ${d.id} is routed to this repository and has no change here yet`,
|
|
285
|
+
}));
|
|
286
|
+
} else if (d.open === 0) {
|
|
287
|
+
actions.push(action({
|
|
288
|
+
id: "delivery-continues-at-workspace",
|
|
289
|
+
severity: "advisory",
|
|
290
|
+
why: `this repository's part of delivery ${d.id} is closed; the delivery goes on at the workspace`,
|
|
291
|
+
text: `${cdThen(inWorkspace?.dir ?? "", "doctrina next")} — this repository's part of delivery ${d.id} is closed; the delivery goes on at the workspace`,
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
256
296
|
// ADRs stuck in proposed need a human decision; accepted-but-bare ADRs are
|
|
257
297
|
// the rot the review flagged — a decision with nothing behind it (no Evidence,
|
|
258
298
|
// no Landed) is drift waiting to be superseded. Surface the proposed ones
|
package/src/lib/adapters.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
|
-
import { exists, isDir, isFile, read, relPath, walk } from "./fs-ops.js";
|
|
4
|
+
import { exists, isDir, isFile, mkdirp, pruneEmptyDirs, read, relPath, remove, walk, write } from "./fs-ops.js";
|
|
5
5
|
import { locateTemplatesDir, substitute } from "./templates.js";
|
|
6
6
|
|
|
7
7
|
// Agent adapters: the thin per-agent files that point at AGENTS.md.
|
|
@@ -127,3 +127,145 @@ export function renderAdapterFile(file, tokens) {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
export { exists };
|
|
130
|
+
|
|
131
|
+
// Files an adapter USED to write, and the file that replaced each (change
|
|
132
|
+
// 0238). Cursor's documentation no longer describes `.cursor/commands/`;
|
|
133
|
+
// Cursor 2.4 converts slash commands to skills (`/migrate-to-skills`), with
|
|
134
|
+
// `disable-model-invocation: true` to keep them explicit — which is what
|
|
135
|
+
// the cursor adapter writes now, under `.cursor/skills/`.
|
|
136
|
+
export const RETIRED_ADAPTER_FILES = Object.freeze({
|
|
137
|
+
cursor: ["context", "next", "status", "why", "work"].map((x) => ({
|
|
138
|
+
retired: `.cursor/commands/doctrina-${x}.md`,
|
|
139
|
+
replacedBy: `.cursor/skills/doctrina-${x}/SKILL.md`,
|
|
140
|
+
})),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Moving an installed adapter off its retired files: the replacement is
|
|
145
|
+
* written where it is missing; a retired file still exactly as the CLI
|
|
146
|
+
* wrote it is removed; one edited since is kept and named, never deleted.
|
|
147
|
+
* @param {string} projectRoot
|
|
148
|
+
* @param {Record<string, string>} tokens as `adapter add` renders with
|
|
149
|
+
* @param {string} [root] where the files live: the shadow folder of a shadowed repository
|
|
150
|
+
*/
|
|
151
|
+
export function planAdapterMigration(projectRoot, tokens, root = projectRoot) {
|
|
152
|
+
/** @type {{ writes: Array<{ relativePath: string, content: string }>, removes: string[], kept: string[] }} */
|
|
153
|
+
const plan = { writes: [], removes: [], kept: [] };
|
|
154
|
+
const norm = (/** @type {string} */ s) => s.replace(/\r\n/g, "\n").trim();
|
|
155
|
+
for (const [name, list] of Object.entries(RETIRED_ADAPTER_FILES)) {
|
|
156
|
+
const spec = adapterFiles(projectRoot, name);
|
|
157
|
+
if (!spec) continue;
|
|
158
|
+
for (const { retired, replacedBy } of list) {
|
|
159
|
+
if (!isFile(path.join(root, retired))) continue;
|
|
160
|
+
const file = spec.files.find((f) => f.relativePath === replacedBy);
|
|
161
|
+
if (!file) continue;
|
|
162
|
+
const rendered = renderAdapterFile(file, tokens);
|
|
163
|
+
if (!isFile(path.join(root, replacedBy))) plan.writes.push({ relativePath: replacedBy, content: rendered });
|
|
164
|
+
const body = rendered.replace(/^---\n[\s\S]*?\n---\n+/, "");
|
|
165
|
+
(norm(read(path.join(root, retired))) === norm(body) ? plan.removes : plan.kept).push(retired);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return plan;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Apply a migration plan under `root` — the project, or the shadow folder
|
|
173
|
+
* a shadowed repository's files live in. Returns the removed files with
|
|
174
|
+
* what they held, for the caller that re-links a shadow.
|
|
175
|
+
* @returns {Array<{ rel: string, content: string }>}
|
|
176
|
+
*/
|
|
177
|
+
export function applyAdapterMigration(root, plan) {
|
|
178
|
+
for (const w of plan.writes) {
|
|
179
|
+
mkdirp(path.dirname(path.join(root, w.relativePath)));
|
|
180
|
+
write(path.join(root, w.relativePath), w.content, { force: true });
|
|
181
|
+
}
|
|
182
|
+
const gone = [];
|
|
183
|
+
for (const rel of plan.removes) {
|
|
184
|
+
const at = path.join(root, rel);
|
|
185
|
+
if (!isFile(at)) continue;
|
|
186
|
+
gone.push({ rel, content: read(at) });
|
|
187
|
+
remove(at);
|
|
188
|
+
pruneEmptyDirs(path.dirname(at), root);
|
|
189
|
+
}
|
|
190
|
+
return gone;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Configuration files an adapter SHARES with the user (change 0211).
|
|
195
|
+
//
|
|
196
|
+
// A hook lives in the agent's own settings — `.claude/settings.json`,
|
|
197
|
+
// `.cursor/hooks.json` — which the project may already own: permissions,
|
|
198
|
+
// other hooks, a model choice. Writing the file whole would be the data loss
|
|
199
|
+
// `adapter add` exists to avoid, and skipping it because it exists would
|
|
200
|
+
// leave the hooks out exactly where an agent is configured. So a `.json`
|
|
201
|
+
// adapter file is MERGED: objects recurse, arrays gain the entries they do
|
|
202
|
+
// not already hold, and a scalar the user set stays theirs. Removal takes
|
|
203
|
+
// back only what the template put in.
|
|
204
|
+
|
|
205
|
+
/** Whether an adapter file is merged into a shared configuration file. */
|
|
206
|
+
export function isMergedConfig(file) {
|
|
207
|
+
return file.relativePath.endsWith(".json");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
211
|
+
|
|
212
|
+
/** `ours` merged into `theirs`; neither argument is modified. */
|
|
213
|
+
export function mergeJson(theirs, ours) {
|
|
214
|
+
if (Array.isArray(theirs) && Array.isArray(ours)) {
|
|
215
|
+
return [...theirs, ...ours.filter((o) => !theirs.some((t) => same(t, o)))];
|
|
216
|
+
}
|
|
217
|
+
if (isPlainObject(theirs) && isPlainObject(ours)) {
|
|
218
|
+
const out = { ...theirs };
|
|
219
|
+
for (const [k, v] of Object.entries(ours)) {
|
|
220
|
+
out[k] = k in theirs ? mergeJson(theirs[k], v) : v;
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
return theirs;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* `theirs` with everything `ours` contributed taken out, and the containers
|
|
229
|
+
* that leaves empty pruned. A scalar is removed only when it equals ours AND
|
|
230
|
+
* nothing of the user's remains beside it. Returns null when nothing is left.
|
|
231
|
+
*/
|
|
232
|
+
export function unmergeJson(theirs, ours) {
|
|
233
|
+
if (Array.isArray(theirs) && Array.isArray(ours)) {
|
|
234
|
+
const left = theirs.filter((t) => !ours.some((o) => same(t, o)));
|
|
235
|
+
return left.length > 0 ? left : null;
|
|
236
|
+
}
|
|
237
|
+
if (isPlainObject(theirs) && isPlainObject(ours)) {
|
|
238
|
+
const out = {};
|
|
239
|
+
for (const [k, v] of Object.entries(theirs)) {
|
|
240
|
+
if (!(k in ours) || isScalar(ours[k])) { out[k] = v; continue; }
|
|
241
|
+
const kept = unmergeJson(v, ours[k]);
|
|
242
|
+
if (kept !== null) out[k] = kept;
|
|
243
|
+
}
|
|
244
|
+
// Our scalars (a `"version": 1`) go only when they are all that is left.
|
|
245
|
+
const userKeys = Object.keys(out).filter((k) => !(k in ours && isScalar(ours[k]) && same(out[k], ours[k])));
|
|
246
|
+
return userKeys.length > 0 ? out : null;
|
|
247
|
+
}
|
|
248
|
+
return same(theirs, ours) ? null : theirs;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isPlainObject(v) {
|
|
252
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isScalar(v) {
|
|
256
|
+
return v === null || typeof v !== "object";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Whether `theirs` holds at least one array entry `ours` contributes. */
|
|
260
|
+
export function holdsAnyOf(theirs, ours) {
|
|
261
|
+
if (Array.isArray(theirs) && Array.isArray(ours)) return ours.some((o) => theirs.some((t) => same(t, o)));
|
|
262
|
+
if (isPlainObject(theirs) && isPlainObject(ours)) {
|
|
263
|
+
return Object.keys(ours).some((k) => k in theirs && holdsAnyOf(theirs[k], ours[k]));
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Whether every entry `ours` contributes is present in `theirs`. */
|
|
269
|
+
export function containsJson(theirs, ours) {
|
|
270
|
+
return same(mergeJson(theirs, ours), theirs);
|
|
271
|
+
}
|
package/src/lib/args.js
CHANGED
|
@@ -8,12 +8,25 @@
|
|
|
8
8
|
// -x=value or -x value -> string value
|
|
9
9
|
// -- -> stop flag parsing; rest is positional
|
|
10
10
|
//
|
|
11
|
-
// Anything else becomes positional. Repeated flags overwrite the previous value
|
|
11
|
+
// Anything else becomes positional. Repeated flags overwrite the previous value
|
|
12
|
+
// in `flags`; every value is still kept, in order, for `flagAll` — a command
|
|
13
|
+
// that takes a flag more than once (`change delta --op a --op b`) reads them
|
|
14
|
+
// all there instead of losing every one but the last (change 0228).
|
|
15
|
+
|
|
16
|
+
/** @type {WeakMap<Map<string, string|boolean>, Map<string, string[]>>} */
|
|
17
|
+
const EVERY_VALUE = new WeakMap();
|
|
12
18
|
|
|
13
19
|
export function parseArgs(argv, opts = {}) {
|
|
14
20
|
const booleanFlags = new Set(opts.boolean ?? []);
|
|
15
21
|
const positional = [];
|
|
22
|
+
/** @type {Map<string, string|boolean>} */
|
|
16
23
|
const flags = new Map();
|
|
24
|
+
/** @type {Map<string, string[]>} */
|
|
25
|
+
const every = new Map();
|
|
26
|
+
EVERY_VALUE.set(flags, every);
|
|
27
|
+
const keep = (/** @type {string} */ name, /** @type {string|boolean} */ value) => {
|
|
28
|
+
if (typeof value === "string") every.set(name, [...(every.get(name) ?? []), value]);
|
|
29
|
+
};
|
|
17
30
|
let i = 0;
|
|
18
31
|
let stopped = false;
|
|
19
32
|
|
|
@@ -37,12 +50,14 @@ export function parseArgs(argv, opts = {}) {
|
|
|
37
50
|
const eq = body.indexOf("=");
|
|
38
51
|
if (eq >= 0) {
|
|
39
52
|
flags.set(body.slice(0, eq), body.slice(eq + 1));
|
|
53
|
+
keep(body.slice(0, eq), body.slice(eq + 1));
|
|
40
54
|
i += 1;
|
|
41
55
|
} else if (booleanFlags.has(body) || isNextTokenFlag(argv[i + 1])) {
|
|
42
56
|
flags.set(body, true);
|
|
43
57
|
i += 1;
|
|
44
58
|
} else {
|
|
45
59
|
flags.set(body, argv[i + 1] ?? true);
|
|
60
|
+
keep(body, argv[i + 1] ?? true);
|
|
46
61
|
i += 2;
|
|
47
62
|
}
|
|
48
63
|
continue;
|
|
@@ -53,12 +68,14 @@ export function parseArgs(argv, opts = {}) {
|
|
|
53
68
|
const eq = body.indexOf("=");
|
|
54
69
|
if (eq >= 0) {
|
|
55
70
|
flags.set(body.slice(0, eq), body.slice(eq + 1));
|
|
71
|
+
keep(body.slice(0, eq), body.slice(eq + 1));
|
|
56
72
|
i += 1;
|
|
57
73
|
} else if (booleanFlags.has(body) || isNextTokenFlag(argv[i + 1])) {
|
|
58
74
|
flags.set(body, true);
|
|
59
75
|
i += 1;
|
|
60
76
|
} else {
|
|
61
77
|
flags.set(body, argv[i + 1] ?? true);
|
|
78
|
+
keep(body, argv[i + 1] ?? true);
|
|
62
79
|
i += 2;
|
|
63
80
|
}
|
|
64
81
|
continue;
|
|
@@ -91,6 +108,21 @@ export function parsePositiveInt(raw) {
|
|
|
91
108
|
return Number.isSafeInteger(n) && n > 0 ? n : null;
|
|
92
109
|
}
|
|
93
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Every value a string flag was given, in the order given — `[]` when absent.
|
|
113
|
+
* A Map built by hand (tests, callers that never parsed argv) answers with
|
|
114
|
+
* its one value.
|
|
115
|
+
* @param {Map<string, string|boolean>} flags
|
|
116
|
+
* @param {string} name
|
|
117
|
+
* @returns {string[]}
|
|
118
|
+
*/
|
|
119
|
+
export function flagAll(flags, name) {
|
|
120
|
+
const every = EVERY_VALUE.get(flags)?.get(name);
|
|
121
|
+
if (every) return every;
|
|
122
|
+
const one = flags.get(name);
|
|
123
|
+
return typeof one === "string" ? [one] : [];
|
|
124
|
+
}
|
|
125
|
+
|
|
94
126
|
export function flagString(flags, name, fallback = undefined) {
|
|
95
127
|
const v = flags.get(name);
|
|
96
128
|
if (v === undefined || v === true) return fallback;
|