@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,216 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { gitExecSync } from "../git/git-cli";
|
|
5
|
+
import { POM_JSON_BASENAME } from "../project/pom-json";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_NUMBER_JSON = {
|
|
8
|
+
next: { CR: 1, RQ: 1, UC: 1, SYS: 1, STORY: 1, BUG: 1, TST: 1, HOR: 1 },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Minimal spec “law” so agents and `waterfall spec init` trees match prompts that reference CURSOR.md. */
|
|
12
|
+
export const SPEC_SCAFFOLD_CURSOR_MD = `# Cursor guidance — waterfall spec (scaffold)
|
|
13
|
+
|
|
14
|
+
This repository is a **structured requirements** tree (lifecycle **CR → RQ → UC → …**), not application source code.
|
|
15
|
+
|
|
16
|
+
## Layout
|
|
17
|
+
|
|
18
|
+
| Path | Role |
|
|
19
|
+
|------|------|
|
|
20
|
+
| \`changerequests/\` | **CR-*** — one folder \`CR-<NNN>-<slug>/\` with canonical \`CR-<NNN>.md\` |
|
|
21
|
+
| \`requirements/\` | **RQ-*** (\`RQ-<NNN>-<slug>/RQ-<NNN>.md\` + **UC-***) |
|
|
22
|
+
| \`technical/\` | **SYS-*** (\`SYS-<NNN>-<slug>/SYS-<NNN>.md\`), **STORY-***, **BUG-*** |
|
|
23
|
+
| \`technical/horizontals/\` | **HOR-*** (\`HOR-<NNN>-<slug>/HOR-<NNN>.md\`) |
|
|
24
|
+
| \`tests/\` | **TST-*** test specifications |
|
|
25
|
+
| \`number.json\` | Next numeric ids for artifacts |
|
|
26
|
+
|
|
27
|
+
## Agents / Waterfall CLI
|
|
28
|
+
|
|
29
|
+
When **Waterfall CLI** runs a built-in lifecycle prompt here, **only** change the artifact types that prompt allows. Full checklists live in **\`waterfall-cli/prompts/commands/\`**. **Operator hints** from the CLI are steering context — interpret them; do not paste them verbatim into Summary, Purpose, or titles. Do not copy **\`prompts/\`** structure text into spec files as filler.
|
|
30
|
+
|
|
31
|
+
## Git (summary)
|
|
32
|
+
|
|
33
|
+
Use **\`feature/CR-<NNN>-…\`** (or **\`feature/<short-slug>\`** for non-CR work) for spec edits; do not land CR-scoped work directly on **\`develop\`** without your team’s merge process.
|
|
34
|
+
|
|
35
|
+
Expand this file for project-specific rules. A fuller template lives in **\`waterfall-spec\`** repos.
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
/** Appended to \`CURSOR.md\` when \`waterfall spec init --full\` copies the template tree. */
|
|
39
|
+
const CURSOR_FULL_SCAFFOLD_APPEND = `
|
|
40
|
+
|
|
41
|
+
## Local prompt index (full scaffold)
|
|
42
|
+
|
|
43
|
+
- **[prompts/workflows.md](./prompts/workflows.md)** — maps lifecycle steps to Waterfall CLI commands and shipped prompts.
|
|
44
|
+
- **[prompts/before-changing-spec.md](./prompts/before-changing-spec.md)** — pointer to global editing rules in the CLI package.
|
|
45
|
+
- **[doc/spec-structure.md](./doc/spec-structure.md)** — quick directory reference (non-normative).
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
function git(specRoot: string, args: string[]): void {
|
|
49
|
+
gitExecSync(specRoot, args, { stdio: "inherit" });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Absolute path to \`spec-template/full\` (files mirrored into the spec repo). */
|
|
53
|
+
export function specTemplateFullRoot(): string {
|
|
54
|
+
/** `lib/cli/spec` → `lib/cli` → `lib` → package root, then `spec-template/full`. */
|
|
55
|
+
return path.resolve(
|
|
56
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
57
|
+
"..",
|
|
58
|
+
"..",
|
|
59
|
+
"..",
|
|
60
|
+
"spec-template",
|
|
61
|
+
"full",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Copy \`spec-template/full/**\` into \`targetAbs\` (recursive). Skips missing template root.
|
|
67
|
+
*/
|
|
68
|
+
export function copyFullSpecTemplate(targetAbs: string): void {
|
|
69
|
+
const root = specTemplateFullRoot();
|
|
70
|
+
if (!fs.existsSync(root)) {
|
|
71
|
+
throw new Error(`Full spec template missing: ${root}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function walk(rel: string): void {
|
|
75
|
+
const src = path.join(root, rel);
|
|
76
|
+
const st = fs.statSync(src);
|
|
77
|
+
if (st.isDirectory()) {
|
|
78
|
+
for (const name of fs.readdirSync(src)) {
|
|
79
|
+
walk(path.join(rel, name));
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const dest = path.join(targetAbs, rel);
|
|
84
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
85
|
+
fs.writeFileSync(dest, fs.readFileSync(src), "utf8");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const name of fs.readdirSync(root)) {
|
|
89
|
+
walk(name);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type SpecInitOptions = {
|
|
94
|
+
/** Human-readable project name; written to \`pom.json\` at the init target root. */
|
|
95
|
+
title: string;
|
|
96
|
+
/** Add \`prompts/**\`, \`doc/spec-structure.md\`, and extend \`CURSOR.md\` (from \`spec-template/full\`). */
|
|
97
|
+
full?: boolean;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type ParsedSpecInitCli = {
|
|
101
|
+
full: boolean;
|
|
102
|
+
title: string;
|
|
103
|
+
targetDir: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse argv after \`spec init\`: requires \`--title\` (or \`--title=…\`); optional \`--full\`; remaining tokens = DIR.
|
|
108
|
+
*/
|
|
109
|
+
export function parseSpecInitCliArgs(tail: string[]): ParsedSpecInitCli {
|
|
110
|
+
let full = false;
|
|
111
|
+
let title: string | undefined;
|
|
112
|
+
const rest: string[] = [];
|
|
113
|
+
for (let i = 0; i < tail.length; i++) {
|
|
114
|
+
const a = tail[i]!;
|
|
115
|
+
if (a === "--full") {
|
|
116
|
+
full = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (a === "--title") {
|
|
120
|
+
const v = tail[++i];
|
|
121
|
+
if (!v || v.startsWith("--")) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
'spec init: --title requires a value (e.g. --title "My project")',
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
title = v;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (a.startsWith("--title=")) {
|
|
130
|
+
const v = a.slice("--title=".length);
|
|
131
|
+
if (!v.trim()) {
|
|
132
|
+
throw new Error("spec init: --title= requires a non-empty value");
|
|
133
|
+
}
|
|
134
|
+
title = v;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
rest.push(a);
|
|
138
|
+
}
|
|
139
|
+
if (!title?.trim()) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
'spec init: required --title "…" is missing (project name for pom.json). Example: waterfall spec init --title "My product" ./my-spec',
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const targetDir = rest.join(" ").trim() || ".";
|
|
145
|
+
return { full, title: title.trim(), targetDir };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Creates a new waterfall-spec-shaped directory: pom.json, layout, number.json, CURSOR.md, local git on develop.
|
|
150
|
+
* Use \`{ full: true }\` for the regeneratable template tree under \`waterfall-cli/spec-template/full/\`.
|
|
151
|
+
*/
|
|
152
|
+
export function runSpecInit(
|
|
153
|
+
cwd: string,
|
|
154
|
+
rawTargetDir: string,
|
|
155
|
+
options: SpecInitOptions,
|
|
156
|
+
): void {
|
|
157
|
+
const abs = path.resolve(cwd, rawTargetDir.trim() || ".");
|
|
158
|
+
const projectTitle = options.title.trim();
|
|
159
|
+
if (!projectTitle) {
|
|
160
|
+
throw new Error("spec init: title must be a non-empty string (after trim)");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (fs.existsSync(path.join(abs, POM_JSON_BASENAME))) {
|
|
164
|
+
throw new Error(`Refusing to init: ${POM_JSON_BASENAME} already exists: ${abs}`);
|
|
165
|
+
}
|
|
166
|
+
if (fs.existsSync(path.join(abs, "number.json"))) {
|
|
167
|
+
throw new Error(`Refusing to init: number.json already exists: ${abs}`);
|
|
168
|
+
}
|
|
169
|
+
if (fs.existsSync(path.join(abs, ".git"))) {
|
|
170
|
+
throw new Error(`Refusing to init: already a Git repository: ${abs}`);
|
|
171
|
+
}
|
|
172
|
+
if (fs.existsSync(abs) && fs.readdirSync(abs).length > 0) {
|
|
173
|
+
throw new Error(`Refusing to init: directory is not empty: ${abs}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
fs.mkdirSync(abs, { recursive: true });
|
|
177
|
+
fs.writeFileSync(
|
|
178
|
+
path.join(abs, POM_JSON_BASENAME),
|
|
179
|
+
`${JSON.stringify({ title: projectTitle }, null, 2)}\n`,
|
|
180
|
+
"utf8",
|
|
181
|
+
);
|
|
182
|
+
for (const d of ["changerequests", "requirements", "technical", "tests"]) {
|
|
183
|
+
const sub = path.join(abs, d);
|
|
184
|
+
fs.mkdirSync(sub, { recursive: true });
|
|
185
|
+
fs.writeFileSync(path.join(sub, ".gitkeep"), "", "utf8");
|
|
186
|
+
}
|
|
187
|
+
const hor = path.join(abs, "technical", "horizontals");
|
|
188
|
+
fs.mkdirSync(hor, { recursive: true });
|
|
189
|
+
fs.writeFileSync(path.join(hor, ".gitkeep"), "", "utf8");
|
|
190
|
+
fs.writeFileSync(
|
|
191
|
+
path.join(abs, "number.json"),
|
|
192
|
+
`${JSON.stringify(DEFAULT_NUMBER_JSON, null, 2)}\n`,
|
|
193
|
+
"utf8",
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (options.full) {
|
|
197
|
+
copyFullSpecTemplate(abs);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const cursorBody = options.full
|
|
201
|
+
? `${SPEC_SCAFFOLD_CURSOR_MD.replace(/\n$/, "")}${CURSOR_FULL_SCAFFOLD_APPEND}`
|
|
202
|
+
: SPEC_SCAFFOLD_CURSOR_MD;
|
|
203
|
+
fs.writeFileSync(path.join(abs, "CURSOR.md"), cursorBody, "utf8");
|
|
204
|
+
|
|
205
|
+
git(abs, ["init", "-b", "develop"]);
|
|
206
|
+
git(abs, ["config", "user.email", "waterfall-spec-init@local"]);
|
|
207
|
+
git(abs, ["config", "user.name", "waterfall spec init"]);
|
|
208
|
+
git(abs, ["add", "-A"]);
|
|
209
|
+
git(abs, [
|
|
210
|
+
"commit",
|
|
211
|
+
"-m",
|
|
212
|
+
options.full
|
|
213
|
+
? "chore: init waterfall spec scaffold (full)"
|
|
214
|
+
: "chore: init waterfall spec scaffold",
|
|
215
|
+
]);
|
|
216
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export type ResolveSpecRootOptions = {
|
|
5
|
+
env?: NodeJS.ProcessEnv;
|
|
6
|
+
cwd: string;
|
|
7
|
+
/** From `--spec-root` */
|
|
8
|
+
explicitSpecRoot?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolution order (STORY-018 — keep in sync with spec / tests):
|
|
13
|
+
* 1. Merged global `specRoot` (CLI `--spec-root` > `WATERFALL_SPEC_ROOT` > `./.waterfall` / `~/.waterfall` `spec_root`, cwd file wins) passed as explicitSpecRoot
|
|
14
|
+
* 2. `WATERFALL_SPEC_ROOT` (fallback if explicitSpecRoot unset)
|
|
15
|
+
* 3. Nearest `waterfall.json` (walking up from cwd): `waterfallSpecRelativePath` resolved from that file’s directory
|
|
16
|
+
* 4. Walk up from cwd for a directory containing `CURSOR.md`
|
|
17
|
+
*/
|
|
18
|
+
export function resolveSpecRoot(opts: ResolveSpecRootOptions): string {
|
|
19
|
+
const env = opts.env ?? process.env;
|
|
20
|
+
if (opts.explicitSpecRoot?.trim()) {
|
|
21
|
+
return path.resolve(opts.cwd, opts.explicitSpecRoot.trim());
|
|
22
|
+
}
|
|
23
|
+
if (env.WATERFALL_SPEC_ROOT?.trim()) {
|
|
24
|
+
return path.resolve(opts.cwd, env.WATERFALL_SPEC_ROOT.trim());
|
|
25
|
+
}
|
|
26
|
+
const fromWaterfallJson = findSpecViaWaterfallJson(opts.cwd);
|
|
27
|
+
if (fromWaterfallJson) return fromWaterfallJson;
|
|
28
|
+
const walked = walkForCursorPrompts(opts.cwd);
|
|
29
|
+
if (walked) return walked;
|
|
30
|
+
throw new Error(
|
|
31
|
+
"Could not resolve waterfall-spec root. Set WATERFALL_SPEC_ROOT, pass --spec-root, run from a directory with waterfall.json + waterfallSpecRelativePath, or from inside a waterfall-spec checkout (CURSOR.md).",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function findSpecViaWaterfallJson(start: string): string | undefined {
|
|
36
|
+
let dir = path.resolve(start);
|
|
37
|
+
const { root } = path.parse(dir);
|
|
38
|
+
for (;;) {
|
|
39
|
+
const wj = path.join(dir, "waterfall.json");
|
|
40
|
+
if (fs.existsSync(wj)) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = JSON.parse(fs.readFileSync(wj, "utf8")) as {
|
|
43
|
+
waterfallSpecRelativePath?: string;
|
|
44
|
+
};
|
|
45
|
+
if (raw.waterfallSpecRelativePath) {
|
|
46
|
+
const spec = path.resolve(dir, raw.waterfallSpecRelativePath);
|
|
47
|
+
if (isResolvableSpecPath(spec)) return spec;
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
/* ignore malformed */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (dir === root) break;
|
|
54
|
+
dir = path.dirname(dir);
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function walkForCursorPrompts(start: string): string | undefined {
|
|
60
|
+
let dir = path.resolve(start);
|
|
61
|
+
const { root } = path.parse(dir);
|
|
62
|
+
for (;;) {
|
|
63
|
+
if (isSpecRoot(dir)) return dir;
|
|
64
|
+
if (dir === root) break;
|
|
65
|
+
dir = path.dirname(dir);
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isSpecRoot(absPath: string): boolean {
|
|
71
|
+
const cursor = path.join(absPath, "CURSOR.md");
|
|
72
|
+
return fs.existsSync(cursor);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Scaffold from waterfall spec init: number.json + changerequests/ (CURSOR.md optional but now written by spec init). */
|
|
76
|
+
export function isScaffoldSpecRoot(absPath: string): boolean {
|
|
77
|
+
const num = path.join(absPath, "number.json");
|
|
78
|
+
const cr = path.join(absPath, "changerequests");
|
|
79
|
+
if (!fs.existsSync(num) || !fs.existsSync(cr)) return false;
|
|
80
|
+
return fs.statSync(cr).isDirectory();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isResolvableSpecPath(absPath: string): boolean {
|
|
84
|
+
return isSpecRoot(absPath) || isScaffoldSpecRoot(absPath);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function assertSpecRoot(absPath: string): void {
|
|
88
|
+
if (!isResolvableSpecPath(absPath)) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Not a valid waterfall-spec root (missing CURSOR.md or spec scaffold — number.json + changerequests/): ${absPath}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import {
|
|
5
|
+
parseWorkItemCanonicalId,
|
|
6
|
+
resolveItemCommentStorage,
|
|
7
|
+
type ItemCommentEntry,
|
|
8
|
+
type ItemCommentsFile,
|
|
9
|
+
type ItemCommentState,
|
|
10
|
+
} from "../comments/item-comments";
|
|
11
|
+
import type { WorkItemSyncKind } from "./provider-types";
|
|
12
|
+
import type { WorkItemComment, WorkItemRecord } from "./work-item-meta";
|
|
13
|
+
|
|
14
|
+
function parseBodyToStateAndHint(body: string): {
|
|
15
|
+
state: ItemCommentState;
|
|
16
|
+
hint: string;
|
|
17
|
+
} {
|
|
18
|
+
const m = body.match(/^\[(OPEN|RESOLVED|REJECTED)\]\s*([\s\S]*)$/);
|
|
19
|
+
if (m) {
|
|
20
|
+
return { state: m[1] as ItemCommentState, hint: m[2] ?? "" };
|
|
21
|
+
}
|
|
22
|
+
return { state: "OPEN", hint: body };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function stableCommentId(c: WorkItemComment): string {
|
|
26
|
+
const ext = c.externalId?.trim();
|
|
27
|
+
if (ext) return ext;
|
|
28
|
+
const h = createHash("sha256")
|
|
29
|
+
.update(`${c.email}\0${c.createdAt}\0${c.body}`)
|
|
30
|
+
.digest("hex")
|
|
31
|
+
.slice(0, 24);
|
|
32
|
+
return `sync:${h}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function remoteCommentToEntry(c: WorkItemComment): ItemCommentEntry {
|
|
36
|
+
const { state, hint } = parseBodyToStateAndHint(c.body);
|
|
37
|
+
const t = c.createdAt;
|
|
38
|
+
return {
|
|
39
|
+
id: stableCommentId(c),
|
|
40
|
+
from: c.user,
|
|
41
|
+
email: c.email,
|
|
42
|
+
hint,
|
|
43
|
+
state,
|
|
44
|
+
createdAt: t,
|
|
45
|
+
updatedAt: t,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* For each remote work item that exists in the spec tree, replace `{id}.comments.json` with the remote
|
|
51
|
+
* comment list (or an empty `comments` array when remote has none). Remote is authoritative for comments.
|
|
52
|
+
*/
|
|
53
|
+
export function applyRemoteCommentsToSpec(
|
|
54
|
+
specRoot: string,
|
|
55
|
+
kind: WorkItemSyncKind,
|
|
56
|
+
remoteItems: WorkItemRecord[],
|
|
57
|
+
specCanonicalIds: Set<string>,
|
|
58
|
+
): number {
|
|
59
|
+
const root = path.resolve(specRoot);
|
|
60
|
+
let written = 0;
|
|
61
|
+
for (const r of remoteItems) {
|
|
62
|
+
if (r.kind !== kind) continue;
|
|
63
|
+
if (!specCanonicalIds.has(r.canonicalId)) continue;
|
|
64
|
+
let storage: ReturnType<typeof resolveItemCommentStorage>;
|
|
65
|
+
try {
|
|
66
|
+
storage = resolveItemCommentStorage(root, r.canonicalId);
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const { canonicalId: itemId } = parseWorkItemCanonicalId(r.canonicalId);
|
|
71
|
+
const entries = (r.comments ?? []).map(remoteCommentToEntry);
|
|
72
|
+
entries.sort((a, b) =>
|
|
73
|
+
`${a.email}\t${a.createdAt}\t${a.from}`.localeCompare(
|
|
74
|
+
`${b.email}\t${b.createdAt}\t${b.from}`,
|
|
75
|
+
"en",
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
const file: ItemCommentsFile = { itemId, comments: entries };
|
|
79
|
+
fs.writeFileSync(
|
|
80
|
+
storage.commentsPath,
|
|
81
|
+
`${JSON.stringify(file, null, 2)}\n`,
|
|
82
|
+
"utf8",
|
|
83
|
+
);
|
|
84
|
+
written++;
|
|
85
|
+
}
|
|
86
|
+
return written;
|
|
87
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { WorkItemAttachmentCategory } from "./work-item-meta";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Map MIME type or filename to a coarse category for sync and UI.
|
|
5
|
+
*/
|
|
6
|
+
export function attachmentCategoryFromMimeOrName(
|
|
7
|
+
mimeType?: string,
|
|
8
|
+
name?: string,
|
|
9
|
+
): WorkItemAttachmentCategory {
|
|
10
|
+
const m = mimeType?.trim().toLowerCase() ?? "";
|
|
11
|
+
const base = name?.trim().toLowerCase() ?? "";
|
|
12
|
+
const ext = base.includes(".") ? base.slice(base.lastIndexOf(".")) : "";
|
|
13
|
+
|
|
14
|
+
if (m === "application/pdf" || ext === ".pdf") return "pdf";
|
|
15
|
+
if (
|
|
16
|
+
m.startsWith("image/") ||
|
|
17
|
+
[".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].includes(ext)
|
|
18
|
+
) {
|
|
19
|
+
return "image";
|
|
20
|
+
}
|
|
21
|
+
if (
|
|
22
|
+
m.startsWith("video/") ||
|
|
23
|
+
[".mp4", ".webm", ".mov", ".mkv", ".avi"].includes(ext)
|
|
24
|
+
) {
|
|
25
|
+
return "video";
|
|
26
|
+
}
|
|
27
|
+
if (
|
|
28
|
+
m.includes("spreadsheet") ||
|
|
29
|
+
m.includes("excel") ||
|
|
30
|
+
[".xls", ".xlsx", ".ods", ".csv"].includes(ext)
|
|
31
|
+
) {
|
|
32
|
+
return "spreadsheet";
|
|
33
|
+
}
|
|
34
|
+
if (
|
|
35
|
+
m.includes("word") ||
|
|
36
|
+
m.includes("msword") ||
|
|
37
|
+
m.includes("wordprocessingml") ||
|
|
38
|
+
[".doc", ".docx", ".odt", ".rtf"].includes(ext)
|
|
39
|
+
) {
|
|
40
|
+
return "document";
|
|
41
|
+
}
|
|
42
|
+
return "other";
|
|
43
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { WorkItemComment, WorkItemRecord } from "./work-item-meta";
|
|
2
|
+
|
|
3
|
+
export type WorkItemDiffEntry = {
|
|
4
|
+
canonicalId: string;
|
|
5
|
+
field: "missing" | "state" | "title" | "comments" | "attachments";
|
|
6
|
+
spec?: string;
|
|
7
|
+
remote?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Stable compare: author = `email`; sort by `email` + `createdAt`, row includes `user` + `body`. */
|
|
11
|
+
function commentsSignature(c: WorkItemComment[] | undefined): string {
|
|
12
|
+
if (!c?.length) return "";
|
|
13
|
+
return JSON.stringify(
|
|
14
|
+
[...c].sort((a, b) =>
|
|
15
|
+
`${a.email}\t${a.createdAt}\t${a.user}`.localeCompare(
|
|
16
|
+
`${b.email}\t${b.createdAt}\t${b.user}`,
|
|
17
|
+
"en",
|
|
18
|
+
),
|
|
19
|
+
).map((x) => ({
|
|
20
|
+
email: x.email,
|
|
21
|
+
user: x.user,
|
|
22
|
+
createdAt: x.createdAt,
|
|
23
|
+
body: x.body,
|
|
24
|
+
})),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function attachmentsSignature(
|
|
29
|
+
a: WorkItemRecord["attachments"],
|
|
30
|
+
): string {
|
|
31
|
+
if (!a?.length) return "";
|
|
32
|
+
return JSON.stringify(
|
|
33
|
+
[...a].sort((x, y) => x.id.localeCompare(y.id, "en")),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type WorkItemDiffReport = {
|
|
38
|
+
onlyInSpec: string[];
|
|
39
|
+
onlyInRemote: string[];
|
|
40
|
+
mismatches: WorkItemDiffEntry[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function byId(items: WorkItemRecord[]): Map<string, WorkItemRecord> {
|
|
44
|
+
const m = new Map<string, WorkItemRecord>();
|
|
45
|
+
for (const i of items) m.set(i.canonicalId, i);
|
|
46
|
+
return m;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compare spec snapshot vs remote snapshot (same kind).
|
|
51
|
+
* Comments: compared on author `email` + `createdAt` order and per-row `user` + `body` (see {@link commentsSignature}).
|
|
52
|
+
*/
|
|
53
|
+
export function diffWorkItemSets(
|
|
54
|
+
specItems: WorkItemRecord[],
|
|
55
|
+
remoteItems: WorkItemRecord[],
|
|
56
|
+
): WorkItemDiffReport {
|
|
57
|
+
const A = byId(specItems);
|
|
58
|
+
const B = byId(remoteItems);
|
|
59
|
+
const onlyInSpec: string[] = [];
|
|
60
|
+
const onlyInRemote: string[] = [];
|
|
61
|
+
const mismatches: WorkItemDiffEntry[] = [];
|
|
62
|
+
|
|
63
|
+
for (const id of A.keys()) {
|
|
64
|
+
if (!B.has(id)) onlyInSpec.push(id);
|
|
65
|
+
}
|
|
66
|
+
for (const id of B.keys()) {
|
|
67
|
+
if (!A.has(id)) onlyInRemote.push(id);
|
|
68
|
+
}
|
|
69
|
+
for (const id of A.keys()) {
|
|
70
|
+
const a = A.get(id)!;
|
|
71
|
+
const b = B.get(id);
|
|
72
|
+
if (!b) continue;
|
|
73
|
+
if (a.state !== b.state) {
|
|
74
|
+
mismatches.push({
|
|
75
|
+
canonicalId: id,
|
|
76
|
+
field: "state",
|
|
77
|
+
spec: a.state,
|
|
78
|
+
remote: b.state,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (a.title.trim() !== b.title.trim()) {
|
|
82
|
+
mismatches.push({
|
|
83
|
+
canonicalId: id,
|
|
84
|
+
field: "title",
|
|
85
|
+
spec: a.title,
|
|
86
|
+
remote: b.title,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const ca = commentsSignature(a.comments);
|
|
90
|
+
const cb = commentsSignature(b.comments);
|
|
91
|
+
if (ca !== cb) {
|
|
92
|
+
mismatches.push({
|
|
93
|
+
canonicalId: id,
|
|
94
|
+
field: "comments",
|
|
95
|
+
spec: `${a.comments?.length ?? 0} (digest len ${ca.length})`,
|
|
96
|
+
remote: `${b.comments?.length ?? 0} (digest len ${cb.length})`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const aa = attachmentsSignature(a.attachments);
|
|
100
|
+
const ab = attachmentsSignature(b.attachments);
|
|
101
|
+
if (aa !== ab) {
|
|
102
|
+
mismatches.push({
|
|
103
|
+
canonicalId: id,
|
|
104
|
+
field: "attachments",
|
|
105
|
+
spec: `${a.attachments?.length ?? 0} (digest len ${aa.length})`,
|
|
106
|
+
remote: `${b.attachments?.length ?? 0} (digest len ${ab.length})`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
onlyInSpec.sort();
|
|
111
|
+
onlyInRemote.sort();
|
|
112
|
+
return { onlyInSpec, onlyInRemote, mismatches };
|
|
113
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { parseWorkItemCanonicalId } from "../comments/item-comments";
|
|
2
|
+
import { inferSysIdForRemoteBug } from "../work-items/infer-bug-sys";
|
|
3
|
+
import { buildRemoteBugImportSummaryBody } from "../work-items/remote-bug-import-scaffold";
|
|
4
|
+
import { writeBugToSpec } from "../work-items/write-bug-to-spec";
|
|
5
|
+
import type { WorkItemRecord } from "./work-item-meta";
|
|
6
|
+
|
|
7
|
+
export { resolveBugParentSysDir } from "../work-items/write-bug-to-spec";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create spec folders + `BUG-NNN.md` for each remote bug whose id is not yet in the spec tree.
|
|
11
|
+
* Only intended when sync leading is `remote` (remote is authoritative for new ids).
|
|
12
|
+
*
|
|
13
|
+
* Resolves **SYS** via {@link inferSysIdForRemoteBug} when `sysId` is omitted.
|
|
14
|
+
* Scaffold matches `bug start` (Draft steering for agent refinement).
|
|
15
|
+
*
|
|
16
|
+
* @returns Spec-relative paths to created `BUG-NNN.md` files (forward slashes).
|
|
17
|
+
*/
|
|
18
|
+
export function materializeRemoteOnlyBugsToSpec(
|
|
19
|
+
specRoot: string,
|
|
20
|
+
remoteItems: WorkItemRecord[],
|
|
21
|
+
specCanonicalIds: Set<string>,
|
|
22
|
+
): string[] {
|
|
23
|
+
const root = specRoot;
|
|
24
|
+
const mdRels: string[] = [];
|
|
25
|
+
for (const r of remoteItems) {
|
|
26
|
+
if (r.kind !== "bug") continue;
|
|
27
|
+
const { canonicalId } = parseWorkItemCanonicalId(r.canonicalId);
|
|
28
|
+
if (!/^BUG-\d{3}$/i.test(canonicalId)) continue;
|
|
29
|
+
if (specCanonicalIds.has(canonicalId)) continue;
|
|
30
|
+
|
|
31
|
+
let sysId: string;
|
|
32
|
+
try {
|
|
33
|
+
sysId = inferSysIdForRemoteBug(root, r);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`[waterfall] sync bugs: cannot import ${canonicalId} — ${(e as Error).message}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const { mdRel } = writeBugToSpec({
|
|
42
|
+
specRoot: root,
|
|
43
|
+
canonicalId,
|
|
44
|
+
title: r.title?.trim() || canonicalId,
|
|
45
|
+
state: r.state,
|
|
46
|
+
sysId,
|
|
47
|
+
summaryBody: buildRemoteBugImportSummaryBody(r),
|
|
48
|
+
});
|
|
49
|
+
mdRels.push(mdRel);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
const msg = (e as Error).message;
|
|
52
|
+
if (msg.includes("no `technical/SYS-NNN")) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`[waterfall] sync bugs: cannot import ${canonicalId} — no \`technical/SYS-NNN-*\` folder under spec; add a subsystem or set \`sysId\` in the remote item and ensure that SYS exists.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (msg.includes("folder already exists")) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`[waterfall] sync bugs: cannot import ${canonicalId} — folder already exists under spec`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return mdRels;
|
|
66
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { WorkItemRecord } from "./work-item-meta";
|
|
2
|
+
|
|
3
|
+
export type WorkItemSyncKind = "story" | "bug";
|
|
4
|
+
|
|
5
|
+
export type WorkItemProviderContext = {
|
|
6
|
+
cwd: string;
|
|
7
|
+
specRoot: string;
|
|
8
|
+
kind: WorkItemSyncKind;
|
|
9
|
+
/** Keys from .waterfall `sync_<kind>_<provider>_*` (suffix after sync_story_ / sync_bug_ excluding provider). */
|
|
10
|
+
options: Record<string, string>;
|
|
11
|
+
/**
|
|
12
|
+
* Optional CLI hint (inline argv joined, `-` stdin, `@PATH` file). Most providers ignore it;
|
|
13
|
+
* `direct` treats it as JSON in the same shape as `--dump-spec`.
|
|
14
|
+
*/
|
|
15
|
+
hint?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* External tracker integration. Implementations map between {@link WorkItemRecord} and the remote API.
|
|
20
|
+
* Sync applies remote comments to spec, diffs, then adapter-specific output (e.g. direct prints spec JSON).
|
|
21
|
+
*/
|
|
22
|
+
export interface WorkItemSyncProvider {
|
|
23
|
+
readonly providerId: string;
|
|
24
|
+
|
|
25
|
+
/** Items as seen in the remote system (canonical ids should align with spec when linked). Sync for now; HTTP adapters may move to async later. */
|
|
26
|
+
fetchRemote(ctx: WorkItemProviderContext): WorkItemRecord[];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Push spec-side changes to remote (issues create/update, transitions). Optional until implemented.
|
|
30
|
+
*/
|
|
31
|
+
pushToRemote?(
|
|
32
|
+
ctx: WorkItemProviderContext,
|
|
33
|
+
items: WorkItemRecord[],
|
|
34
|
+
): Promise<void>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Apply remote changes into the spec tree. Optional until implemented.
|
|
38
|
+
*/
|
|
39
|
+
pullToSpec?(
|
|
40
|
+
ctx: WorkItemProviderContext,
|
|
41
|
+
items: WorkItemRecord[],
|
|
42
|
+
): Promise<void>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WorkItemProviderContext,
|
|
3
|
+
WorkItemSyncProvider,
|
|
4
|
+
} from "../provider-types";
|
|
5
|
+
import type { WorkItemRecord } from "../work-item-meta";
|
|
6
|
+
import { parseWorkItemRecordsFromSyncHintJson } from "../sync-hint-json";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Uses the CLI hint as remote-side JSON: same shape as `--dump-spec` output (object with `items`,
|
|
10
|
+
* optional `kind` / `source` / …) or a top-level array of {@link WorkItemRecord} objects.
|
|
11
|
+
* Requires a non-empty hint; validates strictly and returns normalized items for diff/merge paths.
|
|
12
|
+
* The sync runner always appends the current spec snapshot to stdout as JSON (`source`:
|
|
13
|
+
* `waterfall-direct-out`) after the text report when using this provider.
|
|
14
|
+
*/
|
|
15
|
+
export class DirectWorkItemSyncProvider implements WorkItemSyncProvider {
|
|
16
|
+
readonly providerId = "direct";
|
|
17
|
+
|
|
18
|
+
fetchRemote(ctx: WorkItemProviderContext): WorkItemRecord[] {
|
|
19
|
+
const hint = ctx.hint?.trim();
|
|
20
|
+
if (!hint) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`[waterfall] direct provider: pass a sync JSON hint (inline argv, - for stdin, or @PATH); same shape as sync --dump-spec.`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return parseWorkItemRecordsFromSyncHintJson(hint, ctx.kind);
|
|
26
|
+
}
|
|
27
|
+
}
|