dw-sdlc 0.6.0 → 0.9.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 (52) hide show
  1. package/README.md +38 -5
  2. package/config/agentic.config.schema.json +67 -18
  3. package/dist/agentic-config.d.ts +28 -1
  4. package/dist/agentic-config.js +35 -5
  5. package/dist/checks/agents-map.d.ts +9 -1
  6. package/dist/checks/agents-map.js +60 -17
  7. package/dist/checks/decision-records.d.ts +5 -0
  8. package/dist/checks/decision-records.js +121 -16
  9. package/dist/checks/file-size-ratchet.js +7 -5
  10. package/dist/checks/index.js +2 -0
  11. package/dist/checks/markdown-budget.d.ts +2 -5
  12. package/dist/checks/markdown-budget.js +6 -15
  13. package/dist/checks/markdown-refs.js +1 -11
  14. package/dist/checks/routed-docs.d.ts +6 -0
  15. package/dist/checks/routed-docs.js +49 -0
  16. package/dist/checks/tree.d.ts +2 -0
  17. package/dist/checks/tree.js +13 -3
  18. package/dist/cli.js +5 -1
  19. package/dist/commands/check.d.ts +1 -1
  20. package/dist/commands/check.js +6 -4
  21. package/dist/commands/gate.d.ts +10 -4
  22. package/dist/commands/gate.js +202 -86
  23. package/dist/commands/pr-reviewed.d.ts +2 -0
  24. package/dist/commands/pr-reviewed.js +92 -0
  25. package/dist/commands/pr-ship.d.ts +2 -0
  26. package/dist/commands/pr-ship.js +183 -0
  27. package/dist/commands/references-sync.js +5 -6
  28. package/dist/drift.d.ts +17 -0
  29. package/dist/drift.js +68 -0
  30. package/dist/gate-cache.d.ts +13 -0
  31. package/dist/gate-cache.js +39 -0
  32. package/dist/git-env.d.ts +2 -0
  33. package/dist/git-env.js +10 -0
  34. package/dist/markdown.d.ts +19 -0
  35. package/dist/markdown.js +46 -2
  36. package/dist/package-version.d.ts +2 -0
  37. package/dist/package-version.js +13 -0
  38. package/dist/pr/decide.d.ts +59 -0
  39. package/dist/pr/decide.js +250 -0
  40. package/dist/pr/read.d.ts +24 -0
  41. package/dist/pr/read.js +112 -0
  42. package/dist/pr/state.d.ts +54 -0
  43. package/dist/pr/state.js +220 -0
  44. package/dist/router-block.d.ts +4 -1
  45. package/dist/router-block.js +31 -7
  46. package/dist/run-command.d.ts +24 -0
  47. package/dist/run-command.js +121 -0
  48. package/dist/setup-gate.js +3 -12
  49. package/dist/working-tree.d.ts +13 -0
  50. package/dist/working-tree.js +60 -0
  51. package/package.json +2 -2
  52. package/templates/router.md +1 -0
@@ -0,0 +1,183 @@
1
+ // `dw-sdlc pr ship [<n>]`, the Ship script: reads one pull request through gh, decides the Action it
2
+ // needs next, performs it when it is the script's and reads again, then answers the first Action that
3
+ // is not, as a line or one JSON document, with an exit code that names whose turn it is.
4
+ import { parseArgs } from "node:util";
5
+ import { repositoryRoot, resolveConfig, STRICTNESS } from "../agentic-config.js";
6
+ import { decide, devCommand, hostArgs, isScriptAction, readyToMerge, REVIEW, shellLine } from "../pr/decide.js";
7
+ import { CannotRun, gh, namedProblem, read, readAgain, said } from "../pr/read.js";
8
+ export const summary = "decide the Action one pull request needs next, as a line or JSON";
9
+ const USAGE = `Usage: dw-sdlc pr ship [<n>] [-R owner/name] [--merge] [--review full|off] [--dry-run] [--json] [--verbose]
10
+
11
+ Reads pull request <n> through gh, the one of the current branch as gh pr view resolves it when <n>
12
+ is omitted, and answers the Action it needs next: the status, the action, whose turn it is and why.
13
+ The script performs its own Actions, each at most once and then reads again: it updates a branch
14
+ behind its base, marks a draft ready once nothing else holds it back, and squash merges a pull
15
+ request ready to merge under pr.merge auto in .ai/agentic.config.json or with --merge for this call;
16
+ under ask, the default, it parks with the merge command. A head without a ${REVIEW} success
17
+ gets the Review depth --review names for this call, else pr.review: under full, the default, the
18
+ agent reviews it with the command pr.reviewCommand names, /code-review by default; under off the
19
+ script posts the success itself, its description saying why.
20
+ --dry-run performs nothing: it parks at the first Action the script would perform, listed under
21
+ performed. One line, or one document with --json; --verbose adds the state as read to the document
22
+ under facts. Needs an authenticated gh.
23
+
24
+ Exit 0 done, 10 the agent's turn, 20 wait, 30 the dev's turn, 2 when it cannot run.`;
25
+ const EXIT = { done: 0, act: 10, wait: 20, park: 30 };
26
+ const REMEDIES = { clock: "unset it: it stands in for the clock in tests" };
27
+ function parseCli(argv) {
28
+ try {
29
+ const { values, positionals } = parseArgs({
30
+ args: argv,
31
+ allowPositionals: true,
32
+ options: {
33
+ help: { type: "boolean", short: "h" },
34
+ json: { type: "boolean" },
35
+ verbose: { type: "boolean" },
36
+ merge: { type: "boolean" },
37
+ review: { type: "string" },
38
+ "dry-run": { type: "boolean" },
39
+ repo: { type: "string", short: "R" }
40
+ }
41
+ });
42
+ const [number, ...more] = positionals;
43
+ const { repo } = values;
44
+ const review = STRICTNESS.review.find((depth) => depth === values.review);
45
+ if (values.help !== true) {
46
+ if (more.length > 0) {
47
+ return { error: `name one pull request, not ${positionals.length}` };
48
+ }
49
+ const problem = namedProblem(number, repo);
50
+ if (problem !== undefined) {
51
+ return { error: problem };
52
+ }
53
+ if (values.review !== undefined && review === undefined) {
54
+ return { error: `--review takes ${STRICTNESS.review.join(" or ")}, not ${JSON.stringify(values.review)}` };
55
+ }
56
+ }
57
+ return {
58
+ help: values.help === true,
59
+ json: values.json === true,
60
+ verbose: values.verbose === true,
61
+ merge: values.merge === true,
62
+ review,
63
+ dryRun: values["dry-run"] === true,
64
+ number: number === undefined ? undefined : Number(number),
65
+ repo
66
+ };
67
+ }
68
+ catch (error) {
69
+ return { error: error instanceof Error ? error.message : String(error) };
70
+ }
71
+ }
72
+ /** The time the decision reads: DW_SDLC_NOW, an ISO 8601 time a test sets, else the system clock. */
73
+ function now() {
74
+ const given = process.env.DW_SDLC_NOW?.trim();
75
+ if (given === undefined || given === "") {
76
+ return Date.now();
77
+ }
78
+ const time = Date.parse(given);
79
+ if (Number.isNaN(time)) {
80
+ throw new CannotRun(`DW_SDLC_NOW is ${JSON.stringify(given)}, not an ISO 8601 time`, REMEDIES.clock);
81
+ }
82
+ return time;
83
+ }
84
+ /** How a reason names each script Action: what the script would do, the act, and the state it acts on. */
85
+ const NAMED = {
86
+ "update-branch": { would: "update the branch", act: "the update", still: "the branch still reads behind its base" },
87
+ "mark-ready": { would: "mark it ready", act: "the mark-ready", still: "the pull request still reads as a draft" },
88
+ "mark-reviewed": {
89
+ would: "mark it reviewed",
90
+ act: "the mark-reviewed",
91
+ still: `the head still carries no ${REVIEW} success`
92
+ },
93
+ merge: { would: "merge it", act: "the merge", still: "the pull request still reads open" }
94
+ };
95
+ /** A script Action the call leaves to the dev, `reason` saying why; a merge parks as ready-to-merge. */
96
+ function handedOver(pr, policy, answer, action, reason) {
97
+ if (action === "merge") {
98
+ return { ...readyToMerge(pr, policy, reason), blockers: answer.blockers };
99
+ }
100
+ return { ...answer, status: "park", actor: "dev", reason, command: devCommand(pr, action, policy) };
101
+ }
102
+ /**
103
+ * Reads and decides until the Action is not the script's. Each script Action runs at most once, then the
104
+ * pull request is read again; under --dry-run, or when an Action comes round again, it is the dev's.
105
+ */
106
+ function ship(cli, policy) {
107
+ let pr = read(cli, "pr ship");
108
+ const performed = [];
109
+ const refusals = {};
110
+ for (;;) {
111
+ const answer = decide(pr, now(), policy);
112
+ const { action } = answer;
113
+ if (!isScriptAction(action)) {
114
+ return { pr, answer, performed };
115
+ }
116
+ // -R names the repository, so the checkout the script runs in plays no part.
117
+ const args = hostArgs(pr, action, policy, true);
118
+ const command = shellLine(["gh", ...args]);
119
+ const { would, act, still } = NAMED[action];
120
+ if (cli.dryRun) {
121
+ performed.push({ action, command, ok: null });
122
+ return {
123
+ pr,
124
+ answer: handedOver(pr, policy, answer, action, `the script would ${would}; --dry-run performs nothing`),
125
+ performed
126
+ };
127
+ }
128
+ if (performed.some((done) => done.action === action)) {
129
+ const refusal = refusals[action];
130
+ const reason = refusal === undefined ? `${act} ran, and ${still}` : `gh refused ${act}: ${refusal}`;
131
+ return { pr, answer: handedOver(pr, policy, answer, action, reason), performed };
132
+ }
133
+ const result = gh(args);
134
+ performed.push({ action, command, ok: result.status === 0 });
135
+ if (result.status !== 0) {
136
+ refusals[action] = said(result.stderr) || `gh exited ${result.status}`;
137
+ }
138
+ pr = readAgain(pr);
139
+ }
140
+ }
141
+ /** `<status> <action>: <reason>`, the action dropped when it repeats the status, then the recheck and the command. */
142
+ function lineOf({ status, action, reason, command, recheckAfter }) {
143
+ const label = status === action ? status : `${status} ${action}`;
144
+ const recheck = recheckAfter === undefined ? "" : `, recheck in ${recheckAfter} s`;
145
+ return `${label}: ${reason}${recheck}${command === undefined ? "" : ` — ${command}`}`;
146
+ }
147
+ function documentOf(pr, answer, performed, verbose) {
148
+ const facts = verbose ? { facts: pr } : {};
149
+ return { pr: pr.url, head: pr.head, base: pr.base, ...answer, performed, ...facts };
150
+ }
151
+ export function run(argv) {
152
+ const cli = parseCli(argv);
153
+ if ("error" in cli) {
154
+ process.stderr.write(`dw-sdlc pr ship: ${cli.error}\n\n${USAGE}\n`);
155
+ return 2;
156
+ }
157
+ if (cli.help) {
158
+ process.stdout.write(`${USAGE}\n`);
159
+ return 0;
160
+ }
161
+ // An invalid config layer resolves to ask and full, like every other strictest setting.
162
+ const declared = resolveConfig(repositoryRoot(process.cwd())).config.pr;
163
+ const policy = {
164
+ merge: cli.merge ? "auto" : declared.merge,
165
+ review: cli.review === undefined ? { depth: declared.review, source: "config" } : { depth: cli.review, source: "flag" },
166
+ reviewCommand: declared.reviewCommand
167
+ };
168
+ let shipped;
169
+ try {
170
+ shipped = ship(cli, policy);
171
+ }
172
+ catch (error) {
173
+ if (error instanceof CannotRun) {
174
+ process.stderr.write(`dw-sdlc pr ship: ${error.message} — ${error.remedy}\n`);
175
+ return 2;
176
+ }
177
+ throw error;
178
+ }
179
+ const { pr, answer, performed } = shipped;
180
+ const output = cli.json ? JSON.stringify(documentOf(pr, answer, performed, cli.verbose), null, 2) : lineOf(answer);
181
+ process.stdout.write(`${output}\n`);
182
+ return EXIT[answer.status];
183
+ }
@@ -6,6 +6,7 @@ import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, renameSync,
6
6
  import { extname, join } from "node:path";
7
7
  import { setTimeout as sleep } from "node:timers/promises";
8
8
  import { parseArgs } from "node:util";
9
+ import { repositoryFreeEnv } from "../git-env.js";
9
10
  import { readPins, resolveRef } from "../pins.js";
10
11
  import { REFERENCES_FILE, readReferences } from "../references-key.js";
11
12
  import { renderBlock, withBlock } from "../router-block.js";
@@ -133,13 +134,11 @@ async function lock(root, seconds) {
133
134
  rmSync(own, { force: true });
134
135
  }
135
136
  }
136
- /** The variables that tie git to one repository, as git itself lists them. */
137
- const REPOSITORY_VARS = new Set((spawnSync("git", ["rev-parse", "--local-env-vars"], { encoding: "utf8" }).stdout ?? "").split("\n").filter(Boolean));
138
137
  function git(args, cwd) {
139
- // Each call finds its repository from `cwd`: the GIT_DIR a git hook exports would aim it at the
140
- // hook's repository. A missing credential fails the fetch instead of waiting on a prompt nobody sees.
141
- const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !REPOSITORY_VARS.has(name)));
142
- const result = spawnSync("git", args, { cwd, encoding: "utf8", env: { ...env, GIT_TERMINAL_PROMPT: "0" } });
138
+ // Each call finds its repository from `cwd`. A missing credential fails the fetch instead of waiting
139
+ // on a prompt nobody sees.
140
+ const env = { ...repositoryFreeEnv(), GIT_TERMINAL_PROMPT: "0" };
141
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", env });
143
142
  // git's error on one line, so each Reference stays one line of the report.
144
143
  const err = (result.stderr ?? "")
145
144
  .split("\n")
@@ -0,0 +1,17 @@
1
+ import type { Gate } from "./agentic-config.ts";
2
+ import type { Failure } from "./report.ts";
3
+ import { type Status } from "./run-command.ts";
4
+ /** What a Step answered, and one failure per file it created, changed or deleted. */
5
+ export type Watched<T> = {
6
+ result: T;
7
+ failures: Failure[];
8
+ };
9
+ /** Steps run under watch, and what the watch found. */
10
+ export type DriftWatch = {
11
+ /** Runs one Step by `run` between two records of the tree. */
12
+ step: <T>(command: string, run: () => Promise<T>) => Promise<Watched<T>>;
13
+ /** `fail` once a Step drifted, `error` once the tree could not be recorded, `pass` otherwise. */
14
+ status: () => Status;
15
+ };
16
+ /** A watch over the Steps of `gate` at `root`; under `gate.drift: "allow"` it only runs them. */
17
+ export declare function watchDrift(root: string, gate: Gate): DriftWatch;
package/dist/drift.js ADDED
@@ -0,0 +1,68 @@
1
+ // Drift: a file a Step creates, changes or deletes while the Gate runs. Before and after each Step the
2
+ // Gate records the tree of every file git tracks or would track, never against HEAD, so an ignored
3
+ // file never drifts and an edit made before the run sits in both records.
4
+ import { stoppedBy } from "./run-command.js";
5
+ import { git, workingTree } from "./working-tree.js";
6
+ const REMEDIES = {
7
+ drift: "commit the change, or make the Step only read",
8
+ cannotWatch: 'run the Gate in a git checkout, or set gate.drift to "allow"'
9
+ };
10
+ /** What a letter of `git diff-tree --name-status` did to the file; T is a change of type, as a link to a file. */
11
+ const CHANGES = { A: "created", D: "deleted", M: "changed", T: "changed" };
12
+ /** One failure per file that differs between the trees `before` and `after`, naming `command`. */
13
+ function drifted(root, command, before, after) {
14
+ if (before === after) {
15
+ return [];
16
+ }
17
+ const diff = git(root, ["diff-tree", "-r", "-z", "--no-renames", "--name-status", before, after]);
18
+ if ("error" in diff) {
19
+ return diff;
20
+ }
21
+ // NUL-terminated pairs: the letter, then the path, which may hold any byte but NUL.
22
+ return [...diff.out.matchAll(/([A-Z])\0([^\0]+)\0/g)].map(([, letter = "", file = ""]) => {
23
+ const message = `${JSON.stringify(command)} ${CHANGES[letter] ?? "changed"} it`;
24
+ return { file, rule: "drift", message, remedy: REMEDIES.drift };
25
+ });
26
+ }
27
+ /** A watch over the Steps of `gate` at `root`; under `gate.drift: "allow"` it only runs them. */
28
+ export function watchDrift(root, gate) {
29
+ let status = "pass";
30
+ let watching = gate.drift !== "allow";
31
+ /** The tree after the last Step, the record before the next: nothing runs between two Steps. */
32
+ let last;
33
+ /** The one failure of a tree that cannot be recorded; the watch stops there. */
34
+ const cannotWatch = (command, error) => {
35
+ watching = false;
36
+ status = "error";
37
+ const message = `cannot watch ${JSON.stringify(command)}: ${error}`;
38
+ return [{ rule: "drift", message, remedy: REMEDIES.cannotWatch }];
39
+ };
40
+ const step = async (command, run) => {
41
+ if (!watching) {
42
+ return { result: await run(), failures: [] };
43
+ }
44
+ const before = last === undefined ? workingTree(root) : { tree: last };
45
+ const result = await run();
46
+ if ("error" in before) {
47
+ return { result, failures: cannotWatch(command, before.error) };
48
+ }
49
+ if (stoppedBy() !== undefined) {
50
+ // A signal cut the Step short and ends the Gate, so nothing is compared after it.
51
+ return { result, failures: [] };
52
+ }
53
+ const after = workingTree(root);
54
+ if ("error" in after) {
55
+ return { result, failures: cannotWatch(command, after.error) };
56
+ }
57
+ last = after.tree;
58
+ const found = drifted(root, command, before.tree, after.tree);
59
+ if ("error" in found) {
60
+ return { result, failures: cannotWatch(command, found.error) };
61
+ }
62
+ if (found.length > 0) {
63
+ status = "fail";
64
+ }
65
+ return { result, failures: found };
66
+ };
67
+ return { step, status: () => status };
68
+ }
@@ -0,0 +1,13 @@
1
+ import type { Gate } from "./agentic-config.ts";
2
+ /** A checkout's fingerprint and the file its last green run is stamped in. */
3
+ export type Fingerprint = {
4
+ key: string;
5
+ stampFile: string;
6
+ };
7
+ /** The fingerprint of the checkout at `root` judged by `gate`, or why it has none. */
8
+ export declare function fingerprint(root: string, gate: Gate): Fingerprint | {
9
+ error: string;
10
+ };
11
+ /** Whether the last green run of this checkout was stamped with this fingerprint. */
12
+ export declare function isGreen({ key, stampFile }: Fingerprint): boolean;
13
+ export declare function stampGreen({ key, stampFile }: Fingerprint): void;
@@ -0,0 +1,39 @@
1
+ // The Gate's cache. A checkout's fingerprint is its working tree's content and the Gate that judges
2
+ // it. A green run stamps it in the checkout's own git dir, so each worktree keeps its own stamp.
3
+ import { createHash } from "node:crypto";
4
+ import { readFileSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { packageVersion } from "./package-version.js";
7
+ import { workingTree } from "./working-tree.js";
8
+ const STAMP_FILE = "dw-sdlc-gate.green";
9
+ /** The fingerprint of the checkout at `root` judged by `gate`, or why it has none. */
10
+ export function fingerprint(root, gate) {
11
+ const found = workingTree(root);
12
+ if ("error" in found) {
13
+ return found;
14
+ }
15
+ try {
16
+ const judge = createHash("sha256").update(JSON.stringify({ version: packageVersion(), gate }));
17
+ return { key: `${found.tree} ${judge.digest("hex")}`, stampFile: join(found.gitDir, STAMP_FILE) };
18
+ }
19
+ catch (error) {
20
+ return { error: error instanceof Error ? error.message : String(error) };
21
+ }
22
+ }
23
+ /** Whether the last green run of this checkout was stamped with this fingerprint. */
24
+ export function isGreen({ key, stampFile }) {
25
+ try {
26
+ return readFileSync(stampFile, "utf8").trim() === key;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ export function stampGreen({ key, stampFile }) {
33
+ try {
34
+ writeFileSync(stampFile, `${key}\n`);
35
+ }
36
+ catch {
37
+ // A stamp that cannot be written only means the next run does not skip.
38
+ }
39
+ }
@@ -0,0 +1,2 @@
1
+ /** This process's environment without the variables that tie git to one repository, as git lists them. */
2
+ export declare function repositoryFreeEnv(): NodeJS.ProcessEnv;
@@ -0,0 +1,10 @@
1
+ // The environment a git command runs under to find its repository from its own directory, as a git
2
+ // hook's GIT_DIR or GIT_INDEX_FILE would otherwise aim it at the hook's repository.
3
+ import { spawnSync } from "node:child_process";
4
+ let repositoryVars;
5
+ /** This process's environment without the variables that tie git to one repository, as git lists them. */
6
+ export function repositoryFreeEnv() {
7
+ repositoryVars ??= new Set((spawnSync("git", ["rev-parse", "--local-env-vars"], { encoding: "utf8" }).stdout ?? "").split("\n").filter(Boolean));
8
+ const vars = repositoryVars;
9
+ return Object.fromEntries(Object.entries(process.env).filter(([name]) => !vars.has(name)));
10
+ }
@@ -25,6 +25,25 @@ export declare function codeSpans(text: string): string[];
25
25
  export declare function prose(text: string): string;
26
26
  /** Every link target in the prose of `text`, its angle brackets dropped, with its 1-based line. */
27
27
  export declare function links(text: string): Link[];
28
+ /** A value of a frontmatter, with its 1-based line. */
29
+ export type FrontmatterValue = {
30
+ value: string;
31
+ line: number;
32
+ };
33
+ /** A top-level key of a frontmatter: the value after its colon, and the items of the block list under it. */
34
+ export type FrontmatterEntry = FrontmatterValue & {
35
+ items: FrontmatterValue[];
36
+ };
37
+ /**
38
+ * The top-level keys of the frontmatter `text` opens with, none when it opens with none. A key written
39
+ * twice is read where it first appears.
40
+ */
41
+ export declare function frontmatter(text: string): Map<string, FrontmatterEntry>;
42
+ /**
43
+ * The values a list key names: comma-separated after its colon, in `[…]` or not, or else its items. A comma
44
+ * inside a glob's `{…}` separates none, and an empty value is dropped.
45
+ */
46
+ export declare function frontmatterList(key: FrontmatterEntry | undefined): FrontmatterValue[];
28
47
  /** The anchors of each markdown file read so far, by path. */
29
48
  export type Anchors = Map<string, Set<string>>;
30
49
  /** The anchors of the headings of `path` under `root`; none when it is not a file. */
package/dist/markdown.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // The parts of a markdown file the Checks read: its ATX headings with the anchors GitHub gives them,
2
- // the rows of a table under a heading, its links, and its prose with the code masked; and the anchors
3
- // of a file on disk. A line inside a fenced code block is never any of them.
2
+ // the rows of a table under a heading, its links, its prose with the code masked and the keys of its
3
+ // frontmatter; and the anchors of a file on disk. A line inside a fenced code block is never any of them.
4
4
  import { readFileSync, statSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  /** A code fence at any indent, so one inside a list item counts. */
@@ -103,6 +103,50 @@ export function links(text) {
103
103
  return targets.map((target) => ({ target: target.replace(/^<(.*)>$/, "$1"), line: index + 1 }));
104
104
  });
105
105
  }
106
+ /** A scalar as YAML reads it: the text between its quotes, or else the text before a ` #` comment. */
107
+ function scalar(text) {
108
+ const quoted = /^\s*(["'])(.*?)\1\s*(#.*)?$/.exec(text);
109
+ return quoted?.[2] ?? text.replace(/(^|\s)#.*$/, "").trim();
110
+ }
111
+ /**
112
+ * The top-level keys of the frontmatter `text` opens with, none when it opens with none. A key written
113
+ * twice is read where it first appears.
114
+ */
115
+ export function frontmatter(text) {
116
+ const keys = new Map();
117
+ const lines = text.split("\n");
118
+ if (lines[0]?.trim() !== "---") {
119
+ return keys;
120
+ }
121
+ let current;
122
+ for (let index = 1; index < lines.length && lines[index]?.trim() !== "---"; index++) {
123
+ const line = lines[index] ?? "";
124
+ const [, name, rest = ""] = /^([\w-]+):(.*)$/.exec(line) ?? [];
125
+ const item = /^\s*-(?:\s+(.*))?$/.exec(line);
126
+ if (name !== undefined) {
127
+ current = keys.has(name) ? undefined : { value: scalar(rest), line: index + 1, items: [] };
128
+ if (current !== undefined) {
129
+ keys.set(name, current);
130
+ }
131
+ }
132
+ else if (item !== null) {
133
+ current?.items.push({ value: scalar(item[1] ?? ""), line: index + 1 });
134
+ }
135
+ }
136
+ return keys;
137
+ }
138
+ /**
139
+ * The values a list key names: comma-separated after its colon, in `[…]` or not, or else its items. A comma
140
+ * inside a glob's `{…}` separates none, and an empty value is dropped.
141
+ */
142
+ export function frontmatterList(key) {
143
+ if (key === undefined) {
144
+ return [];
145
+ }
146
+ const inline = key.value.replace(/^\[(.*)\]$/, "$1");
147
+ const values = inline === "" ? key.items : inline.split(/,(?![^{]*\})/).map((value) => ({ value: scalar(value), line: key.line }));
148
+ return values.filter(({ value }) => value !== "");
149
+ }
106
150
  /** The anchors of the headings of `path` under `root`; none when it is not a file. */
107
151
  export function anchorsOf(root, path, cache) {
108
152
  let anchors = cache.get(path);
@@ -0,0 +1,2 @@
1
+ /** This package's version; throws when its manifest names none. */
2
+ export declare function packageVersion(): string;
@@ -0,0 +1,13 @@
1
+ // The version of this package, read from its own manifest: one directory above `src/` in the source
2
+ // and above `dist/` in what npm installs.
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { parseObject } from "./json-text.js";
6
+ /** This package's version; throws when its manifest names none. */
7
+ export function packageVersion() {
8
+ const manifest = parseObject(readFileSync(join(import.meta.dirname, "..", "package.json"), "utf8"));
9
+ if (typeof manifest?.version !== "string") {
10
+ throw new Error("dw-sdlc's package.json names no version");
11
+ }
12
+ return manifest.version;
13
+ }
@@ -0,0 +1,59 @@
1
+ import type { PrMerge, PrReview } from "../agentic-config.ts";
2
+ import type { PrState } from "./state.ts";
3
+ export type Status = "act" | "wait" | "park" | "done";
4
+ export type Actor = "script" | "agent" | "dev";
5
+ export type Action = "done" | "stop" | "wait" | "fix-conflicts" | "update-branch" | "fix-ci" | "address-review" | "review" | "mark-reviewed" | "unverified" | "ci-stuck" | "awaiting-reply" | "mark-ready" | "awaiting-author" | "awaiting-approval" | "ready-to-merge" | "merge";
6
+ /** The Actions the script performs itself, each one gh command. */
7
+ export type ScriptAction = "update-branch" | "mark-ready" | "mark-reviewed" | "merge";
8
+ /** Where a Review depth comes from: the dev's `--review` for this call, or `pr.review`. */
9
+ export type DepthSource = "flag" | "config";
10
+ /**
11
+ * What the decision reads beside the pull request: who merges it, how closely a Review reads it, and
12
+ * the slash command a `full` Review runs.
13
+ */
14
+ export type Policy = {
15
+ merge: PrMerge;
16
+ review: {
17
+ depth: PrReview;
18
+ source: DepthSource;
19
+ };
20
+ reviewCommand: string;
21
+ };
22
+ /** An Action the pull request needs beside the one its answer names, so one push can fix several. */
23
+ export type Blocker = {
24
+ action: Action;
25
+ actor: Actor;
26
+ reason: string;
27
+ details?: Record<string, unknown>;
28
+ };
29
+ export type Answer = Blocker & {
30
+ status: Status;
31
+ /** A shell-quoted command, when there is one to run. */
32
+ command?: string;
33
+ /** Seconds to wait before calling again. */
34
+ recheckAfter?: number;
35
+ blockers: Blocker[];
36
+ };
37
+ /** The commit status a clean review of the head leaves on it. */
38
+ export declare const REVIEW = "dw-sdlc/review";
39
+ /** Its one state: a failure or a pending status would turn the pull request unstable. */
40
+ export declare const REVIEW_STATE = "success";
41
+ /** The words as one POSIX shell command line, each word that needs it single-quoted. */
42
+ export declare function shellLine(words: string[]): string;
43
+ /**
44
+ * The gh arguments that post the `dw-sdlc/review` success on the head, `description` saying how it was
45
+ * read. The path names the repository, so the call reads the same from any checkout.
46
+ */
47
+ export declare function reviewStatusArgs(pr: PrState, description: string): string[];
48
+ export declare function isScriptAction(action: Action): action is ScriptAction;
49
+ /**
50
+ * The gh arguments of a script Action, `-R` naming the repository when `named`. The update merges the
51
+ * base in, never --rebase, which rewrites the branch on GitHub; the merge names the head it read.
52
+ */
53
+ export declare function hostArgs(pr: PrState, action: ScriptAction, policy: Policy, named: boolean): string[];
54
+ /** The command a script Action leaves the dev to run, from a checkout of the repository. */
55
+ export declare function devCommand(pr: PrState, action: ScriptAction, policy: Policy): string;
56
+ /** The merge left to the dev, `reason` saying why it is theirs. */
57
+ export declare function readyToMerge(pr: PrState, policy: Policy, reason: string): Omit<Answer, "blockers">;
58
+ /** The Action `pr` needs at `now`, milliseconds since the epoch, under `policy`. */
59
+ export declare function decide(pr: PrState, now: number, policy: Policy): Answer;