@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,232 @@
|
|
|
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 { slugifyDescription } from "../../core/slug";
|
|
7
|
+
|
|
8
|
+
export const HORIZONTAL_ANCHORS = ["story"] as const;
|
|
9
|
+
export type HorizontalAnchor = (typeof HORIZONTAL_ANCHORS)[number];
|
|
10
|
+
|
|
11
|
+
type NumberJson = { next: { HOR: number } & Record<string, number> };
|
|
12
|
+
|
|
13
|
+
function git(specRoot: string, args: string[]): void {
|
|
14
|
+
gitExecSync(specRoot, args, { stdio: "inherit" });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function gitCapture(specRoot: string, args: string[]): string {
|
|
18
|
+
return gitExecSync(specRoot, args, {
|
|
19
|
+
encoding: "utf8",
|
|
20
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
21
|
+
}).toString();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatHorId(n: number): string {
|
|
25
|
+
return String(n).padStart(3, "0");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Accept `HOR-001`, `hor-001`, or `001`. */
|
|
29
|
+
export function parseHorToken(tok: string): string | undefined {
|
|
30
|
+
const t = tok.trim();
|
|
31
|
+
const m = t.match(/^HOR-(\d{3})$/i) ?? t.match(/^(\d{3})$/);
|
|
32
|
+
return m?.[1];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function assertHorizontalAnchor(raw: string): HorizontalAnchor {
|
|
36
|
+
const a = raw.trim().toLowerCase() as HorizontalAnchor;
|
|
37
|
+
if (!HORIZONTAL_ANCHORS.includes(a as HorizontalAnchor)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`horizontal create: unknown anchor "${raw}" (use: ${HORIZONTAL_ANCHORS.join(", ")})`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return a as HorizontalAnchor;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function matchesHorizontalAnchor(anchor: HorizontalAnchor, relPath: string): boolean {
|
|
46
|
+
const n = relPath.replace(/\\/g, "/");
|
|
47
|
+
switch (anchor) {
|
|
48
|
+
case "story":
|
|
49
|
+
return /(^|\/)STORY-\d+/i.test(n) || /\/_STORY-\d+/i.test(n);
|
|
50
|
+
default:
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseAnchorFromDoc(body: string): HorizontalAnchor {
|
|
56
|
+
const m = body.match(/\*\*Anchor item type:\*\*\s*(\S+)/i);
|
|
57
|
+
if (m?.[1]) {
|
|
58
|
+
const low = m[1].toLowerCase();
|
|
59
|
+
if (HORIZONTAL_ANCHORS.includes(low as HorizontalAnchor)) {
|
|
60
|
+
return low as HorizontalAnchor;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return "story";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function horizontalsDir(specRoot: string): string {
|
|
67
|
+
return path.join(specRoot, "technical", "horizontals");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Resolve the one `technical/horizontals` folder whose name starts with `HOR-{digits}-`. */
|
|
71
|
+
export function findHorizontalDir(specRoot: string, digits: string): string {
|
|
72
|
+
const base = horizontalsDir(specRoot);
|
|
73
|
+
if (!fs.existsSync(base)) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`horizontal: missing ${path.relative(specRoot, base) + "/"}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const prefix = `HOR-${digits}-`;
|
|
79
|
+
const names = fs.readdirSync(base).filter((n) => n.startsWith(prefix));
|
|
80
|
+
if (names.length === 0) {
|
|
81
|
+
throw new Error(`horizontal: no folder matching ${prefix}* under technical/horizontals/`);
|
|
82
|
+
}
|
|
83
|
+
if (names.length > 1) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`horizontal: ambiguous HOR-${digits}: multiple folders (${names.join(", ")})`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return path.join(base, names[0]!);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Path to `HOR-<digits>.md` inside the horizontal folder `dir`. */
|
|
92
|
+
export function horizontalMarkdownPath(dir: string, digits: string): string {
|
|
93
|
+
return path.join(dir, `HOR-${digits}.md`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Create `technical/horizontals/HOR-NNN-<slug>/HOR-NNN.md`, bump `next.HOR`, commit.
|
|
98
|
+
*/
|
|
99
|
+
export function runHorizontalCreate(
|
|
100
|
+
specRoot: string,
|
|
101
|
+
anchor: HorizontalAnchor,
|
|
102
|
+
horizontalNameRaw: string,
|
|
103
|
+
purpose: string,
|
|
104
|
+
): void {
|
|
105
|
+
assertSpecRoot(specRoot);
|
|
106
|
+
assertBranchForCrScopedCommand(specRoot, "waterfall horizontal create");
|
|
107
|
+
const nameSlug = slugifyDescription(horizontalNameRaw.replace(/\//g, "-"));
|
|
108
|
+
if (!nameSlug || nameSlug === "change") {
|
|
109
|
+
throw new Error(
|
|
110
|
+
'horizontal create: give a concrete horizontal name (e.g. database-model, api-model, technical-stack)',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const numPath = path.join(specRoot, "number.json");
|
|
115
|
+
const raw = JSON.parse(fs.readFileSync(numPath, "utf8")) as NumberJson;
|
|
116
|
+
if (typeof raw.next?.HOR !== "number") {
|
|
117
|
+
throw new Error("horizontal create: number.json missing next.HOR (re-run spec init or add HOR: 1)");
|
|
118
|
+
}
|
|
119
|
+
const n = raw.next.HOR;
|
|
120
|
+
const id = formatHorId(n);
|
|
121
|
+
const base = `HOR-${id}-${nameSlug}`;
|
|
122
|
+
const horDir = path.join(horizontalsDir(specRoot), base);
|
|
123
|
+
|
|
124
|
+
if (fs.existsSync(horDir)) {
|
|
125
|
+
throw new Error(`horizontal folder already exists: technical/horizontals/${base}`);
|
|
126
|
+
}
|
|
127
|
+
fs.mkdirSync(horDir, { recursive: true });
|
|
128
|
+
|
|
129
|
+
const mdPath = path.join(horDir, `HOR-${id}.md`);
|
|
130
|
+
const purposeTrim = purpose.trim();
|
|
131
|
+
const steeringLines = purposeTrim
|
|
132
|
+
.split(/\r?\n/)
|
|
133
|
+
.map((line) => (line.length > 0 ? `> ${line}` : ">"));
|
|
134
|
+
const steeringBlock =
|
|
135
|
+
steeringLines.length > 0 ? steeringLines.join("\n") : "> _(empty)_";
|
|
136
|
+
|
|
137
|
+
const md = `# HOR-${id} — ${horizontalNameRaw.trim()} (horizontal on **${anchor}**)
|
|
138
|
+
|
|
139
|
+
**Id:** HOR-${id}
|
|
140
|
+
**Anchor item type:** ${anchor}
|
|
141
|
+
**Horizontal name:** ${nameSlug}
|
|
142
|
+
**Status:** draft
|
|
143
|
+
|
|
144
|
+
## Purpose
|
|
145
|
+
|
|
146
|
+
*(One short paragraph on what this horizontal reconciles — per \`prompts/global/horizontal-structure.md\`. **Synthesize** from **Draft steering (CLI)**; do not paste it verbatim. Remove **Draft steering** when **Purpose** and **Aggregated definitions** are filled.)*
|
|
147
|
+
|
|
148
|
+
## Draft steering (CLI)
|
|
149
|
+
|
|
150
|
+
The operator supplied this when running \`waterfall horizontal create …\`. **Scratch input only**:
|
|
151
|
+
|
|
152
|
+
${steeringBlock}
|
|
153
|
+
|
|
154
|
+
## Aggregated definitions
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
## Maintenance notes
|
|
158
|
+
|
|
159
|
+
(Optional) Human revision notes for major model changes.
|
|
160
|
+
`;
|
|
161
|
+
|
|
162
|
+
fs.writeFileSync(mdPath, md, "utf8");
|
|
163
|
+
|
|
164
|
+
raw.next.HOR = n + 1;
|
|
165
|
+
fs.writeFileSync(numPath, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
|
|
166
|
+
|
|
167
|
+
const relDir = path.relative(specRoot, horDir).replace(/\\/g, "/");
|
|
168
|
+
git(specRoot, ["add", `${relDir}/HOR-${id}.md`, "number.json"]);
|
|
169
|
+
git(specRoot, ["commit", "-m", `feat(HOR-${id}): scaffold horizontal (${anchor} / ${nameSlug})`]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolves `develop...HEAD` name-only paths vs develop, filters by this horizontal’s anchor.
|
|
174
|
+
* Does **not** modify `HOR-<NNN>.md` — returns markdown for the operator / agent hint only.
|
|
175
|
+
*/
|
|
176
|
+
export function runHorizontalUpdate(specRoot: string, horToken: string): string {
|
|
177
|
+
assertSpecRoot(specRoot);
|
|
178
|
+
assertBranchForCrScopedCommand(specRoot, "waterfall horizontal update");
|
|
179
|
+
const digits = parseHorToken(horToken);
|
|
180
|
+
if (!digits) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`horizontal update: invalid id "${horToken}" (use HOR-NNN or NNN)`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const dir = findHorizontalDir(specRoot, digits);
|
|
187
|
+
const mdPath = horizontalMarkdownPath(dir, digits);
|
|
188
|
+
if (!fs.existsSync(mdPath)) {
|
|
189
|
+
const rel = path.relative(specRoot, mdPath).replace(/\\/g, "/");
|
|
190
|
+
throw new Error(
|
|
191
|
+
`horizontal update: missing ${rel} — run horizontal create or add HOR-${digits}.md in the horizontal folder`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const body = fs.readFileSync(mdPath, "utf8");
|
|
195
|
+
const anchor = parseAnchorFromDoc(body);
|
|
196
|
+
|
|
197
|
+
let names: string;
|
|
198
|
+
try {
|
|
199
|
+
names = gitCapture(specRoot, ["diff", "develop...HEAD", "--name-only"]);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`horizontal update: git diff develop...HEAD failed (${(e as Error).message}). Is branch develop present?`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const allPaths = names
|
|
207
|
+
.split("\n")
|
|
208
|
+
.map((l) => l.trim())
|
|
209
|
+
.filter(Boolean);
|
|
210
|
+
const matched = allPaths.filter((p) => matchesHorizontalAnchor(anchor, p));
|
|
211
|
+
|
|
212
|
+
const matchingBlock = matched.length
|
|
213
|
+
? matched.map((p) => `- ${p}`).join("\n")
|
|
214
|
+
: `(No paths in this diff match anchor ${anchor}.)`;
|
|
215
|
+
const allBlock = allPaths.length
|
|
216
|
+
? allPaths.map((p) => `- ${p}`).join("\n")
|
|
217
|
+
: "(None.)";
|
|
218
|
+
|
|
219
|
+
return [
|
|
220
|
+
`Horizontal update HOR-${digits} (anchor ${anchor})`,
|
|
221
|
+
"",
|
|
222
|
+
`Range develop...HEAD. ${matched.length} path(s) match this anchor; ${allPaths.length} path(s) changed in total. Do not paste these lists into HOR-${digits}.md — merge substance into the Aggregated definitions section only (typed ids in prose; see before-changing-spec.md).`,
|
|
223
|
+
"",
|
|
224
|
+
`Paths matching anchor ${anchor}`,
|
|
225
|
+
"",
|
|
226
|
+
matchingBlock,
|
|
227
|
+
"",
|
|
228
|
+
"All changed paths vs develop",
|
|
229
|
+
"",
|
|
230
|
+
allBlock,
|
|
231
|
+
].join("\n");
|
|
232
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { AGENT_GLOBAL_PROMPTS_HORIZONTAL_CREATE } from "../agent/global-prompts";
|
|
2
|
+
import {
|
|
3
|
+
assertHorizontalAnchor,
|
|
4
|
+
runHorizontalCreate,
|
|
5
|
+
} from "./horizontal/horizontal";
|
|
6
|
+
import { assertBranchForCrScopedCommand } from "../git/git-branch-guards";
|
|
7
|
+
import { resolveOperatorHint } from "./resolve-operator-hint";
|
|
8
|
+
import type { WaterfallCommand } from "./types";
|
|
9
|
+
|
|
10
|
+
export const horizontalCreateCommand: WaterfallCommand = {
|
|
11
|
+
matches: (r) => r[0] === "horizontal" && r[1] === "create",
|
|
12
|
+
run(ctx, rest) {
|
|
13
|
+
const [, , anchorRaw, nameSlug, ...more] = rest;
|
|
14
|
+
const purpose = resolveOperatorHint(more.filter(Boolean), {
|
|
15
|
+
required: true,
|
|
16
|
+
}).text;
|
|
17
|
+
if (!anchorRaw || !nameSlug || !purpose) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
'horizontal create requires anchor, name, and purpose: waterfall horizontal create story database-model "…"',
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
assertBranchForCrScopedCommand(ctx.specRoot, "waterfall horizontal create");
|
|
23
|
+
const anchor = assertHorizontalAnchor(anchorRaw);
|
|
24
|
+
runHorizontalCreate(ctx.specRoot, anchor, nameSlug, purpose);
|
|
25
|
+
const hint = `[horizontal create · anchor=${anchor} · name=${nameSlug}] ${purpose}`;
|
|
26
|
+
ctx.runAgentPrompt(
|
|
27
|
+
"prompts/commands/horizontal-create.md",
|
|
28
|
+
hint,
|
|
29
|
+
AGENT_GLOBAL_PROMPTS_HORIZONTAL_CREATE,
|
|
30
|
+
"waterfall horizontal create — refine HOR prose",
|
|
31
|
+
);
|
|
32
|
+
return 0;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { runHorizontalUpdate } from "./horizontal/horizontal";
|
|
2
|
+
import { assertBranchForCrScopedCommand } from "../git/git-branch-guards";
|
|
3
|
+
import { AGENT_GLOBAL_PROMPTS_HORIZONTAL_UPDATE } from "../agent/global-prompts";
|
|
4
|
+
import { resolveOperatorHint } from "./resolve-operator-hint";
|
|
5
|
+
import type { WaterfallCommand } from "./types";
|
|
6
|
+
|
|
7
|
+
export const horizontalUpdateCommand: WaterfallCommand = {
|
|
8
|
+
matches: (r) => r[0] === "horizontal" && r[1] === "update",
|
|
9
|
+
run(ctx, rest) {
|
|
10
|
+
const horTok = rest[2];
|
|
11
|
+
if (!horTok) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
"horizontal update requires HOR id: waterfall horizontal update HOR-001 [hint]",
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
assertBranchForCrScopedCommand(ctx.specRoot, "waterfall horizontal update");
|
|
17
|
+
const summary = runHorizontalUpdate(ctx.specRoot, horTok);
|
|
18
|
+
const tail = rest.slice(3);
|
|
19
|
+
const operator = resolveOperatorHint(tail.filter(Boolean), {
|
|
20
|
+
required: false,
|
|
21
|
+
}).text;
|
|
22
|
+
const hint = [operator, summary].filter(Boolean).join("\n\n");
|
|
23
|
+
ctx.runAgentPrompt(
|
|
24
|
+
"prompts/commands/horizontal-update.md",
|
|
25
|
+
hint,
|
|
26
|
+
AGENT_GLOBAL_PROMPTS_HORIZONTAL_UPDATE,
|
|
27
|
+
undefined,
|
|
28
|
+
{ attachCrBranchPaths: false },
|
|
29
|
+
);
|
|
30
|
+
return 0;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { exportPdfCommand } from "./export-pdf";
|
|
2
|
+
import { syncBugsCommand, syncStoriesCommand } from "./sync-work-items";
|
|
3
|
+
import { crAllCommand } from "./cr-all";
|
|
4
|
+
import { crFinishCommand } from "./cr-finish";
|
|
5
|
+
import { crStartCommand } from "./cr-start";
|
|
6
|
+
import { crToRqCommand } from "./cr-to-rq";
|
|
7
|
+
import { horizontalCreateCommand } from "./horizontal-create";
|
|
8
|
+
import { horizontalUpdateCommand } from "./horizontal-update";
|
|
9
|
+
import { rqAllCommand } from "./rq-all";
|
|
10
|
+
import { rqToUcCommand } from "./rq-to-uc";
|
|
11
|
+
import { commentAddCommand } from "./comment-add";
|
|
12
|
+
import { storyCloseCommand } from "./story-close";
|
|
13
|
+
import { sysStartCommand } from "./sys-start";
|
|
14
|
+
import { bugStartCommand } from "./bug-start";
|
|
15
|
+
import { testAllCommand } from "./test-all";
|
|
16
|
+
import { testToStoryCommand } from "./test-to-story";
|
|
17
|
+
import { ucAllCommand } from "./uc-all";
|
|
18
|
+
import { ucToStoryCommand } from "./uc-to-story";
|
|
19
|
+
import { ucToTestCommand } from "./uc-to-test";
|
|
20
|
+
import type { CommandRunContext, WaterfallCommand } from "./types";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Order is only relevant if two matchers could both match; keep specific
|
|
24
|
+
* multi-token commands before looser ones (e.g. `test to story` before `test all`).
|
|
25
|
+
*/
|
|
26
|
+
export const SPEC_COMMANDS: WaterfallCommand[] = [
|
|
27
|
+
syncStoriesCommand,
|
|
28
|
+
syncBugsCommand,
|
|
29
|
+
commentAddCommand,
|
|
30
|
+
exportPdfCommand,
|
|
31
|
+
crStartCommand,
|
|
32
|
+
sysStartCommand,
|
|
33
|
+
bugStartCommand,
|
|
34
|
+
horizontalCreateCommand,
|
|
35
|
+
horizontalUpdateCommand,
|
|
36
|
+
crFinishCommand,
|
|
37
|
+
crToRqCommand,
|
|
38
|
+
rqToUcCommand,
|
|
39
|
+
ucToStoryCommand,
|
|
40
|
+
ucToTestCommand,
|
|
41
|
+
testToStoryCommand,
|
|
42
|
+
crAllCommand,
|
|
43
|
+
rqAllCommand,
|
|
44
|
+
ucAllCommand,
|
|
45
|
+
testAllCommand,
|
|
46
|
+
storyCloseCommand,
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export function dispatchSpecCommand(
|
|
50
|
+
ctx: CommandRunContext,
|
|
51
|
+
rest: string[],
|
|
52
|
+
): number | null {
|
|
53
|
+
for (const cmd of SPEC_COMMANDS) {
|
|
54
|
+
if (cmd.matches(rest)) {
|
|
55
|
+
return cmd.run(ctx, rest);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { joinHint } from "./join-hint";
|
|
4
|
+
|
|
5
|
+
/** How the operator hint text was obtained. */
|
|
6
|
+
export type OperatorHintSource = "inline" | "stdin" | "file";
|
|
7
|
+
|
|
8
|
+
/** Resolved hint: use `text` for agents and git scaffolds. */
|
|
9
|
+
export type ResolvedOperatorHint = {
|
|
10
|
+
source: OperatorHintSource;
|
|
11
|
+
/** UTF-8 content; newlines normalized; leading/trailing whitespace trimmed. */
|
|
12
|
+
text: string;
|
|
13
|
+
/** Absolute path when source is `file`. */
|
|
14
|
+
resolvedPath?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ResolveOperatorHintOptions = {
|
|
18
|
+
/** When true, missing or blank hint throws. @default false */
|
|
19
|
+
required?: boolean;
|
|
20
|
+
/** Base directory for relative <code>@PATH</code> hints. @default process.cwd() */
|
|
21
|
+
cwd?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Shorter text for <code>help -o json</code> positional rows. */
|
|
25
|
+
export const OPERATOR_HINT_POSITIONAL_DESCRIPTION =
|
|
26
|
+
"Inline argv tokens (joined with spaces), a single - (read stdin UTF-8), or a single @PATH (read file UTF-8; PATH relative to cwd unless absolute).";
|
|
27
|
+
|
|
28
|
+
/** Machine-readable documentation for <code>help -o json</code>. */
|
|
29
|
+
export const OPERATOR_HINT_JSON = {
|
|
30
|
+
summary:
|
|
31
|
+
"Operator hints (steering text for agents, or descriptions for scaffolds) are resolved from argv after fixed command arguments.",
|
|
32
|
+
modes: [
|
|
33
|
+
{
|
|
34
|
+
id: "inline",
|
|
35
|
+
description:
|
|
36
|
+
"Default: all remaining argv tokens are joined with single spaces (shell quoting controls word boundaries).",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "stdin",
|
|
40
|
+
description:
|
|
41
|
+
'Exactly one token "-" reads the entire hint from standard input as UTF-8.',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "file",
|
|
45
|
+
description:
|
|
46
|
+
"Exactly one token whose value starts with @ reads the file path after @ (relative paths resolve against cwd).",
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
function normalizeHintBody(raw: string): string {
|
|
52
|
+
return raw.replace(/\r\n/g, "\n").trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readStdinUtf8(): string {
|
|
56
|
+
try {
|
|
57
|
+
return fs.readFileSync(0, "utf8");
|
|
58
|
+
} catch (e) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Cannot read operator hint from stdin: ${(e as Error).message}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve operator-hint argv fragments to a single string and record the source.
|
|
67
|
+
* - Single <code>-</code> → stdin
|
|
68
|
+
* - Single <code>@path</code> → file (path may not be empty)
|
|
69
|
+
* - Otherwise → inline via {@link joinHint}
|
|
70
|
+
*/
|
|
71
|
+
export function resolveOperatorHint(
|
|
72
|
+
parts: string[],
|
|
73
|
+
options?: ResolveOperatorHintOptions,
|
|
74
|
+
): ResolvedOperatorHint {
|
|
75
|
+
const required = options?.required ?? false;
|
|
76
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
77
|
+
const filtered = parts.filter((p) => p.length > 0);
|
|
78
|
+
|
|
79
|
+
if (filtered.length === 0) {
|
|
80
|
+
if (required) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
"Operator hint is required: pass inline text, - for stdin, or @PATH for a file (see waterfall help).",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return { source: "inline", text: "" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (filtered.length === 1 && filtered[0] === "-") {
|
|
89
|
+
const text = normalizeHintBody(readStdinUtf8());
|
|
90
|
+
if (required && !text) {
|
|
91
|
+
throw new Error("Operator hint from stdin is empty.");
|
|
92
|
+
}
|
|
93
|
+
return { source: "stdin", text };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (filtered.length === 1 && filtered[0]!.startsWith("@")) {
|
|
97
|
+
const rel = filtered[0]!.slice(1);
|
|
98
|
+
if (!rel) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
'File hint requires a path after "@", e.g. @./notes/hint.md',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const abs = path.isAbsolute(rel) ? rel : path.resolve(cwd, rel);
|
|
104
|
+
let raw: string;
|
|
105
|
+
try {
|
|
106
|
+
raw = fs.readFileSync(abs, "utf8");
|
|
107
|
+
} catch (e) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Cannot read hint file ${abs}: ${(e as Error).message}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const text = normalizeHintBody(raw);
|
|
113
|
+
if (required && !text) {
|
|
114
|
+
throw new Error(`Hint file is empty: ${abs}`);
|
|
115
|
+
}
|
|
116
|
+
return { source: "file", text, resolvedPath: abs };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { source: "inline", text: joinHint(filtered) };
|
|
120
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { AGENT_GLOBAL_PROMPTS_LIFECYCLE } from "../agent/global-prompts";
|
|
2
|
+
import { ORCHESTRATE_RQ_ALL_ORDER } from "../core/prompt-map";
|
|
3
|
+
import { resolveOperatorHint } from "./resolve-operator-hint";
|
|
4
|
+
import type { WaterfallCommand } from "./types";
|
|
5
|
+
|
|
6
|
+
export const rqAllCommand: WaterfallCommand = {
|
|
7
|
+
matches: (r) => r[0] === "rq" && r[1] === "all",
|
|
8
|
+
run(ctx, rest) {
|
|
9
|
+
const tail = rest.slice(2);
|
|
10
|
+
ctx.runOrchestrate(
|
|
11
|
+
ORCHESTRATE_RQ_ALL_ORDER,
|
|
12
|
+
"waterfall rq all",
|
|
13
|
+
resolveOperatorHint(tail, { required: false }).text,
|
|
14
|
+
AGENT_GLOBAL_PROMPTS_LIFECYCLE,
|
|
15
|
+
);
|
|
16
|
+
return 0;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -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 rqToUcCommand: WaterfallCommand = {
|
|
7
|
+
matches: (r) => r[0] === "rq" && r[1] === "to" && r[2] === "uc",
|
|
8
|
+
run(ctx, rest) {
|
|
9
|
+
assertBranchForCrScopedCommand(ctx.specRoot, "waterfall rq to uc");
|
|
10
|
+
const tail = rest.slice(3);
|
|
11
|
+
ctx.runAgentPrompt(
|
|
12
|
+
"prompts/commands/rq-to-uc.md",
|
|
13
|
+
resolveOperatorHint(tail, { required: true }).text,
|
|
14
|
+
AGENT_GLOBAL_PROMPTS_LIFECYCLE,
|
|
15
|
+
);
|
|
16
|
+
return 0;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { assertBranchDevelop } 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 git(specRoot: string, args: string[]): void {
|
|
9
|
+
gitExecSync(specRoot, args, { stdio: "inherit" });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Parse STORY-018 or 018 -> padded "018" */
|
|
13
|
+
export function parseStoryIdToken(arg: string): string {
|
|
14
|
+
const m = arg.match(/^STORY-(\d+)$/i) ?? arg.match(/^(\d+)$/);
|
|
15
|
+
if (!m) throw new Error(`Invalid story id "${arg}". Use STORY-NNN or NNN.`);
|
|
16
|
+
return String(parseInt(m[1]!, 10)).padStart(3, "0");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function walkStoryDirs(root: string): string[] {
|
|
20
|
+
const out: string[] = [];
|
|
21
|
+
const stack = [root];
|
|
22
|
+
while (stack.length) {
|
|
23
|
+
const d = stack.pop()!;
|
|
24
|
+
let entries: fs.Dirent[];
|
|
25
|
+
try {
|
|
26
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
27
|
+
} catch {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
for (const e of entries) {
|
|
31
|
+
const p = path.join(d, e.name);
|
|
32
|
+
if (e.isDirectory()) {
|
|
33
|
+
if (/^STORY-\d{3}-/.test(e.name) && !e.name.startsWith("_")) {
|
|
34
|
+
out.push(p);
|
|
35
|
+
}
|
|
36
|
+
if (!e.name.startsWith(".") && e.name !== "node_modules") {
|
|
37
|
+
stack.push(p);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Find .../STORY-NNN-slug/ under technical/ */
|
|
46
|
+
export function findOpenStoryFolder(
|
|
47
|
+
specRoot: string,
|
|
48
|
+
paddedId: string,
|
|
49
|
+
): string {
|
|
50
|
+
const technical = path.join(specRoot, "technical");
|
|
51
|
+
if (!fs.existsSync(technical)) {
|
|
52
|
+
throw new Error(`No technical/ under ${specRoot}`);
|
|
53
|
+
}
|
|
54
|
+
const dirs = walkStoryDirs(technical);
|
|
55
|
+
const needle = `STORY-${paddedId}-`;
|
|
56
|
+
const hit = dirs.find((p) => path.basename(p).startsWith(needle));
|
|
57
|
+
if (!hit) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`No open STORY-${paddedId}-* folder found under technical/ (already closed or missing).`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return hit;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Git mv folder to _STORY-...; update STORY-NNN.md status (minimal). */
|
|
66
|
+
export function runStoryClose(specRoot: string, storyToken: string): void {
|
|
67
|
+
assertSpecRoot(specRoot);
|
|
68
|
+
const padded = parseStoryIdToken(storyToken);
|
|
69
|
+
const oldDir = findOpenStoryFolder(specRoot, padded);
|
|
70
|
+
const base = path.basename(oldDir);
|
|
71
|
+
const newBase = `_${base}`;
|
|
72
|
+
const parent = path.dirname(oldDir);
|
|
73
|
+
const newDir = path.join(parent, newBase);
|
|
74
|
+
|
|
75
|
+
const storyFile = fs
|
|
76
|
+
.readdirSync(oldDir)
|
|
77
|
+
.find((f) => /^STORY-\d{3}\.md$/i.test(f));
|
|
78
|
+
if (!storyFile) {
|
|
79
|
+
throw new Error(`No STORY-NNN.md in ${oldDir}`);
|
|
80
|
+
}
|
|
81
|
+
const storyPath = path.join(oldDir, storyFile);
|
|
82
|
+
let body = fs.readFileSync(storyPath, "utf8");
|
|
83
|
+
body = body.replace(/\*\*Status:\*\*\s*\w+/i, "**Status:** closed");
|
|
84
|
+
if (!/\*\*Closed:\*\*/i.test(body)) {
|
|
85
|
+
body = body.replace(
|
|
86
|
+
/(\*\*Status:\*\*[^\n]*\n)/i,
|
|
87
|
+
`$1**Closed:** ${new Date().toISOString().slice(0, 10)} — closed via waterfall CLI.\n`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
fs.writeFileSync(storyPath, body, "utf8");
|
|
91
|
+
|
|
92
|
+
git(specRoot, [
|
|
93
|
+
"mv",
|
|
94
|
+
path.relative(specRoot, oldDir),
|
|
95
|
+
path.relative(specRoot, newDir),
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
git(specRoot, ["add", "-A"]);
|
|
99
|
+
git(specRoot, [
|
|
100
|
+
"commit",
|
|
101
|
+
"-m",
|
|
102
|
+
`chore: close ${base} -> ${newBase}`,
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const storyCloseCommand: WaterfallCommand = {
|
|
107
|
+
matches: (r) => r[0] === "story" && r[1] === "close",
|
|
108
|
+
run(ctx, rest) {
|
|
109
|
+
const tok = rest[2];
|
|
110
|
+
if (!tok) throw new Error("story close requires STORY-NNN or all");
|
|
111
|
+
const storyCloseLabel =
|
|
112
|
+
tok.toLowerCase() === "all"
|
|
113
|
+
? "waterfall story close all"
|
|
114
|
+
: "waterfall story close";
|
|
115
|
+
assertBranchDevelop(ctx.specRoot, storyCloseLabel);
|
|
116
|
+
if (tok.toLowerCase() === "all") {
|
|
117
|
+
throw new Error(
|
|
118
|
+
"story close all: use explicit ids per story-close-all.md (not automated here).",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
runStoryClose(ctx.specRoot, tok);
|
|
122
|
+
return 0;
|
|
123
|
+
},
|
|
124
|
+
};
|