doctrina-cli 0.21.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 +6 -6
- package/package.json +1 -1
- package/src/commands/change.js +111 -14
- package/src/commands/clarify.js +2 -2
- package/src/commands/close.js +1 -1
- package/src/commands/coverage.js +69 -6
- package/src/commands/doctor.js +62 -4
- package/src/commands/hooks.js +56 -11
- package/src/commands/index-rebuild.js +47 -1
- package/src/commands/init.js +51 -7
- package/src/commands/metrics.js +1 -1
- package/src/commands/onboard.js +113 -0
- package/src/commands/review.js +1 -1
- package/src/commands/search.js +2 -2
- 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 +46 -6
- package/src/commands/verify.js +3 -3
- package/src/commands/why.js +1 -1
- package/src/commands/work.js +53 -40
- package/src/commands/workspace.js +16 -3
- package/src/index.js +9 -0
- package/src/lib/actions.js +19 -0
- package/src/lib/adapters.js +23 -3
- package/src/lib/agent-bridge.js +400 -0
- package/src/lib/applied-deltas.js +82 -0
- package/src/lib/args.js +1 -1
- package/src/lib/change-numbers.js +115 -0
- package/src/lib/change-ops.js +2 -2
- package/src/lib/clarify-scan.js +3 -3
- package/src/lib/code-inventory.js +22 -5
- package/src/lib/commands.js +50 -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/coverage-model.js +29 -6
- package/src/lib/delivery.js +2 -2
- package/src/lib/docs-impact.js +1 -1
- package/src/lib/fs-ops.js +80 -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 +4 -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/spec-ops.js +2 -2
- package/src/lib/task-card.js +1 -1
- 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 +2 -1
- package/src/lib/workspace-agents.js +2 -2
- package/src/lib/workspace-tools.js +41 -1
- package/src/lib/workspace.js +1 -1
- package/templates/AGENTS.md.template +8 -4
- package/templates/README.md +2 -1
- 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/skills/doctrina-onboard/SKILL.md.template +18 -0
- package/templates/adapters/kiro/README.md +22 -0
package/src/commands/work.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import { readdirSync } from "node:fs";
|
|
5
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";
|
|
@@ -10,17 +9,18 @@ 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
11
|
import { readDelivery, resolveDeliveryId, sectionText } from "../lib/delivery.js";
|
|
13
|
-
import { parseLedger } from "../lib/ledger.js";
|
|
14
12
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
15
13
|
import { listHeader } from "../lib/scan.js";
|
|
16
14
|
import { changeNew, reindexChange, addAffectedSpec } from "../lib/change-ops.js";
|
|
17
|
-
import { classify } from "../lib/triage-model.js";
|
|
15
|
+
import { classify, directSignals, trackLines } from "../lib/triage-model.js";
|
|
18
16
|
import { printPlaybookTemplate } from "../lib/playbook.js";
|
|
19
17
|
import { specShape } from "../lib/spec-ops.js";
|
|
20
18
|
import { changedFiles } from "../lib/git.js";
|
|
21
19
|
import { slugFromPrompt, fold, CONFIDENT_MARGIN } from "../lib/lexicon.js";
|
|
22
20
|
import { rankCapabilities, rankCapabilitiesByDiff, similarChanges, SIMILAR_CHANGE_DAYS } from "../lib/work-model.js";
|
|
23
21
|
import { isChangeId, refuseChangeRef } from "../lib/project.js";
|
|
22
|
+
import { nextChangeNumber } from "../lib/change-numbers.js";
|
|
23
|
+
import { withProjectLock } from "../lib/lock.js";
|
|
24
24
|
export { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
|
|
25
25
|
|
|
26
26
|
// `work` is the second half of the no-ceremony path (ADR 0005): a brief
|
|
@@ -176,6 +176,26 @@ export async function run(positional, flags) {
|
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
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
|
+
|
|
179
199
|
const pinned = flagString(flags, "capability");
|
|
180
200
|
if (pinned && !/^[a-z][a-z0-9-]*$/.test(pinned)) {
|
|
181
201
|
console.error(c.red("error:") + ` invalid capability "${pinned}" (lowercase letters, digits, hyphens)`);
|
|
@@ -196,14 +216,19 @@ export async function run(positional, flags) {
|
|
|
196
216
|
const slug = flags.has("title")
|
|
197
217
|
? slugify(title)
|
|
198
218
|
: (prompt ? slugFromPrompt(prompt) : "backfill");
|
|
199
|
-
|
|
200
|
-
|
|
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) {
|
|
201
228
|
console.error(c.red("error:") + ` invalid change id "${id}" (lowercase letters, digits, hyphens)`);
|
|
202
229
|
return 2;
|
|
203
230
|
}
|
|
204
|
-
|
|
205
|
-
const code = changeNew([id, title], flags);
|
|
206
|
-
if (code !== 0) return code;
|
|
231
|
+
if (opened.code !== 0) return opened.code;
|
|
207
232
|
|
|
208
233
|
// The CLI reduces a prompt to an id deterministically; it cannot write a
|
|
209
234
|
// good short name, and ADR 0005 says it must not try. So it says how to get
|
|
@@ -330,7 +355,7 @@ export async function run(positional, flags) {
|
|
|
330
355
|
// see beats a missing file nothing reports.
|
|
331
356
|
//
|
|
332
357
|
// Returns what was written, or null when the file already exists.
|
|
333
|
-
function scaffoldDelta(projectRoot, { id, capability, guess = null, runnerUp = null }) {
|
|
358
|
+
function scaffoldDelta(projectRoot, { id, capability, guess = /** @type {any} */ (null), runnerUp = /** @type {any} */ (null) }) {
|
|
334
359
|
const deltaPath = path.join(projectRoot, ".doctrina", "changes", id, "specs", capability, "delta.md");
|
|
335
360
|
if (exists(deltaPath)) return null;
|
|
336
361
|
const tpl = read(path.join(locateTemplatesDir(), "change", "spec-delta.md.template"));
|
|
@@ -415,8 +440,8 @@ function writeLane(projectRoot, id, verdict, opts) {
|
|
|
415
440
|
const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
|
|
416
441
|
if (!isFile(proposalPath)) return;
|
|
417
442
|
const text = read(proposalPath);
|
|
418
|
-
// CRLF-safe:
|
|
419
|
-
// 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`
|
|
420
445
|
// skill exists for exactly this mistake.
|
|
421
446
|
const updated = text.replace(/^(-[ \t]+\*\*Lane:\*\*)[^\r\n]*/m, `$1 ${line}`);
|
|
422
447
|
if (updated !== text) write(proposalPath, updated, { force: true });
|
|
@@ -424,34 +449,6 @@ function writeLane(projectRoot, id, verdict, opts) {
|
|
|
424
449
|
|
|
425
450
|
// Next sequential NNNN across open changes and the archive, so work-driven
|
|
426
451
|
// ids stay ordered no matter how earlier changes were named.
|
|
427
|
-
function nextChangeNumber(projectRoot) {
|
|
428
|
-
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
429
|
-
let max = 0;
|
|
430
|
-
const harvest = (name) => {
|
|
431
|
-
const m = name.match(/^(\d{1,4})-/) ?? name.match(/^(\d{1,4})$/);
|
|
432
|
-
if (m) max = Math.max(max, Number(m[1]));
|
|
433
|
-
};
|
|
434
|
-
if (isDir(changesDir)) {
|
|
435
|
-
for (const entry of readdirSync(changesDir)) {
|
|
436
|
-
if (entry === "archive" || entry.startsWith(".")) continue;
|
|
437
|
-
if (isDir(path.join(changesDir, entry))) harvest(entry);
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
const archiveDir = path.join(changesDir, "archive");
|
|
441
|
-
if (isDir(archiveDir)) {
|
|
442
|
-
for (const entry of readdirSync(archiveDir)) {
|
|
443
|
-
if (!isDir(path.join(archiveDir, entry))) continue;
|
|
444
|
-
harvest(entry.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
|
|
445
|
-
}
|
|
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);
|
|
452
|
-
return String(max + 1).padStart(4, "0");
|
|
453
|
-
}
|
|
454
|
-
|
|
455
452
|
// Open (non-archived) changes from the index — the set `work --resume` and
|
|
456
453
|
// the thin-prompt guard offer to continue.
|
|
457
454
|
function openChanges(projectRoot) {
|
|
@@ -591,7 +588,7 @@ function printChorePlaybook(projectRoot, { id, prompt }) {
|
|
|
591
588
|
printPlaybookTemplate(projectRoot, "chore", { CHANGE_ID: id, PROMPT: prompt });
|
|
592
589
|
}
|
|
593
590
|
|
|
594
|
-
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[]} */ ([]) }) {
|
|
595
592
|
printPlaybookTemplate(projectRoot, "work", {
|
|
596
593
|
TITLE: fromDiff ? "Backfill playbook" : "Work playbook",
|
|
597
594
|
CHANGE_ID: id,
|
|
@@ -720,6 +717,22 @@ A request that mostly repeats a change already open, or archived in the
|
|
|
720
717
|
last 14 days, is HELD the same way (exit 3), naming it: the same work is
|
|
721
718
|
resumed, or reported as done where it is archived; --force opens it anyway.
|
|
722
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
|
+
|
|
723
736
|
A prompt that reads as a RUNTIME problem — a workflow, an env value, a run
|
|
724
737
|
that executed nothing — is HELD before anything is scaffolded, and pointed
|
|
725
738
|
at \`doctrina triage\` instead (exit 3: a precondition, not a bad prompt).
|
|
@@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process";
|
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { exists, isDir, mkdirp, relPath, write } from "../lib/fs-ops.js";
|
|
8
|
-
import { flagString } from "../lib/args.js";
|
|
8
|
+
import { flagString, flagGivenWithoutValue } from "../lib/args.js";
|
|
9
9
|
import { c } from "../lib/colors.js";
|
|
10
10
|
import { suggest } from "../lib/suggest.js";
|
|
11
11
|
import { EXIT, PreconditionError } from "../lib/exit-codes.js";
|
|
@@ -29,7 +29,7 @@ const CLI_ENTRY = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
|
|
|
29
29
|
|
|
30
30
|
export const flags = {
|
|
31
31
|
boolean: ["json", "no-git", "remove"],
|
|
32
|
-
string: ["name", "mode", "role", "about", "depends-on", "agent", "path", "server", "access", "for", "purpose", "hook"],
|
|
32
|
+
string: ["name", "mode", "role", "about", "depends-on", "agent", "path", "server", "access", "for", "purpose", "only", "hook"],
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
export async function run(positional, flags) {
|
|
@@ -290,11 +290,18 @@ function workspaceTool(wsDir, name, flags) {
|
|
|
290
290
|
console.log(c.green("removed") + ` tool ${name}`);
|
|
291
291
|
return EXIT.OK;
|
|
292
292
|
}
|
|
293
|
+
// `--only` with nothing after it is not "no list": taking it for one
|
|
294
|
+
// removed the list a strict tool had, with exit 0 (change 0250).
|
|
295
|
+
if (flagGivenWithoutValue(flags, "only")) {
|
|
296
|
+
console.error(c.red("error:") + " --only needs the calls it allows, e.g. --only \"getJiraIssue,search*\" — or --only none to take the list away");
|
|
297
|
+
return EXIT.USAGE;
|
|
298
|
+
}
|
|
293
299
|
let entry;
|
|
294
300
|
try {
|
|
295
301
|
entry = toolEntry(name, {
|
|
296
302
|
server: flagString(flags, "server"), access: flagString(flags, "access"),
|
|
297
303
|
for: flagString(flags, "for"), purpose: flagString(flags, "purpose"),
|
|
304
|
+
only: flags.has("only") ? flagString(flags, "only") ?? "" : undefined,
|
|
298
305
|
}, at >= 0 ? ws.tools[at] : null);
|
|
299
306
|
} catch (err) {
|
|
300
307
|
console.error(c.red("error:") + ` ${err.message}`);
|
|
@@ -304,6 +311,7 @@ function workspaceTool(wsDir, name, flags) {
|
|
|
304
311
|
else ws.tools.push(entry);
|
|
305
312
|
writeWorkspace(wsDir, ws);
|
|
306
313
|
console.log(c.green(at >= 0 ? "updated" : "declared") + ` ${entry.name} → MCP server "${entry.server}", ${entry.access === "write" ? "read/write (each write asks the human)" : "read-only"}, for ${(entry.for ?? []).join(", ") || "the planner"}`);
|
|
314
|
+
if (entry.only) console.log(c.gray(`only these calls pass through "${entry.server}": ${entry.only.join(", ")} — every other call is denied`));
|
|
307
315
|
console.log(c.gray(`the server itself is configured in your agent (Cursor: .cursor/mcp.json; Claude Code: .mcp.json) under the name "${entry.server}"`));
|
|
308
316
|
// Said now, not only at the next validate (change 0235): a key that matches
|
|
309
317
|
// no configuration is a guard that never fires.
|
|
@@ -350,6 +358,7 @@ function workspaceList(wsDir) {
|
|
|
350
358
|
console.log(c.bold("Tools"));
|
|
351
359
|
for (const t of ws.tools) {
|
|
352
360
|
console.log(` ${c.cyan(t.name.padEnd(12))} ${t.server.padEnd(12)} ${t.access.padEnd(6)} ${(t.for ?? []).join(", ").padEnd(16)} ${c.gray(t.purpose ?? "")}`);
|
|
361
|
+
if (t.only?.length) console.log(c.gray(` ${"".padEnd(12)} only: ${t.only.join(", ")}`));
|
|
353
362
|
}
|
|
354
363
|
}
|
|
355
364
|
// The orchestrator files carry what changes without a workspace write — a
|
|
@@ -394,12 +403,16 @@ artifacts of repositories that cannot carry .doctrina/ themselves.
|
|
|
394
403
|
files when a project's state or workspace.json changed since.
|
|
395
404
|
|
|
396
405
|
tool <name> [--server <mcp-name>] [--access read|write] [--for <roles>]
|
|
397
|
-
[--purpose "<what for>"] [--remove]
|
|
406
|
+
[--purpose "<what for>"] [--only "<calls>"] [--remove]
|
|
398
407
|
Declare (or update, or --remove) a tool the work uses. Presets:
|
|
399
408
|
jira, confluence, figma, playwright, datadog, databricks, sql.
|
|
400
409
|
Roles: planner, executor, qa, observer, reviewer. Access defaults
|
|
401
410
|
to read: the guard refuses a write through a read-only tool and
|
|
402
411
|
makes a declared write wait for the human.
|
|
412
|
+
--only "getJiraIssue,search*" lists the calls the tool may make,
|
|
413
|
+
by MCP tool name (\`*\` for any run of characters): every other
|
|
414
|
+
call through its server is denied, instead of being judged by the
|
|
415
|
+
verb in its name. --only none takes the list away.
|
|
403
416
|
|
|
404
417
|
guard --hook cursor|claude
|
|
405
418
|
The pre-call hook the workspace installs (Cursor beforeMCPExecution,
|
package/src/index.js
CHANGED
|
@@ -34,6 +34,8 @@ const COMMANDS = {
|
|
|
34
34
|
context: () => import("./commands/context.js"),
|
|
35
35
|
search: () => import("./commands/search.js"),
|
|
36
36
|
intake: () => import("./commands/intake.js"),
|
|
37
|
+
onboard: () => import("./commands/onboard.js"),
|
|
38
|
+
ui: () => import("./commands/ui.js"),
|
|
37
39
|
work: () => import("./commands/work.js"),
|
|
38
40
|
coverage: () => import("./commands/coverage.js"),
|
|
39
41
|
verify: () => import("./commands/verify.js"),
|
|
@@ -110,6 +112,13 @@ async function main(argv) {
|
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
if (positional.length === 0) {
|
|
115
|
+
// In a terminal, `doctrina` alone opens the console (change 0257): a
|
|
116
|
+
// person who typed the name and nothing else is asking where to start.
|
|
117
|
+
// A pipe, a script or an agent still gets the help it always got.
|
|
118
|
+
if (argv.length === 0 && process.stdin.isTTY && process.stdout.isTTY) {
|
|
119
|
+
const ui = await COMMANDS.ui();
|
|
120
|
+
return ui.run([], new Map());
|
|
121
|
+
}
|
|
113
122
|
process.stdout.write(TOP_HELP);
|
|
114
123
|
return 0;
|
|
115
124
|
}
|
package/src/lib/actions.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { conflictedArtifacts } from "./conflict-markers.js";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
4
5
|
import { isDir, isFile, read, relPath, walk } from "./fs-ops.js";
|
|
5
6
|
import * as idx from "./index-json.js";
|
|
@@ -98,6 +99,24 @@ export function computeActions(projectRoot, gates = {}) {
|
|
|
98
99
|
/** @type {Action[]} */
|
|
99
100
|
const actions = [];
|
|
100
101
|
|
|
102
|
+
// A MERGE THAT STOPPED INSIDE AN ARTIFACT comes before everything (change
|
|
103
|
+
// 0250): every action below is computed from files that hold both halves
|
|
104
|
+
// of a conflict, and the next thing an agent does with a spec is read it.
|
|
105
|
+
try {
|
|
106
|
+
const conflicted = conflictedArtifacts(projectRoot);
|
|
107
|
+
if (conflicted.length > 0) {
|
|
108
|
+
const first = path.relative(projectRoot, conflicted[0].file).split(path.sep).join("/");
|
|
109
|
+
actions.push(action({
|
|
110
|
+
id: "merge-conflict",
|
|
111
|
+
command: "validate",
|
|
112
|
+
gate: "structure",
|
|
113
|
+
runnable: false,
|
|
114
|
+
why: `${conflicted.length} artifact${conflicted.length === 1 ? " carries" : "s carry"} git's merge conflict markers (first: ${first}:${conflicted[0].line})`,
|
|
115
|
+
text: `resolve the merge conflict in ${first} (line ${conflicted[0].line})${conflicted.length > 1 ? ` and ${conflicted.length - 1} more` : ""} — keep what both branches meant and delete the marker lines; \`doctrina validate\` names each file`,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
} catch { /* an unreadable tree is reported by the checks below */ }
|
|
119
|
+
|
|
101
120
|
// RUNTIME FIRST (change 0029). A declared wiring that does not hold is
|
|
102
121
|
// not a queue item — it is the reason the last run lied. After a job goes
|
|
103
122
|
// green having executed nothing, "open a change on the observability
|
package/src/lib/adapters.js
CHANGED
|
@@ -148,9 +148,12 @@ export const RETIRED_ADAPTER_FILES = Object.freeze({
|
|
|
148
148
|
* @param {Record<string, string>} tokens as `adapter add` renders with
|
|
149
149
|
* @param {string} [root] where the files live: the shadow folder of a shadowed repository
|
|
150
150
|
*/
|
|
151
|
+
// The core loop an adapter installs as slash commands or skills.
|
|
152
|
+
const CORE_LOOP_FILE = /(^|\/)doctrina-[a-z-]+(\.md|\/SKILL\.md)$/;
|
|
153
|
+
|
|
151
154
|
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: [] };
|
|
155
|
+
/** @type {{ writes: Array<{ relativePath: string, content: string }>, removes: string[], kept: string[], added: Array<{ relativePath: string, content: string }> }} */
|
|
156
|
+
const plan = { writes: [], removes: [], kept: [], added: [] };
|
|
154
157
|
const norm = (/** @type {string} */ s) => s.replace(/\r\n/g, "\n").trim();
|
|
155
158
|
for (const [name, list] of Object.entries(RETIRED_ADAPTER_FILES)) {
|
|
156
159
|
const spec = adapterFiles(projectRoot, name);
|
|
@@ -165,6 +168,23 @@ export function planAdapterMigration(projectRoot, tokens, root = projectRoot) {
|
|
|
165
168
|
(norm(read(path.join(root, retired))) === norm(body) ? plan.removes : plan.kept).push(retired);
|
|
166
169
|
}
|
|
167
170
|
}
|
|
171
|
+
// A family the adapter installed gains the member a newer CLI ships
|
|
172
|
+
// (change 0256). `/doctrina-onboard` reached only the projects that ran
|
|
173
|
+
// `adapter add` again: the upgrade compared nothing but retired files. An
|
|
174
|
+
// adapter is read as installed by its own core-loop files, so one the
|
|
175
|
+
// project never added is never written. Not in a shadow, where every
|
|
176
|
+
// file is also a link the workspace owns.
|
|
177
|
+
if (root === projectRoot) {
|
|
178
|
+
for (const name of listAdapterNames(projectRoot)) {
|
|
179
|
+
const family = (adapterFiles(projectRoot, name)?.files ?? [])
|
|
180
|
+
.filter((f) => CORE_LOOP_FILE.test(f.relativePath));
|
|
181
|
+
if (!family.some((f) => isFile(path.join(root, f.relativePath)))) continue;
|
|
182
|
+
for (const f of family) {
|
|
183
|
+
if (isFile(path.join(root, f.relativePath)) || plan.writes.some((w) => w.relativePath === f.relativePath)) continue;
|
|
184
|
+
plan.added.push({ relativePath: f.relativePath, content: renderAdapterFile(f, tokens) });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
168
188
|
return plan;
|
|
169
189
|
}
|
|
170
190
|
|
|
@@ -175,7 +195,7 @@ export function planAdapterMigration(projectRoot, tokens, root = projectRoot) {
|
|
|
175
195
|
* @returns {Array<{ rel: string, content: string }>}
|
|
176
196
|
*/
|
|
177
197
|
export function applyAdapterMigration(root, plan) {
|
|
178
|
-
for (const w of plan.writes) {
|
|
198
|
+
for (const w of [...plan.writes, ...(plan.added ?? [])]) {
|
|
179
199
|
mkdirp(path.dirname(path.join(root, w.relativePath)));
|
|
180
200
|
write(path.join(root, w.relativePath), w.content, { force: true });
|
|
181
201
|
}
|