@pknx/waterfall-cli 0.1.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/LICENSE +21 -0
- package/README.md +62 -0
- package/bin/waterfall.mjs +14 -0
- package/lib/cli/agent/agent-message.ts +71 -0
- package/lib/cli/agent/agent-translators.ts +145 -0
- package/lib/cli/agent/backend-invoke.ts +133 -0
- package/lib/cli/agent/backends.ts +100 -0
- package/lib/cli/agent/global-prompts.ts +55 -0
- package/lib/cli/commands/bug-start.ts +115 -0
- package/lib/cli/commands/comment-add.ts +47 -0
- package/lib/cli/commands/cr-all.ts +18 -0
- package/lib/cli/commands/cr-finish.ts +176 -0
- package/lib/cli/commands/cr-start.ts +105 -0
- package/lib/cli/commands/cr-to-rq.ts +18 -0
- package/lib/cli/commands/export-pdf.ts +193 -0
- package/lib/cli/commands/horizontal/horizontal.ts +232 -0
- package/lib/cli/commands/horizontal-create.ts +34 -0
- package/lib/cli/commands/horizontal-update.ts +32 -0
- package/lib/cli/commands/join-hint.ts +4 -0
- package/lib/cli/commands/registry.ts +59 -0
- package/lib/cli/commands/resolve-operator-hint.ts +120 -0
- package/lib/cli/commands/rq-all.ts +18 -0
- package/lib/cli/commands/rq-to-uc.ts +18 -0
- package/lib/cli/commands/story-close.ts +124 -0
- package/lib/cli/commands/sync-work-items.ts +59 -0
- package/lib/cli/commands/sys-start.ts +96 -0
- package/lib/cli/commands/test-all.ts +18 -0
- package/lib/cli/commands/test-to-story.ts +18 -0
- package/lib/cli/commands/types.ts +33 -0
- package/lib/cli/commands/uc-all.ts +18 -0
- package/lib/cli/commands/uc-to-story.ts +18 -0
- package/lib/cli/commands/uc-to-test.ts +18 -0
- package/lib/cli/comments/item-comments.ts +285 -0
- package/lib/cli/config/dot-waterfall.ts +404 -0
- package/lib/cli/config/global-cli.ts +21 -0
- package/lib/cli/config/sync-work-item-config.ts +34 -0
- package/lib/cli/core/cli-help-spec.ts +833 -0
- package/lib/cli/core/cli-log.ts +124 -0
- package/lib/cli/core/exec-file.ts +8 -0
- package/lib/cli/core/prompt-map.ts +64 -0
- package/lib/cli/core/slug.ts +44 -0
- package/lib/cli/entry.ts +4 -0
- package/lib/cli/export/collect-md.ts +41 -0
- package/lib/cli/export/export-items.ts +104 -0
- package/lib/cli/export/export-pdf-path.ts +88 -0
- package/lib/cli/export/merge-md.ts +37 -0
- package/lib/cli/export/mermaid-run.ts +104 -0
- package/lib/cli/export/pandoc-pdf.ts +90 -0
- package/lib/cli/export/pdf-bundled-worker.mjs +73 -0
- package/lib/cli/export/pdf-bundled.ts +36 -0
- package/lib/cli/git/cr-agent-context.ts +62 -0
- package/lib/cli/git/git-branch-guards.ts +60 -0
- package/lib/cli/git/git-cli-mock.ts +191 -0
- package/lib/cli/git/git-cli.ts +24 -0
- package/lib/cli/main.ts +434 -0
- package/lib/cli/paths.ts +9 -0
- package/lib/cli/project/pom-json.ts +55 -0
- package/lib/cli/spec/spec-init.ts +216 -0
- package/lib/cli/spec/spec-root.ts +93 -0
- package/lib/cli/sync/apply-remote-comments.ts +87 -0
- package/lib/cli/sync/attachment-category.ts +43 -0
- package/lib/cli/sync/diff-work-items.ts +113 -0
- package/lib/cli/sync/materialize-remote-bugs.ts +66 -0
- package/lib/cli/sync/provider-types.ts +43 -0
- package/lib/cli/sync/providers/direct-provider.ts +27 -0
- package/lib/cli/sync/providers/jira-provider.ts +34 -0
- package/lib/cli/sync/providers/registry.ts +26 -0
- package/lib/cli/sync/run-sync-work-items.ts +202 -0
- package/lib/cli/sync/spec-work-items.ts +226 -0
- package/lib/cli/sync/sync-hint-json.ts +163 -0
- package/lib/cli/sync/work-item-meta.ts +117 -0
- package/lib/cli/work-items/infer-bug-sys.ts +147 -0
- package/lib/cli/work-items/remote-bug-import-scaffold.ts +32 -0
- package/lib/cli/work-items/write-bug-to-spec.ts +158 -0
- package/package.json +54 -0
- package/prompts/commands/bug-start.md +46 -0
- package/prompts/commands/cr-finish.md +44 -0
- package/prompts/commands/cr-start.md +65 -0
- package/prompts/commands/cr-to-rq.md +62 -0
- package/prompts/commands/horizontal-create.md +27 -0
- package/prompts/commands/horizontal-update.md +39 -0
- package/prompts/commands/rq-to-uc.md +62 -0
- package/prompts/commands/story-close-all.md +34 -0
- package/prompts/commands/story-close.md +44 -0
- package/prompts/commands/sync-bugs-refine-imports.md +33 -0
- package/prompts/commands/sys-start.md +63 -0
- package/prompts/commands/test-to-story.md +64 -0
- package/prompts/commands/uc-to-story.md +85 -0
- package/prompts/commands/uc-to-test.md +58 -0
- package/prompts/global/before-changing-spec.md +62 -0
- package/prompts/global/content-requirements-vs-use-cases.md +116 -0
- package/prompts/global/cursor-overview.md +31 -0
- package/prompts/global/git-usage.md +46 -0
- package/prompts/global/horizontal-structure.md +75 -0
- package/prompts/global/workflows-index.md +59 -0
- package/prompts/items/bug-document-structure.md +23 -0
- package/prompts/items/cr-document-structure.md +45 -0
- package/prompts/items/rq-theme-document-structure.md +36 -0
- package/prompts/items/story-document-structure.md +49 -0
- package/prompts/items/sys-document-structure.md +36 -0
- package/prompts/items/tst-document-structure.md +55 -0
- package/prompts/items/uc-document-structure.md +38 -0
- package/spec-template/README.md +11 -0
- package/spec-template/full/doc/spec-structure.md +16 -0
- package/spec-template/full/prompts/before-changing-spec.md +7 -0
- package/spec-template/full/prompts/workflows.md +25 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { assertBranchForCrScopedCommand } from "../git/git-branch-guards";
|
|
4
|
+
import { gitExecSync } from "../git/git-cli";
|
|
5
|
+
import { assertSpecRoot } from "../spec/spec-root";
|
|
6
|
+
import { scaffoldHeadingFromDescription, slugifyDescription } from "../core/slug";
|
|
7
|
+
import { parseWorkItemCanonicalId } from "../comments/item-comments";
|
|
8
|
+
import { AGENT_GLOBAL_PROMPTS_LIFECYCLE } from "../agent/global-prompts";
|
|
9
|
+
import { resolveBugParentSysDir, writeBugToSpec } from "../work-items/write-bug-to-spec";
|
|
10
|
+
import { resolveOperatorHint } from "./resolve-operator-hint";
|
|
11
|
+
import type { WaterfallCommand } from "./types";
|
|
12
|
+
|
|
13
|
+
type NumberJson = { next: { BUG: number } };
|
|
14
|
+
|
|
15
|
+
function git(specRoot: string, args: string[]): void {
|
|
16
|
+
gitExecSync(specRoot, args, { stdio: "inherit" });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatBugPad(n: number): string {
|
|
20
|
+
return String(n).padStart(3, "0");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Scaffold BUG under `technical/SYS-…/`, bump `number.json` `next.BUG`, commit.
|
|
25
|
+
* @param hint Resolved operator hint (same rules as `resolveOperatorHint`): steers the agent and supplies title/slug for the scaffold.
|
|
26
|
+
*/
|
|
27
|
+
export function runBugStart(
|
|
28
|
+
specRoot: string,
|
|
29
|
+
sysIdRaw: string,
|
|
30
|
+
hint: string,
|
|
31
|
+
): { canonicalId: string; mdRel: string } {
|
|
32
|
+
assertSpecRoot(specRoot);
|
|
33
|
+
const { prefix, canonicalId: sysCanon } = parseWorkItemCanonicalId(sysIdRaw);
|
|
34
|
+
if (prefix !== "SYS") {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`bug start: first argument must be SYS-NNN (got "${sysIdRaw.trim()}")`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const parent = resolveBugParentSysDir(specRoot, sysCanon);
|
|
41
|
+
if (!parent) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`bug start: no folder matching ${sysCanon} under technical/ (create the subsystem with \`waterfall sys start\` first)`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const slug = slugifyDescription(hint);
|
|
48
|
+
const numPath = path.join(specRoot, "number.json");
|
|
49
|
+
const raw = JSON.parse(fs.readFileSync(numPath, "utf8")) as NumberJson;
|
|
50
|
+
const n = raw.next.BUG;
|
|
51
|
+
if (typeof n !== "number") {
|
|
52
|
+
throw new Error(
|
|
53
|
+
"bug start: number.json missing next.BUG (re-run spec init or add BUG: 1)",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const pad = formatBugPad(n);
|
|
57
|
+
const canonicalId = `BUG-${pad}`;
|
|
58
|
+
const headingTitle = scaffoldHeadingFromDescription(hint, slug);
|
|
59
|
+
|
|
60
|
+
const steeringLines = hint
|
|
61
|
+
.trim()
|
|
62
|
+
.split(/\r?\n/)
|
|
63
|
+
.map((line) => (line.length > 0 ? `> ${line}` : ">"));
|
|
64
|
+
const steeringBlock =
|
|
65
|
+
steeringLines.length > 0 ? steeringLines.join("\n") : "> _(empty)_";
|
|
66
|
+
|
|
67
|
+
const summaryBody = `*(Replace with defect summary, repro steps, expected vs actual, and links per \`prompts/items/bug-document-structure.md\`. **Synthesize** from **Draft steering (CLI)** below — remove that section when done.)*
|
|
68
|
+
|
|
69
|
+
## Draft steering (CLI)
|
|
70
|
+
|
|
71
|
+
The operator supplied this when running \`waterfall bug start\`. **Scratch input only**:
|
|
72
|
+
|
|
73
|
+
${steeringBlock}
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
const { mdRel } = writeBugToSpec({
|
|
77
|
+
specRoot,
|
|
78
|
+
canonicalId,
|
|
79
|
+
title: headingTitle,
|
|
80
|
+
state: "draft",
|
|
81
|
+
sysId: sysCanon,
|
|
82
|
+
summaryBody,
|
|
83
|
+
parentDir: parent,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
raw.next.BUG = n + 1;
|
|
87
|
+
fs.writeFileSync(numPath, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
|
|
88
|
+
|
|
89
|
+
git(specRoot, ["add", mdRel, "number.json"]);
|
|
90
|
+
git(specRoot, ["commit", "-m", `feat(${canonicalId}): scaffold bug`]);
|
|
91
|
+
|
|
92
|
+
return { canonicalId, mdRel };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const bugStartCommand: WaterfallCommand = {
|
|
96
|
+
matches: (r) => r[0] === "bug" && r[1] === "start",
|
|
97
|
+
run(ctx, rest) {
|
|
98
|
+
const [, , sysRaw, a3, ...tail] = rest;
|
|
99
|
+
if (!sysRaw?.trim()) {
|
|
100
|
+
throw new Error("bug start: SYS-NNN is required (e.g. waterfall bug start SYS-001 …)");
|
|
101
|
+
}
|
|
102
|
+
const { text } = resolveOperatorHint([a3, ...tail].filter(Boolean), {
|
|
103
|
+
required: true,
|
|
104
|
+
});
|
|
105
|
+
assertBranchForCrScopedCommand(ctx.specRoot, "waterfall bug start");
|
|
106
|
+
runBugStart(ctx.specRoot, sysRaw, text);
|
|
107
|
+
ctx.runAgentPrompt(
|
|
108
|
+
"prompts/commands/bug-start.md",
|
|
109
|
+
text,
|
|
110
|
+
AGENT_GLOBAL_PROMPTS_LIFECYCLE,
|
|
111
|
+
"waterfall bug start — refine BUG Summary",
|
|
112
|
+
);
|
|
113
|
+
return 0;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { assertBranchDevelop } from "../git/git-branch-guards";
|
|
2
|
+
import { assertSpecRoot } from "../spec/spec-root";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
ITEM_COMMENT_PREFIXES,
|
|
6
|
+
addItemComment,
|
|
7
|
+
parseWorkItemCanonicalId,
|
|
8
|
+
resolveItemCommentStorage,
|
|
9
|
+
} from "../comments/item-comments";
|
|
10
|
+
import type { WaterfallCommand } from "./types";
|
|
11
|
+
|
|
12
|
+
const LABEL = "waterfall comment add";
|
|
13
|
+
|
|
14
|
+
export const commentAddCommand: WaterfallCommand = {
|
|
15
|
+
matches: (r) => r[0] === "comment" && r[1] === "add",
|
|
16
|
+
run(ctx, rest) {
|
|
17
|
+
assertSpecRoot(ctx.specRoot);
|
|
18
|
+
assertBranchDevelop(ctx.specRoot, LABEL);
|
|
19
|
+
const item = rest[2];
|
|
20
|
+
const email = rest[3];
|
|
21
|
+
const from = rest[4];
|
|
22
|
+
const hintParts = rest.slice(5);
|
|
23
|
+
if (!item?.trim() || !email?.trim() || !from?.trim() || hintParts.length === 0) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`${LABEL}: usage: waterfall comment add <ITEM-ID> <email> <display-name> <hint…> (ITEM-ID: ${ITEM_COMMENT_PREFIXES.join(", ")}-NNN)`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const { canonicalId } = parseWorkItemCanonicalId(item);
|
|
29
|
+
const { commentsPath } = resolveItemCommentStorage(ctx.specRoot, canonicalId);
|
|
30
|
+
const hint = hintParts.join(" ").trim();
|
|
31
|
+
if (!hint) {
|
|
32
|
+
throw new Error(`${LABEL}: hint must not be empty`);
|
|
33
|
+
}
|
|
34
|
+
addItemComment({
|
|
35
|
+
commentsPath,
|
|
36
|
+
itemId: canonicalId,
|
|
37
|
+
email: email.trim(),
|
|
38
|
+
from: from.trim(),
|
|
39
|
+
hint,
|
|
40
|
+
});
|
|
41
|
+
const rel = path.relative(ctx.specRoot, commentsPath).split(path.sep).join("/");
|
|
42
|
+
process.stdout.write(
|
|
43
|
+
`[waterfall] Appended OPEN comment to ${canonicalId} → ${rel}\n`,
|
|
44
|
+
);
|
|
45
|
+
return 0;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { AGENT_GLOBAL_PROMPTS_LIFECYCLE } from "../agent/global-prompts";
|
|
2
|
+
import { ORCHESTRATE_CR_ALL_ORDER } from "../core/prompt-map";
|
|
3
|
+
import { resolveOperatorHint } from "./resolve-operator-hint";
|
|
4
|
+
import type { WaterfallCommand } from "./types";
|
|
5
|
+
|
|
6
|
+
export const crAllCommand: WaterfallCommand = {
|
|
7
|
+
matches: (r) => r[0] === "cr" && r[1] === "all",
|
|
8
|
+
run(ctx, rest) {
|
|
9
|
+
const tail = rest.slice(2);
|
|
10
|
+
ctx.runOrchestrate(
|
|
11
|
+
ORCHESTRATE_CR_ALL_ORDER,
|
|
12
|
+
"waterfall cr all",
|
|
13
|
+
resolveOperatorHint(tail, { required: false }).text,
|
|
14
|
+
AGENT_GLOBAL_PROMPTS_LIFECYCLE,
|
|
15
|
+
);
|
|
16
|
+
return 0;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { assertBranchForCrScopedCommand } from "../git/git-branch-guards";
|
|
4
|
+
import { gitExecSync } from "../git/git-cli";
|
|
5
|
+
import { assertSpecRoot } from "../spec/spec-root";
|
|
6
|
+
import type { WaterfallCommand } from "./types";
|
|
7
|
+
|
|
8
|
+
function gitInherit(specRoot: string, args: string[]): void {
|
|
9
|
+
gitExecSync(specRoot, args, { stdio: "inherit" });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function gitCapture(specRoot: string, args: string[]): string {
|
|
13
|
+
return gitExecSync(specRoot, args, {
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
16
|
+
}).toString();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Parse `001` from `feature/CR-001-slug`. */
|
|
20
|
+
export function parseCrBranchId(branch: string): string | undefined {
|
|
21
|
+
const m = branch.match(/^feature\/CR-(\d{3})(?:-.+)?$/);
|
|
22
|
+
return m?.[1];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function assertNumberJson(specRoot: string): void {
|
|
26
|
+
const p = path.join(specRoot, "number.json");
|
|
27
|
+
let raw: string;
|
|
28
|
+
try {
|
|
29
|
+
raw = fs.readFileSync(p, "utf8");
|
|
30
|
+
} catch {
|
|
31
|
+
throw new Error(`cr finish: number.json missing (${p})`);
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const o = JSON.parse(raw) as { next?: Record<string, unknown> };
|
|
35
|
+
if (!o || typeof o !== "object" || !o.next || typeof o.next !== "object") {
|
|
36
|
+
throw new Error("cr finish: number.json must contain a next object");
|
|
37
|
+
}
|
|
38
|
+
} catch (e) {
|
|
39
|
+
if (e instanceof SyntaxError) {
|
|
40
|
+
throw new Error(`cr finish: number.json is not valid JSON (${e.message})`);
|
|
41
|
+
}
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* True if `changerequests/CR-<digits>-<slug>/CR-<digits>.md` exists for this CR.
|
|
48
|
+
*/
|
|
49
|
+
export function crMarkdownPresentForFinish(specRoot: string, crDigits: string): boolean {
|
|
50
|
+
const dir = path.join(specRoot, "changerequests");
|
|
51
|
+
if (!fs.existsSync(dir)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const prefix = `CR-${crDigits}-`;
|
|
55
|
+
const innerName = `CR-${crDigits}.md`;
|
|
56
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
57
|
+
for (const ent of entries) {
|
|
58
|
+
if (!ent.isDirectory() || !ent.name.startsWith(prefix)) continue;
|
|
59
|
+
if (fs.existsSync(path.join(dir, ent.name, innerName))) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertCrMarkdownPresent(specRoot: string, crDigits: string): void {
|
|
67
|
+
if (!crMarkdownPresentForFinish(specRoot, crDigits)) {
|
|
68
|
+
const prefix = `CR-${crDigits}-`;
|
|
69
|
+
throw new Error(
|
|
70
|
+
`cr finish: no changerequests/${prefix}*/CR-${crDigits}.md for CR-${crDigits}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function assertWorkingTreeClean(specRoot: string): void {
|
|
76
|
+
const s = gitCapture(specRoot, ["status", "--porcelain"]).trim();
|
|
77
|
+
if (s.length > 0) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
"cr finish: working tree is not clean after commit; stash or commit remaining changes",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function assertAheadOfDevelop(specRoot: string): void {
|
|
85
|
+
const n = gitCapture(specRoot, ["rev-list", "--count", "develop..HEAD"]).trim();
|
|
86
|
+
const count = parseInt(n, 10);
|
|
87
|
+
if (!Number.isFinite(count) || count < 1) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
"cr finish: branch has no commits ahead of develop; nothing to integrate",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function assertNonEmptyDiffVsDevelop(specRoot: string): void {
|
|
95
|
+
const diff = gitCapture(specRoot, ["diff", "develop...HEAD"]);
|
|
96
|
+
if (diff.trim().length === 0) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
"cr finish: git diff develop...HEAD is empty (no file changes vs develop)",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function assertNoProtectedPathsInDiff(specRoot: string): void {
|
|
104
|
+
const names = gitCapture(specRoot, ["diff", "develop...HEAD", "--name-only"]);
|
|
105
|
+
const lines = names
|
|
106
|
+
.split("\n")
|
|
107
|
+
.map((l) => l.trim().replace(/\\/g, "/"))
|
|
108
|
+
.filter(Boolean);
|
|
109
|
+
const bad: string[] = [];
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
if (line === "CURSOR.md" || line.startsWith("schemas/")) {
|
|
112
|
+
bad.push(line);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (bad.length > 0) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`cr finish: diff vs develop touches ${bad.join(", ")} — revert or finish without those paths in this CR`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Mechanical finish: validate branch and spec/git invariants, commit WIP, merge into develop.
|
|
124
|
+
* Substance (complete CR text, links, triage) is validated via prompts/commands/cr-finish.md only.
|
|
125
|
+
*/
|
|
126
|
+
export function runCrFinish(specRoot: string): void {
|
|
127
|
+
assertSpecRoot(specRoot);
|
|
128
|
+
const branch = gitCapture(specRoot, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
129
|
+
if (!/^feature\/CR-\d/.test(branch)) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`cr finish: expected branch feature/CR-* on spec repo, got "${branch}"`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const crDigits = parseCrBranchId(branch);
|
|
136
|
+
if (!crDigits) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`cr finish: branch must look like feature/CR-<NNN> or feature/CR-<NNN>-<slug>, got "${branch}"`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
assertNumberJson(specRoot);
|
|
143
|
+
assertCrMarkdownPresent(specRoot, crDigits);
|
|
144
|
+
|
|
145
|
+
gitInherit(specRoot, ["add", "-A"]);
|
|
146
|
+
try {
|
|
147
|
+
gitExecSync(specRoot, ["diff", "--cached", "--quiet"], {
|
|
148
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
149
|
+
});
|
|
150
|
+
} catch {
|
|
151
|
+
gitInherit(specRoot, ["commit", "-m", `chore: finish ${branch}`]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
assertWorkingTreeClean(specRoot);
|
|
155
|
+
assertAheadOfDevelop(specRoot);
|
|
156
|
+
assertNonEmptyDiffVsDevelop(specRoot);
|
|
157
|
+
assertNoProtectedPathsInDiff(specRoot);
|
|
158
|
+
|
|
159
|
+
gitInherit(specRoot, ["checkout", "develop"]);
|
|
160
|
+
gitInherit(specRoot, [
|
|
161
|
+
"merge",
|
|
162
|
+
branch,
|
|
163
|
+
"--no-ff",
|
|
164
|
+
"-m",
|
|
165
|
+
`Merge branch '${branch}' into develop`,
|
|
166
|
+
]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const crFinishCommand: WaterfallCommand = {
|
|
170
|
+
matches: (r) => r[0] === "cr" && r[1] === "finish",
|
|
171
|
+
run(ctx) {
|
|
172
|
+
assertBranchForCrScopedCommand(ctx.specRoot, "waterfall cr finish");
|
|
173
|
+
runCrFinish(ctx.specRoot);
|
|
174
|
+
return 0;
|
|
175
|
+
},
|
|
176
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { assertBranchForCrStart } from "../git/git-branch-guards";
|
|
4
|
+
import { gitExecSync } from "../git/git-cli";
|
|
5
|
+
import { assertSpecRoot } from "../spec/spec-root";
|
|
6
|
+
import {
|
|
7
|
+
formatCrId,
|
|
8
|
+
scaffoldHeadingFromDescription,
|
|
9
|
+
slugifyDescription,
|
|
10
|
+
} from "../core/slug";
|
|
11
|
+
import { AGENT_GLOBAL_PROMPTS_LIFECYCLE } from "../agent/global-prompts";
|
|
12
|
+
import { resolveOperatorHint } from "./resolve-operator-hint";
|
|
13
|
+
import type { WaterfallCommand } from "./types";
|
|
14
|
+
|
|
15
|
+
type NumberJson = { next: { CR: number } };
|
|
16
|
+
|
|
17
|
+
function git(specRoot: string, args: string[]): void {
|
|
18
|
+
gitExecSync(specRoot, args, { stdio: "inherit" });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Implements waterfall-spec/prompts/cr-start.md on disk + git.
|
|
23
|
+
* Mutates spec repo: new branch, new CR files, number.json bump, commit.
|
|
24
|
+
*/
|
|
25
|
+
export function runCrStart(specRoot: string, description: string): void {
|
|
26
|
+
assertSpecRoot(specRoot);
|
|
27
|
+
assertBranchForCrStart(specRoot);
|
|
28
|
+
const slug = slugifyDescription(description);
|
|
29
|
+
const numPath = path.join(specRoot, "number.json");
|
|
30
|
+
const raw = JSON.parse(fs.readFileSync(numPath, "utf8")) as NumberJson;
|
|
31
|
+
const n = raw.next.CR;
|
|
32
|
+
const id = formatCrId(n);
|
|
33
|
+
const branch = `feature/CR-${id}-${slug}`;
|
|
34
|
+
const base = `CR-${id}-${slug}`;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
git(specRoot, ["pull", "--ff-only"]);
|
|
38
|
+
} catch {
|
|
39
|
+
/* optional remote */
|
|
40
|
+
}
|
|
41
|
+
git(specRoot, ["checkout", "-b", branch]);
|
|
42
|
+
|
|
43
|
+
const crDir = path.join(specRoot, "changerequests", base);
|
|
44
|
+
fs.mkdirSync(crDir, { recursive: true });
|
|
45
|
+
const mdPath = path.join(crDir, `CR-${id}.md`);
|
|
46
|
+
const headingTitle = scaffoldHeadingFromDescription(description, slug);
|
|
47
|
+
const steeringLines = description
|
|
48
|
+
.trim()
|
|
49
|
+
.split(/\r?\n/)
|
|
50
|
+
.map((line) => (line.length > 0 ? `> ${line}` : ">"));
|
|
51
|
+
const steeringBlock =
|
|
52
|
+
steeringLines.length > 0 ? steeringLines.join("\n") : "> _(empty)_";
|
|
53
|
+
|
|
54
|
+
const md = `# CR-${id} — ${headingTitle}
|
|
55
|
+
|
|
56
|
+
**Id:** CR-${id}
|
|
57
|
+
**Status:** draft
|
|
58
|
+
|
|
59
|
+
## Summary
|
|
60
|
+
|
|
61
|
+
*(Replace with a concise stakeholder summary per \`prompts/items/cr-document-structure.md\`. **Do not** treat the CLI text as final copy — synthesize product intent from **Draft steering (CLI)** below, then remove that section.)*
|
|
62
|
+
|
|
63
|
+
## Proposed change
|
|
64
|
+
|
|
65
|
+
*(Add subsections per the same structure guide — derived from draft steering, not a verbatim paste of it.)*
|
|
66
|
+
|
|
67
|
+
## Draft steering (CLI)
|
|
68
|
+
|
|
69
|
+
The operator supplied this when running \`waterfall cr start\`. **Scratch input only** — not canonical spec prose:
|
|
70
|
+
|
|
71
|
+
${steeringBlock}
|
|
72
|
+
|
|
73
|
+
## Links and dependencies
|
|
74
|
+
|
|
75
|
+
### Downstream references
|
|
76
|
+
|
|
77
|
+
- (add RQ/UC links when triaged)
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
fs.writeFileSync(mdPath, md, "utf8");
|
|
81
|
+
|
|
82
|
+
raw.next.CR = n + 1;
|
|
83
|
+
fs.writeFileSync(numPath, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
|
|
84
|
+
|
|
85
|
+
git(specRoot, ["add", `changerequests/${base}/CR-${id}.md`, "number.json"]);
|
|
86
|
+
git(specRoot, ["commit", "-m", `feat(CR-${id}): scaffold change request`]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const crStartCommand: WaterfallCommand = {
|
|
90
|
+
matches: (r) => r[0] === "cr" && r[1] === "start",
|
|
91
|
+
run(ctx, rest) {
|
|
92
|
+
const [, , a2, ...tail] = rest;
|
|
93
|
+
const { text } = resolveOperatorHint([a2, ...tail].filter(Boolean), {
|
|
94
|
+
required: true,
|
|
95
|
+
});
|
|
96
|
+
runCrStart(ctx.specRoot, text);
|
|
97
|
+
ctx.runAgentPrompt(
|
|
98
|
+
"prompts/commands/cr-start.md",
|
|
99
|
+
text,
|
|
100
|
+
AGENT_GLOBAL_PROMPTS_LIFECYCLE,
|
|
101
|
+
"waterfall cr start — refine CR prose",
|
|
102
|
+
);
|
|
103
|
+
return 0;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { assertBranchForCrScopedCommand } from "../git/git-branch-guards";
|
|
2
|
+
import { AGENT_GLOBAL_PROMPTS_LIFECYCLE } from "../agent/global-prompts";
|
|
3
|
+
import { resolveOperatorHint } from "./resolve-operator-hint";
|
|
4
|
+
import type { WaterfallCommand } from "./types";
|
|
5
|
+
|
|
6
|
+
export const crToRqCommand: WaterfallCommand = {
|
|
7
|
+
matches: (r) => r[0] === "cr" && r[1] === "to" && r[2] === "rq",
|
|
8
|
+
run(ctx, rest) {
|
|
9
|
+
assertBranchForCrScopedCommand(ctx.specRoot, "waterfall cr to rq");
|
|
10
|
+
const tail = rest.slice(3);
|
|
11
|
+
ctx.runAgentPrompt(
|
|
12
|
+
"prompts/commands/cr-to-rq.md",
|
|
13
|
+
resolveOperatorHint(tail, { required: true }).text,
|
|
14
|
+
AGENT_GLOBAL_PROMPTS_LIFECYCLE,
|
|
15
|
+
);
|
|
16
|
+
return 0;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { traceExportPdfLine } from "../core/cli-log";
|
|
5
|
+
import {
|
|
6
|
+
assertSpecRepoCleanForExport,
|
|
7
|
+
resolveExportPdfOutputPath,
|
|
8
|
+
} from "../export/export-pdf-path";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_EXPORT_ITEMS,
|
|
11
|
+
filterMarkdownPathsByExportItems,
|
|
12
|
+
parseExportItemsList,
|
|
13
|
+
validateExportItemCodes,
|
|
14
|
+
} from "../export/export-items";
|
|
15
|
+
import { collectMarkdownFiles } from "../export/collect-md";
|
|
16
|
+
import { mergeMarkdownFiles } from "../export/merge-md";
|
|
17
|
+
import { readPomProjectTitle } from "../project/pom-json";
|
|
18
|
+
import {
|
|
19
|
+
describeMmdcResolution,
|
|
20
|
+
renderMermaidBlocksToPng,
|
|
21
|
+
} from "../export/mermaid-run";
|
|
22
|
+
import {
|
|
23
|
+
bundledPdfWorkerScriptPath,
|
|
24
|
+
runBundledMarkdownToPdf,
|
|
25
|
+
} from "../export/pdf-bundled";
|
|
26
|
+
import {
|
|
27
|
+
buildNativePandocArgs,
|
|
28
|
+
resolvePandocExecutable,
|
|
29
|
+
runNativePandocPdf,
|
|
30
|
+
} from "../export/pandoc-pdf";
|
|
31
|
+
import type { WaterfallCommand } from "./types";
|
|
32
|
+
|
|
33
|
+
function shellWords(argv: string[]): string {
|
|
34
|
+
return argv
|
|
35
|
+
.map((a) => (/[\s'"\\]/.test(a) ? JSON.stringify(a) : a))
|
|
36
|
+
.join(" ");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseExportPdfArgs(tail: string[]): {
|
|
40
|
+
output?: string;
|
|
41
|
+
items: string[];
|
|
42
|
+
} {
|
|
43
|
+
let output: string | undefined;
|
|
44
|
+
let items: string[] | undefined;
|
|
45
|
+
for (let i = 0; i < tail.length; i++) {
|
|
46
|
+
const t = tail[i]!;
|
|
47
|
+
if (t === "--output" || t === "-o") {
|
|
48
|
+
const v = tail[++i];
|
|
49
|
+
if (!v || v.startsWith("-")) {
|
|
50
|
+
throw new Error("--output / -o requires a path");
|
|
51
|
+
}
|
|
52
|
+
output = v;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (t === "--items" || t === "-items") {
|
|
56
|
+
const v = tail[++i];
|
|
57
|
+
if (!v || v.startsWith("-")) {
|
|
58
|
+
throw new Error("--items requires a comma-separated list (e.g. CR,RQ,UC,HOR,TST)");
|
|
59
|
+
}
|
|
60
|
+
items = parseExportItemsList(v);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (/^--items=/i.test(t) || /^-items=/i.test(t)) {
|
|
64
|
+
const v = t.slice(t.indexOf("=") + 1).trim();
|
|
65
|
+
if (!v) {
|
|
66
|
+
throw new Error("--items= requires a comma-separated list");
|
|
67
|
+
}
|
|
68
|
+
items = parseExportItemsList(v);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (t.startsWith("-")) {
|
|
72
|
+
throw new Error(`Unknown option: ${t}`);
|
|
73
|
+
}
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Unexpected argument: ${t} (use --items CR,RQ,... and/or -o PATH; see waterfall help)`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const resolvedItems = items ?? [...DEFAULT_EXPORT_ITEMS];
|
|
79
|
+
validateExportItemCodes(resolvedItems);
|
|
80
|
+
return { output, items: resolvedItems };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const exportPdfCommand: WaterfallCommand = {
|
|
84
|
+
matches: (r) => r[0] === "export" && r[1] === "pdf",
|
|
85
|
+
run(ctx, rest) {
|
|
86
|
+
let opts: { output?: string; items: string[] };
|
|
87
|
+
try {
|
|
88
|
+
opts = parseExportPdfArgs(rest.slice(2));
|
|
89
|
+
} catch (e) {
|
|
90
|
+
process.stderr.write(`${(e as Error).message}\n`);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const specRoot = ctx.specRoot;
|
|
95
|
+
try {
|
|
96
|
+
assertSpecRepoCleanForExport(specRoot);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
process.stderr.write(`${(e as Error).message}\n`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const allMd = collectMarkdownFiles(specRoot);
|
|
103
|
+
const itemSet = new Set(opts.items);
|
|
104
|
+
const mdFiles = filterMarkdownPathsByExportItems(allMd, specRoot, itemSet);
|
|
105
|
+
if (allMd.length === 0) {
|
|
106
|
+
process.stderr.write(`[waterfall] No .md files found under ${specRoot}\n`);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
if (mdFiles.length === 0) {
|
|
110
|
+
process.stderr.write(
|
|
111
|
+
`[waterfall] No markdown matched export --items ${opts.items.join(",")} (under ${specRoot})\n`,
|
|
112
|
+
);
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const docTitle = readPomProjectTitle(specRoot);
|
|
117
|
+
const merged = mergeMarkdownFiles(specRoot, mdFiles, docTitle);
|
|
118
|
+
let pdfOut: string;
|
|
119
|
+
try {
|
|
120
|
+
pdfOut = resolveExportPdfOutputPath(specRoot, ctx.g.cwd, opts.output);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
process.stderr.write(`${(e as Error).message}\n`);
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
fs.mkdirSync(path.dirname(pdfOut), { recursive: true });
|
|
126
|
+
|
|
127
|
+
const log = ctx.g.logLevel;
|
|
128
|
+
traceExportPdfLine(log, `export pdf · spec_root=${specRoot}`);
|
|
129
|
+
traceExportPdfLine(log, `export pdf · items=${opts.items.join(",")}`);
|
|
130
|
+
traceExportPdfLine(log, `export pdf · title=${docTitle}`);
|
|
131
|
+
traceExportPdfLine(log, `export pdf · output=${pdfOut}`);
|
|
132
|
+
traceExportPdfLine(
|
|
133
|
+
log,
|
|
134
|
+
`export pdf · use_native_pandoc=${ctx.g.useNativePandoc}`,
|
|
135
|
+
);
|
|
136
|
+
traceExportPdfLine(
|
|
137
|
+
log,
|
|
138
|
+
`export pdf · markdown files=${mdFiles.length} (matched) / ${allMd.length} (total .md under spec)`,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
let workDir: string | undefined;
|
|
142
|
+
try {
|
|
143
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "waterfall-export-"));
|
|
144
|
+
const mermaidBlocks = merged.match(/```mermaid/gi) ?? [];
|
|
145
|
+
traceExportPdfLine(
|
|
146
|
+
log,
|
|
147
|
+
`export pdf · mermaid: ${mermaidBlocks.length} fenced diagram(s) → PNG via mmdc (${describeMmdcResolution()})`,
|
|
148
|
+
);
|
|
149
|
+
if (log === "verbose") {
|
|
150
|
+
traceExportPdfLine(log, `export pdf · temp workdir=${workDir}`);
|
|
151
|
+
}
|
|
152
|
+
const { markdown: withImages } = renderMermaidBlocksToPng(merged, workDir);
|
|
153
|
+
const mdName = "consolidated.md";
|
|
154
|
+
fs.writeFileSync(path.join(workDir, mdName), withImages, "utf8");
|
|
155
|
+
if (ctx.g.useNativePandoc) {
|
|
156
|
+
const pandocBin = resolvePandocExecutable();
|
|
157
|
+
const pArgs = buildNativePandocArgs(mdName, pdfOut);
|
|
158
|
+
traceExportPdfLine(
|
|
159
|
+
log,
|
|
160
|
+
`export pdf · pdf tool: pandoc (system) ${shellWords([pandocBin, ...pArgs])}`,
|
|
161
|
+
);
|
|
162
|
+
traceExportPdfLine(log, `export pdf · pandoc cwd: ${workDir}`);
|
|
163
|
+
runNativePandocPdf(workDir, mdName, pdfOut, pandocBin);
|
|
164
|
+
} else {
|
|
165
|
+
const worker = bundledPdfWorkerScriptPath();
|
|
166
|
+
traceExportPdfLine(
|
|
167
|
+
log,
|
|
168
|
+
"export pdf · pdf tool: pandoc-wasm (npm) + puppeteer/Chromium → standalone HTML → print PDF",
|
|
169
|
+
);
|
|
170
|
+
traceExportPdfLine(
|
|
171
|
+
log,
|
|
172
|
+
`export pdf · worker: ${shellWords([process.execPath, worker, workDir, mdName, pdfOut])}`,
|
|
173
|
+
);
|
|
174
|
+
runBundledMarkdownToPdf(workDir, mdName, pdfOut);
|
|
175
|
+
}
|
|
176
|
+
process.stdout.write(
|
|
177
|
+
`[waterfall] Wrote PDF (${mdFiles.length} markdown files): ${pdfOut}\n`,
|
|
178
|
+
);
|
|
179
|
+
return 0;
|
|
180
|
+
} catch (e) {
|
|
181
|
+
process.stderr.write(`${(e as Error).message}\n`);
|
|
182
|
+
return 1;
|
|
183
|
+
} finally {
|
|
184
|
+
if (workDir && fs.existsSync(workDir)) {
|
|
185
|
+
try {
|
|
186
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
187
|
+
} catch {
|
|
188
|
+
/* ignore */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
};
|