doctrina-cli 0.13.0 → 0.15.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.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -2
  3. package/src/commands/adapter.js +205 -0
  4. package/src/commands/analyze.js +120 -17
  5. package/src/commands/change.js +100 -42
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +57 -2
  8. package/src/commands/completion.js +6 -0
  9. package/src/commands/constitution.js +8 -1
  10. package/src/commands/context.js +391 -58
  11. package/src/commands/contract.js +76 -43
  12. package/src/commands/coverage.js +126 -12
  13. package/src/commands/decision.js +172 -40
  14. package/src/commands/doctor.js +92 -11
  15. package/src/commands/handoff.js +8 -1
  16. package/src/commands/hooks.js +6 -0
  17. package/src/commands/index-rebuild.js +8 -1
  18. package/src/commands/init.js +139 -6
  19. package/src/commands/intake.js +11 -2
  20. package/src/commands/intent.js +8 -1
  21. package/src/commands/metrics.js +80 -7
  22. package/src/commands/next.js +38 -3
  23. package/src/commands/prime.js +8 -1
  24. package/src/commands/report.js +8 -1
  25. package/src/commands/review.js +8 -1
  26. package/src/commands/search.js +8 -1
  27. package/src/commands/show.js +8 -1
  28. package/src/commands/skill.js +201 -8
  29. package/src/commands/spec.js +13 -6
  30. package/src/commands/status.js +14 -2
  31. package/src/commands/templates.js +193 -59
  32. package/src/commands/trace.js +15 -3
  33. package/src/commands/triage.js +248 -0
  34. package/src/commands/upgrade.js +8 -1
  35. package/src/commands/validate.js +148 -5
  36. package/src/commands/verify.js +126 -5
  37. package/src/commands/watch.js +8 -1
  38. package/src/commands/why.js +10 -24
  39. package/src/commands/work.js +44 -3
  40. package/src/index.js +61 -6
  41. package/src/lib/adapters.js +140 -0
  42. package/src/lib/adr-guard.js +1 -0
  43. package/src/lib/args.js +10 -0
  44. package/src/lib/clarity.js +1 -0
  45. package/src/lib/colors.js +1 -0
  46. package/src/lib/commands.js +252 -27
  47. package/src/lib/criteria.js +1 -0
  48. package/src/lib/dates.js +1 -0
  49. package/src/lib/diff.js +1 -0
  50. package/src/lib/doc-model.js +287 -0
  51. package/src/lib/docs-impact.js +167 -0
  52. package/src/lib/ears.js +1 -0
  53. package/src/lib/exit-codes.js +103 -0
  54. package/src/lib/flag-catalog.js +106 -0
  55. package/src/lib/fs-ops.js +1 -0
  56. package/src/lib/gates.js +123 -0
  57. package/src/lib/git.js +97 -0
  58. package/src/lib/index-json.js +14 -6
  59. package/src/lib/json-out.js +83 -0
  60. package/src/lib/pipeline.js +161 -0
  61. package/src/lib/prompt.js +18 -1
  62. package/src/lib/runtime.js +683 -0
  63. package/src/lib/scan.js +81 -33
  64. package/src/lib/spec-ops.js +94 -9
  65. package/src/lib/suggest.js +1 -0
  66. package/src/lib/templates.js +89 -0
  67. package/src/lib/usage.js +89 -0
  68. package/src/lib/version.js +1 -0
  69. package/templates/AGENTS.md.template +59 -53
  70. package/templates/contract.md.template +74 -3
  71. package/templates/spec.md.template +22 -0
@@ -0,0 +1,123 @@
1
+ // @ts-check
2
+ import { getSection } from "./doc-model.js";
3
+ import path from "node:path";
4
+ import { appendFileSync, writeFileSync } from "node:fs";
5
+ import { exists, isFile, mkdirp, read } from "./fs-ops.js";
6
+ import { today } from "./dates.js";
7
+ import { collectAnalysis } from "../commands/analyze.js";
8
+
9
+ // The gate-to-transition map (audit item C6).
10
+ //
11
+ // Each command used to decide its own preconditions. `change archive` and
12
+ // `close` checked theirs; `change apply` checked nothing. So `analyze`
13
+ // could exit 1 on a change and `change apply` would mutate it anyway and
14
+ // exit 0 — and the README flowchart tells agents to follow exactly that
15
+ // analyze → apply path. An agent could reach, through one path, a state
16
+ // another path forbids.
17
+ //
18
+ // The fix is not "add a check to apply" but "declare once which gates
19
+ // guard which transition, and have every driver consult the declaration".
20
+ // Which specific gate guards which transition matters less than the
21
+ // guarantee that the answer does not depend on how you got there.
22
+
23
+ // A gate is a named precondition returning human-readable blockers.
24
+ export const GATES = {
25
+ // Structural validity: the change's own files parse and point at real
26
+ // targets. The same checks `doctrina analyze` reports.
27
+ structure: {
28
+ label: "structure",
29
+ rerun: (id) => `doctrina analyze ${id}`,
30
+ blockers(projectRoot, changeDir) {
31
+ return collectAnalysis(projectRoot, changeDir)
32
+ .filter((r) => r.kind === "fail")
33
+ .map((r) => stripAnsi(r.line).replace(/^✗\s*/, "").trim());
34
+ },
35
+ },
36
+
37
+ // Verification: the work is claimed complete. Every checkbox in
38
+ // tasks.md (closing steps included) and in the proposal's
39
+ // "## Verification" section.
40
+ verification: {
41
+ label: "verification",
42
+ rerun: (id) => `doctrina change tick ${id}`,
43
+ blockers(projectRoot, changeDir) {
44
+ const out = [];
45
+ const countUnchecked = (s) => (s.match(/^\s*-\s*\[ \]/gm) ?? []).length;
46
+
47
+ const tasksPath = path.join(changeDir, "tasks.md");
48
+ if (isFile(tasksPath)) {
49
+ const n = countUnchecked(read(tasksPath));
50
+ if (n > 0) out.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md (closing steps count)`);
51
+ }
52
+ const proposalPath = path.join(changeDir, "proposal.md");
53
+ if (isFile(proposalPath)) {
54
+ const n = countUnchecked(getSection(read(proposalPath), "Verification"));
55
+ if (n > 0) out.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
56
+ }
57
+ return out;
58
+ },
59
+ },
60
+ };
61
+
62
+ // Which gates guard each lifecycle transition. The single declaration.
63
+ //
64
+ // `apply` requires structure: mutating specs from a change whose deltas do
65
+ // not parse is how a malformed delta reaches a spec file. This is the gate
66
+ // `analyze` reports and that `apply` used to ignore entirely (C6).
67
+ //
68
+ // `archive` requires verification, and deliberately NOT structure. The
69
+ // structure gate is a PRE-APPLY question — "is this safe to apply?" — and
70
+ // one of its checks is that an ADDED delta's target does not already hold
71
+ // real content. After a successful apply that target holds exactly the
72
+ // content the delta just wrote, so re-asking at archive time reports a
73
+ // conflict that is the proof the apply worked. A gate must be asked at the
74
+ // point its question is meaningful.
75
+ export const TRANSITIONS = {
76
+ apply: { label: "change apply", gates: ["structure"] },
77
+ archive: { label: "change archive", gates: ["verification"] },
78
+ };
79
+
80
+ // Evaluate a transition's gates. Returns { ok, blockers, gates } where
81
+ // blockers is a flat list of human-readable reasons.
82
+ export function checkTransition(projectRoot, changeDir, transition) {
83
+ const t = TRANSITIONS[transition];
84
+ if (!t) throw new Error(`unknown transition "${transition}"`);
85
+ const blockers = [];
86
+ for (const name of t.gates) {
87
+ for (const b of GATES[name].blockers(projectRoot, changeDir)) {
88
+ blockers.push({ gate: name, message: b });
89
+ }
90
+ }
91
+ return { ok: blockers.length === 0, blockers, gates: t.gates };
92
+ }
93
+
94
+ // Record a forced transition in the archive ledger, so a gap that was
95
+ // waved through is part of the visible history rather than nothing at all
96
+ // — the same posture `change archive --force` and `change abandon` take.
97
+ export function recordForcedGap(projectRoot, id, transition, blockers) {
98
+ const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
99
+ const ledgerPath = path.join(archiveDir, "LEDGER.md");
100
+ // Create the ledger if this is the first entry. A forced transition can
101
+ // happen before any change has ever been archived, and a gap that goes
102
+ // unrecorded because the file did not exist yet is exactly the silence
103
+ // this is meant to break.
104
+ if (!exists(ledgerPath)) {
105
+ mkdirp(archiveDir);
106
+ writeFileSync(ledgerPath,
107
+ "# Change ledger\n\n" +
108
+ "One line per archived change, newest last. Appended by\n" +
109
+ "`doctrina change archive`; edit freely, the CLI only appends.\n\n");
110
+ }
111
+ const summary = blockers.map((b) => `${b.gate}: ${b.message}`).join("; ");
112
+ appendFileSync(
113
+ ledgerPath,
114
+ ` - ${today()} — ${id} — forced ${transition} past ${blockers.length} ` +
115
+ `blocker${blockers.length === 1 ? "" : "s"} (${summary})\n`,
116
+ );
117
+ return true;
118
+ }
119
+
120
+ function stripAnsi(s) {
121
+ // eslint-disable-next-line no-control-regex
122
+ return String(s).replace(/\[[0-9;]*m/g, "");
123
+ }
package/src/lib/git.js ADDED
@@ -0,0 +1,97 @@
1
+ // @ts-check
2
+ import { spawnSync } from "node:child_process";
3
+
4
+ // One place that knows how to ask git a question and how to interpret not
5
+ // getting an answer (audit item C8).
6
+ //
7
+ // Nine call sites each ran `spawnSync("git", ...)` and decided for
8
+ // themselves what a non-zero status meant. `metrics` decided it meant
9
+ // failure, so on a brand-new project — the most common state in which
10
+ // someone explores the CLI — it printed:
11
+ //
12
+ // error: git log failed: fatal: your current branch 'master' does not
13
+ // have any commits yet
14
+ //
15
+ // A repository with no commits is a valid state, not an error. So is a
16
+ // directory that is not a repository at all. What is genuinely an error
17
+ // is git being absent when a command cannot work without it — and that is
18
+ // an ENVIRONMENT condition (exit 4), not a gate failure.
19
+
20
+ export const GIT_STATE = Object.freeze({
21
+ /** git ran and answered. */
22
+ OK: "ok",
23
+ /** A repository with no commits yet — a valid first-run state. */
24
+ EMPTY: "empty",
25
+ /** Not a git repository. */
26
+ NOT_A_REPO: "not-a-repo",
27
+ /** git is not installed or not on PATH. */
28
+ ABSENT: "absent",
29
+ /** git ran and refused, for a reason this module does not classify. */
30
+ FAILED: "failed",
31
+ });
32
+
33
+ // Run a git command. Never throws. Returns
34
+ // { state, stdout, lines, stderr, code }.
35
+ export function git(cwd, args) {
36
+ const r = spawnSync("git", args, { cwd, encoding: "utf8" });
37
+ if (r.error) {
38
+ // ENOENT here means the binary is missing, not that the command failed.
39
+ return { state: GIT_STATE.ABSENT, stdout: "", lines: [], stderr: String(r.error.message ?? ""), code: null };
40
+ }
41
+ const stderr = String(r.stderr ?? "");
42
+ if (r.status === 0) {
43
+ const stdout = String(r.stdout ?? "");
44
+ return { state: GIT_STATE.OK, stdout, lines: splitLines(stdout), stderr, code: 0 };
45
+ }
46
+ // git's own wording for the benign cases. Matched on the message because
47
+ // git reports them all with a generic non-zero status. "Needed a single
48
+ // revision" is what `rev-parse --verify HEAD` says on a repo with no
49
+ // commits, which is how the empty-history probe first fooled itself.
50
+ if (/does not have any commits yet|unknown revision or path not in the working tree|bad default revision|Needed a single revision|ambiguous argument 'HEAD'/i.test(stderr)) {
51
+ return { state: GIT_STATE.EMPTY, stdout: "", lines: [], stderr, code: r.status };
52
+ }
53
+ if (/not a git repository/i.test(stderr)) {
54
+ return { state: GIT_STATE.NOT_A_REPO, stdout: "", lines: [], stderr, code: r.status };
55
+ }
56
+ // Anything else that exited non-zero is a failure, NOT a success with an
57
+ // empty answer. Reporting it as OK is how a caller reads "no results"
58
+ // from a command that actually refused.
59
+ return { state: GIT_STATE.FAILED, stdout: "", lines: [], stderr, code: r.status };
60
+ }
61
+
62
+ // Is this a git repository at all?
63
+ export function isRepo(cwd) {
64
+ return git(cwd, ["rev-parse", "--is-inside-work-tree"]).state === GIT_STATE.OK;
65
+ }
66
+
67
+ // Does it have at least one commit? False for a fresh `git init`.
68
+ export function hasCommits(cwd) {
69
+ return git(cwd, ["rev-parse", "--verify", "HEAD"]).state === GIT_STATE.OK;
70
+ }
71
+
72
+ // The state a command should report before trying to read history:
73
+ // { usable, state, reason } where `reason` is a sentence fit to print.
74
+ export function historyState(cwd) {
75
+ const probe = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
76
+ if (probe.state === GIT_STATE.ABSENT) {
77
+ return { usable: false, state: GIT_STATE.ABSENT, reason: "git is not installed or not on PATH" };
78
+ }
79
+ if (probe.state !== GIT_STATE.OK) {
80
+ return { usable: false, state: GIT_STATE.NOT_A_REPO, reason: "this is not a git repository" };
81
+ }
82
+ if (!hasCommits(cwd)) {
83
+ return { usable: false, state: GIT_STATE.EMPTY, reason: "this repository has no commits yet" };
84
+ }
85
+ return { usable: true, state: GIT_STATE.OK, reason: "" };
86
+ }
87
+
88
+ // Lines of output, trimmed and blank-filtered — what nearly every caller
89
+ // actually wanted from stdout.
90
+ export function gitLines(cwd, args) {
91
+ const r = git(cwd, args);
92
+ return r.state === GIT_STATE.OK ? r.lines : [];
93
+ }
94
+
95
+ function splitLines(s) {
96
+ return s.split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
97
+ }
@@ -1,9 +1,22 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import { read, write, exists } from "./fs-ops.js";
3
4
  import { cliVersion } from "./version.js";
4
5
 
5
6
  export const SCHEMA_VERSION = "0.1.0";
6
7
 
8
+ // The artifact categories a well-formed index.json carries. ONE definition,
9
+ // consumed by `blank()` (what `init` writes) and by `templates check` /
10
+ // `templates update` (what a project is measured against).
11
+ //
12
+ // There used to be two: the shape `init` materialised from a template file
13
+ // and the shape `templates check` required. Nothing compared them, so when
14
+ // `contracts` was added to the expected shape and not to the template,
15
+ // every project was born needing a scaffold update (audit item C5).
16
+ export const ARTIFACT_CATEGORIES = Object.freeze([
17
+ "specs", "decisions", "changes", "changes_archive", "skills", "contracts",
18
+ ]);
19
+
7
20
  export function indexPath(projectRoot) {
8
21
  return path.join(projectRoot, ".doctrina", "index.json");
9
22
  }
@@ -46,12 +59,7 @@ export function blank(projectName, date) {
46
59
  version: "0.1.0",
47
60
  last_updated: date,
48
61
  },
49
- specs: [],
50
- decisions: [],
51
- changes: [],
52
- changes_archive: [],
53
- skills: [],
54
- contracts: [],
62
+ ...Object.fromEntries(ARTIFACT_CATEGORIES.map((cat) => [cat, []])),
55
63
  },
56
64
  };
57
65
  }
@@ -0,0 +1,83 @@
1
+ // @ts-check
2
+ import process from "node:process";
3
+ import { EXIT } from "./exit-codes.js";
4
+
5
+ // `--json` on every command, with a versioned envelope (audit item M7).
6
+ //
7
+ // Five of thirty-five commands spoke JSON. Everything else emitted
8
+ // ANSI-coloured prose, while the primary consumer is a machine. Paired with
9
+ // the exit-code contract (ADR 0018), the two form the machine interface:
10
+ // structured output plus a meaningful status. Without both, an autonomous
11
+ // loop is parsing English.
12
+ //
13
+ // Two levels of support, and the difference is stated rather than hidden:
14
+ //
15
+ // STRUCTURED — the command builds a payload describing its result. These
16
+ // are the read and report commands, where a result has real shape.
17
+ // ENVELOPE — the command's human output is captured and returned inside
18
+ // a versioned envelope alongside `ok` and `exitCode`. Branch on those;
19
+ // the lines are there for completeness, not for parsing.
20
+ //
21
+ // A command with nothing better to say is still machine-consumable, which
22
+ // is what makes "--json on every command" a claim rather than an intention.
23
+
24
+ export const JSON_SCHEMA_VERSION = "1.0.0";
25
+
26
+ export function wantsJson(flags) {
27
+ return flags?.get?.("json") === true;
28
+ }
29
+
30
+ // Print a structured payload. `command` is the invocation, `data` whatever
31
+ // that command has to say.
32
+ export function emitJson(command, data, { ok = true, exitCode = EXIT.OK } = {}) {
33
+ process.stdout.write(JSON.stringify({
34
+ $schema_version: JSON_SCHEMA_VERSION,
35
+ command,
36
+ ok,
37
+ exit_code: exitCode,
38
+ ...data,
39
+ }, null, 2) + "\n");
40
+ }
41
+
42
+ // Run `fn` with console output captured, so a command that has no
43
+ // structured payload can still answer in JSON. Returns { code, stdout,
44
+ // stderr } with the lines it would have printed.
45
+ export async function captureOutput(fn) {
46
+ const stdout = [];
47
+ const stderr = [];
48
+ const origLog = console.log;
49
+ const origError = console.error;
50
+ const origWarn = console.warn;
51
+ // Some commands write to the stream directly rather than through console
52
+ // — `completion` emits a shell script meant for redirection. Capturing
53
+ // only console.* let that output escape the envelope and corrupt the
54
+ // JSON on stdout, so the raw stream is captured too.
55
+ const origWrite = process.stdout.write.bind(process.stdout);
56
+ const pushLines = (target, chunk) => {
57
+ for (const line of String(chunk).replace(/\n$/, "").split("\n")) target.push(line);
58
+ };
59
+ console.log = (...args) => stdout.push(args.map(String).join(" "));
60
+ console.error = (...args) => stderr.push(args.map(String).join(" "));
61
+ console.warn = (...args) => stderr.push(args.map(String).join(" "));
62
+ process.stdout.write = (chunk, ...rest) => {
63
+ pushLines(stdout, chunk);
64
+ const cb = rest.find((r) => typeof r === "function");
65
+ if (cb) cb();
66
+ return true;
67
+ };
68
+ try {
69
+ const code = await fn();
70
+ return { code: code ?? EXIT.OK, stdout, stderr };
71
+ } finally {
72
+ console.log = origLog;
73
+ console.error = origError;
74
+ console.warn = origWarn;
75
+ process.stdout.write = origWrite;
76
+ }
77
+ }
78
+
79
+ // Strip ANSI so captured lines are usable as data even when colour was on.
80
+ export function stripAnsi(s) {
81
+ // eslint-disable-next-line no-control-regex
82
+ return String(s).replace(/\[[0-9;]*m/g, "");
83
+ }
@@ -0,0 +1,161 @@
1
+ // @ts-check
2
+ import { getSection } from "./doc-model.js";
3
+
4
+ // ORDERED requirements: what EARS cannot say.
5
+ //
6
+ // EARS event-driven requirements are individually true and mutually
7
+ // unordered. "When the run finishes, the system shall append a summary" and
8
+ // "When the analysis completes, the system shall publish the dashboard" both
9
+ // pass every gate, and neither says the dashboard needs the analysis to have
10
+ // run first. So "the dashboard includes this run's analysis" gets read as
11
+ // "call the renderer at the end" without anyone checking whether the thing
12
+ // it renders exists yet — and the failure is a published dashboard that is
13
+ // silently one run stale, which no structural check can see.
14
+ //
15
+ // A Pipeline block states the order and the artifact each step hands on:
16
+ //
17
+ // ### Pipeline
18
+ //
19
+ // 1. run-suite — produces `reports/results.json`
20
+ // 2. analyse — requires `reports/results.json`, produces `reports/analysis.md`
21
+ // 3. publish — requires `reports/analysis.md`
22
+ //
23
+ // The invariant is the one an ordered list cannot enforce on its own: a step
24
+ // may only require what an EARLIER step produced. Requiring something a
25
+ // later step produces is the inversion; requiring something no step produces
26
+ // is the missing producer. Both are errors, and both are exactly the bug
27
+ // that ships as "green, and a run behind".
28
+ //
29
+ // Artifacts that genuinely come from outside are marked `(external)`.
30
+
31
+ /**
32
+ * @typedef {object} PipelineStep
33
+ * @property {number} number The declared ordinal.
34
+ * @property {string} name
35
+ * @property {string[]} requires
36
+ * @property {string[]} produces
37
+ * @property {string[]} external Requirements declared as coming from outside.
38
+ * @property {number} line 1-based line within the spec.
39
+ */
40
+
41
+ /**
42
+ * Parse the `### Pipeline` block of a spec. Returns null when the spec
43
+ * declares none — the block is opt-in, so every spec written before it
44
+ * existed parses to null and is never checked.
45
+ *
46
+ * @returns {PipelineStep[] | null}
47
+ */
48
+ export function parsePipeline(text) {
49
+ const section = getSection(text, "Pipeline", { level: 3 });
50
+ if (!section || !section.trim()) return null;
51
+
52
+ const all = String(text).split(/\r?\n/);
53
+ const offset = all.findIndex((l) => /^###\s+Pipeline\s*$/.test(l));
54
+
55
+ const steps = [];
56
+ const lines = section.split(/\r?\n/);
57
+ for (let i = 0; i < lines.length; i++) {
58
+ const m = lines[i].match(/^\s*(\d+)\.\s+(.*\S)\s*$/);
59
+ if (!m) continue;
60
+ const number = Number.parseInt(m[1], 10);
61
+ const body = m[2];
62
+
63
+ // The step name is everything up to the first clause separator.
64
+ const name = body.split(/\s+[—-]\s+|\s*,\s*(?=requires|produces)/)[0].trim();
65
+
66
+ steps.push({
67
+ number,
68
+ name,
69
+ requires: clause(body, "requires").filter((a) => !a.external).map((a) => a.name),
70
+ external: clause(body, "requires").filter((a) => a.external).map((a) => a.name),
71
+ produces: clause(body, "produces").map((a) => a.name),
72
+ line: offset >= 0 ? offset + 1 + i + 1 : i + 1,
73
+ });
74
+ }
75
+ return steps;
76
+ }
77
+
78
+ // The artifacts named by one clause: `requires \`a\`, \`b\` (external)`.
79
+ // Backticks are how a spec already marks a path, so they are the anchor.
80
+ function clause(body, keyword) {
81
+ const re = new RegExp(`${keyword}\\s+([^;]*?)(?=(?:\\brequires\\b|\\bproduces\\b|$))`, "i");
82
+ const m = body.match(re);
83
+ if (!m) return [];
84
+ const out = [];
85
+ for (const artifact of m[1].matchAll(/`([^`]+)`(\s*\(external\))?/g)) {
86
+ out.push({ name: artifact[1].trim(), external: Boolean(artifact[2]) });
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * Check the ordering invariant.
93
+ * @param {PipelineStep[]} steps
94
+ * @returns {Array<{code: string, level: "error"|"warn", message: string, remedy: string, line: number}>}
95
+ */
96
+ export function checkPipeline(steps) {
97
+ /** @type {Array<{code: string, level: "error"|"warn", message: string, remedy: string, line: number}>} */
98
+ const findings = [];
99
+ if (!steps || steps.length === 0) return findings;
100
+
101
+ // Numbering first: an out-of-order list is not a pipeline, and every
102
+ // check below reads the declared order as the real one.
103
+ for (let i = 0; i < steps.length; i++) {
104
+ if (steps[i].number !== i + 1) {
105
+ findings.push({
106
+ code: "PL01",
107
+ level: "error",
108
+ message: `pipeline step ${i + 1} is numbered ${steps[i].number} ("${steps[i].name}") — the order a pipeline declares must be the order it reads in`,
109
+ remedy: "renumber the Pipeline steps 1..n in execution order",
110
+ line: steps[i].line,
111
+ });
112
+ }
113
+ }
114
+
115
+ // Everything produced at or before each index, which is what "earlier"
116
+ // means. Built as we walk, so a step can never satisfy itself.
117
+ const producedBefore = new Set();
118
+ const producedBy = new Map();
119
+ for (const step of steps) {
120
+ for (const artifact of step.produces) {
121
+ if (!producedBy.has(artifact)) producedBy.set(artifact, step);
122
+ }
123
+ }
124
+
125
+ for (const step of steps) {
126
+ for (const artifact of step.requires) {
127
+ if (producedBefore.has(artifact)) continue;
128
+
129
+ const producer = producedBy.get(artifact);
130
+ if (producer && producer.number > step.number) {
131
+ // The inversion: the consumer runs first and reads last run's file.
132
+ findings.push({
133
+ code: "PL02",
134
+ level: "error",
135
+ message: `step ${step.number} ("${step.name}") requires \`${artifact}\`, which step ${producer.number} ("${producer.name}") produces LATER — this step can only ever read the previous run's copy`,
136
+ remedy: `move "${producer.name}" before "${step.name}", or make the dependency explicit and drop the requirement`,
137
+ line: step.line,
138
+ });
139
+ } else if (producer && producer.number === step.number) {
140
+ findings.push({
141
+ code: "PL02",
142
+ level: "error",
143
+ message: `step ${step.number} ("${step.name}") both requires and produces \`${artifact}\``,
144
+ remedy: `split the step, or mark the input \`${artifact}\` (external) if it comes from outside the pipeline`,
145
+ line: step.line,
146
+ });
147
+ } else {
148
+ findings.push({
149
+ code: "PL03",
150
+ level: "error",
151
+ message: `step ${step.number} ("${step.name}") requires \`${artifact}\`, which no step produces — the step runs against a file nothing in this pipeline creates`,
152
+ remedy: `add the producing step, or mark it \`${artifact}\` (external) to declare it comes from outside`,
153
+ line: step.line,
154
+ });
155
+ }
156
+ }
157
+ for (const artifact of step.produces) producedBefore.add(artifact);
158
+ }
159
+
160
+ return findings;
161
+ }
package/src/lib/prompt.js CHANGED
@@ -1,4 +1,15 @@
1
+ // @ts-check
1
2
  import readline from "node:readline";
3
+ import process from "node:process";
4
+
5
+ // Is there a real person on the other end? Every prompt must ask this
6
+ // first. `init` checked it before the ADAPTER prompt but not before the
7
+ // project-description prompt, so a non-interactive caller that forgot
8
+ // --non-interactive got a project with a blank description and no warning
9
+ // (audit item C9).
10
+ export function isInteractive() {
11
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
12
+ }
2
13
 
3
14
  export async function ask(question, { defaultValue = "" } = {}) {
4
15
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -11,7 +22,13 @@ export async function ask(question, { defaultValue = "" } = {}) {
11
22
  }
12
23
  }
13
24
 
14
- export async function confirm(question, { defaultYes = false } = {}) {
25
+ // Ask for consent. Off a terminal there is nobody to ask, so the answer is
26
+ // `whenNonInteractive` — which callers set to false for destructive
27
+ // operations: silence is not consent.
28
+ export async function confirm(question, { defaultYes = false, whenNonInteractive = null } = {}) {
29
+ if (!isInteractive()) {
30
+ return whenNonInteractive === null ? defaultYes : whenNonInteractive;
31
+ }
15
32
  const suffix = defaultYes ? "Y/n" : "y/N";
16
33
  const ans = await ask(`${question} (${suffix})`);
17
34
  if (!ans) return defaultYes;