@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,59 @@
|
|
|
1
|
+
import { AGENT_GLOBAL_PROMPTS_LIFECYCLE } from "../agent/global-prompts";
|
|
2
|
+
import { runSyncWorkItems } from "../sync/run-sync-work-items";
|
|
3
|
+
import type { WaterfallCommand } from "./types";
|
|
4
|
+
import type { WorkItemSyncKind } from "../sync/provider-types";
|
|
5
|
+
import { resolveOperatorHint } from "./resolve-operator-hint";
|
|
6
|
+
|
|
7
|
+
function parseSyncArgv(
|
|
8
|
+
rest: string[],
|
|
9
|
+
cwd: string,
|
|
10
|
+
): { dumpSpec: boolean; hint?: string } {
|
|
11
|
+
const dumpSpec = rest.includes("--dump-spec");
|
|
12
|
+
if (dumpSpec) {
|
|
13
|
+
return { dumpSpec: true };
|
|
14
|
+
}
|
|
15
|
+
const parts = rest.slice(2).filter((t) => t !== "--dump-spec");
|
|
16
|
+
const { text } = resolveOperatorHint(parts, { cwd });
|
|
17
|
+
return { dumpSpec: false, hint: text || undefined };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeSyncCommand(kind: WorkItemSyncKind): WaterfallCommand {
|
|
21
|
+
const label = kind === "story" ? "stories" : "bugs";
|
|
22
|
+
return {
|
|
23
|
+
matches: (r) => r[0] === "sync" && r[1] === label,
|
|
24
|
+
run(ctx, rest) {
|
|
25
|
+
try {
|
|
26
|
+
const block = kind === "story" ? ctx.g.syncStory : ctx.g.syncBug;
|
|
27
|
+
runSyncWorkItems(
|
|
28
|
+
ctx.specRoot,
|
|
29
|
+
ctx.g.cwd,
|
|
30
|
+
kind,
|
|
31
|
+
block,
|
|
32
|
+
ctx.g.logLevel,
|
|
33
|
+
parseSyncArgv(rest, ctx.g.cwd),
|
|
34
|
+
kind === "bug"
|
|
35
|
+
? {
|
|
36
|
+
afterBugsMaterialized: ({ mdRels }) => {
|
|
37
|
+
if (mdRels.length === 0) return;
|
|
38
|
+
const list = mdRels.map((r) => `- ${r}`).join("\n");
|
|
39
|
+
ctx.runAgentPrompt(
|
|
40
|
+
"prompts/commands/sync-bugs-refine-imports.md",
|
|
41
|
+
`BUG markdown was just imported from remote sync (this run). Refine each file below per the prompt (Summary + remove Draft steering). Comments were written to matching *.comments.json already.\n\n${list}\n`,
|
|
42
|
+
AGENT_GLOBAL_PROMPTS_LIFECYCLE,
|
|
43
|
+
`waterfall sync bugs — refine ${mdRels.length} imported BUG(s)`,
|
|
44
|
+
);
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
: undefined,
|
|
48
|
+
);
|
|
49
|
+
return 0;
|
|
50
|
+
} catch (e) {
|
|
51
|
+
process.stderr.write(`${(e as Error).message}\n`);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const syncStoriesCommand = makeSyncCommand("story");
|
|
59
|
+
export const syncBugsCommand = makeSyncCommand("bug");
|
|
@@ -0,0 +1,96 @@
|
|
|
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 { AGENT_GLOBAL_PROMPTS_LIFECYCLE } from "../agent/global-prompts";
|
|
8
|
+
import { resolveOperatorHint } from "./resolve-operator-hint";
|
|
9
|
+
import type { WaterfallCommand } from "./types";
|
|
10
|
+
|
|
11
|
+
type NumberJson = { next: { SYS: number } };
|
|
12
|
+
|
|
13
|
+
function git(specRoot: string, args: string[]): void {
|
|
14
|
+
gitExecSync(specRoot, args, { stdio: "inherit" });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatSysId(n: number): string {
|
|
18
|
+
return String(n).padStart(3, "0");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates a new SYS folder and scaffold document.
|
|
23
|
+
* Mutates spec repo: new technical/SYS-* folder, number.json bump, commit.
|
|
24
|
+
*/
|
|
25
|
+
export function runSysStart(specRoot: string, description: string): void {
|
|
26
|
+
assertSpecRoot(specRoot);
|
|
27
|
+
const slug = slugifyDescription(description);
|
|
28
|
+
const numPath = path.join(specRoot, "number.json");
|
|
29
|
+
const raw = JSON.parse(fs.readFileSync(numPath, "utf8")) as NumberJson;
|
|
30
|
+
const n = raw.next.SYS;
|
|
31
|
+
const id = formatSysId(n);
|
|
32
|
+
const base = `SYS-${id}-${slug}`;
|
|
33
|
+
|
|
34
|
+
const sysDir = path.join(specRoot, "technical", base);
|
|
35
|
+
if (fs.existsSync(sysDir)) {
|
|
36
|
+
throw new Error(`SYS folder already exists: technical/${base}`);
|
|
37
|
+
}
|
|
38
|
+
fs.mkdirSync(sysDir, { recursive: true });
|
|
39
|
+
|
|
40
|
+
const mdPath = path.join(sysDir, `SYS-${id}.md`);
|
|
41
|
+
const headingTitle = scaffoldHeadingFromDescription(description, slug);
|
|
42
|
+
const steeringLines = description
|
|
43
|
+
.trim()
|
|
44
|
+
.split(/\r?\n/)
|
|
45
|
+
.map((line) => (line.length > 0 ? `> ${line}` : ">"));
|
|
46
|
+
const steeringBlock =
|
|
47
|
+
steeringLines.length > 0 ? steeringLines.join("\n") : "> _(empty)_";
|
|
48
|
+
|
|
49
|
+
const md = `# SYS-${id} — ${headingTitle}
|
|
50
|
+
|
|
51
|
+
**Id:** SYS-${id}
|
|
52
|
+
**Status:** draft
|
|
53
|
+
|
|
54
|
+
## Links and dependencies
|
|
55
|
+
|
|
56
|
+
### Downstream references
|
|
57
|
+
|
|
58
|
+
- (none)
|
|
59
|
+
|
|
60
|
+
## Purpose
|
|
61
|
+
|
|
62
|
+
*(Replace with subsystem purpose and boundaries per \`prompts/items/sys-document-structure.md\`. **Synthesize** from **Draft steering (CLI)** below — do not paste that block verbatim as the only Purpose text. Remove **Draft steering** when done.)*
|
|
63
|
+
|
|
64
|
+
## Draft steering (CLI)
|
|
65
|
+
|
|
66
|
+
The operator supplied this when running \`waterfall sys start\`. **Scratch input only**:
|
|
67
|
+
|
|
68
|
+
${steeringBlock}
|
|
69
|
+
`;
|
|
70
|
+
fs.writeFileSync(mdPath, md, "utf8");
|
|
71
|
+
|
|
72
|
+
raw.next.SYS = n + 1;
|
|
73
|
+
fs.writeFileSync(numPath, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
|
|
74
|
+
|
|
75
|
+
git(specRoot, ["add", `technical/${base}/SYS-${id}.md`, "number.json"]);
|
|
76
|
+
git(specRoot, ["commit", "-m", `feat(SYS-${id}): scaffold subsystem`]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const sysStartCommand: WaterfallCommand = {
|
|
80
|
+
matches: (r) => r[0] === "sys" && r[1] === "start",
|
|
81
|
+
run(ctx, rest) {
|
|
82
|
+
const [, , a2, ...tail] = rest;
|
|
83
|
+
const { text } = resolveOperatorHint([a2, ...tail].filter(Boolean), {
|
|
84
|
+
required: true,
|
|
85
|
+
});
|
|
86
|
+
assertBranchForCrScopedCommand(ctx.specRoot, "waterfall sys start");
|
|
87
|
+
runSysStart(ctx.specRoot, text);
|
|
88
|
+
ctx.runAgentPrompt(
|
|
89
|
+
"prompts/commands/sys-start.md",
|
|
90
|
+
text,
|
|
91
|
+
AGENT_GLOBAL_PROMPTS_LIFECYCLE,
|
|
92
|
+
"waterfall sys start — refine SYS prose",
|
|
93
|
+
);
|
|
94
|
+
return 0;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { AGENT_GLOBAL_PROMPTS_LIFECYCLE } from "../agent/global-prompts";
|
|
2
|
+
import { ORCHESTRATE_TEST_ALL_ORDER } from "../core/prompt-map";
|
|
3
|
+
import { resolveOperatorHint } from "./resolve-operator-hint";
|
|
4
|
+
import type { WaterfallCommand } from "./types";
|
|
5
|
+
|
|
6
|
+
export const testAllCommand: WaterfallCommand = {
|
|
7
|
+
matches: (r) => r[0] === "test" && r[1] === "all",
|
|
8
|
+
run(ctx, rest) {
|
|
9
|
+
const tail = rest.slice(2);
|
|
10
|
+
ctx.runOrchestrate(
|
|
11
|
+
ORCHESTRATE_TEST_ALL_ORDER,
|
|
12
|
+
"waterfall test 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 testToStoryCommand: WaterfallCommand = {
|
|
7
|
+
matches: (r) => r[0] === "test" && r[1] === "to" && r[2] === "story",
|
|
8
|
+
run(ctx, rest) {
|
|
9
|
+
assertBranchForCrScopedCommand(ctx.specRoot, "waterfall test to story");
|
|
10
|
+
const tail = rest.slice(3);
|
|
11
|
+
ctx.runAgentPrompt(
|
|
12
|
+
"prompts/commands/test-to-story.md",
|
|
13
|
+
resolveOperatorHint(tail, { required: true }).text,
|
|
14
|
+
AGENT_GLOBAL_PROMPTS_LIFECYCLE,
|
|
15
|
+
);
|
|
16
|
+
return 0;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { GlobalCli } from "../config/global-cli";
|
|
2
|
+
import type { WaterfallGlobalPrompt } from "../agent/global-prompts";
|
|
3
|
+
import type { LifecycleStep } from "../core/prompt-map";
|
|
4
|
+
|
|
5
|
+
/** Optional flags for `runAgentPrompt` (4th/5th parameters). */
|
|
6
|
+
export type RunAgentPromptOptions = {
|
|
7
|
+
/** @default true — set false when the hint already includes develop...HEAD path scope. */
|
|
8
|
+
attachCrBranchPaths?: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Per-command execution: branch guards, git, filesystem, or agent prompt. */
|
|
12
|
+
export type CommandRunContext = {
|
|
13
|
+
g: GlobalCli;
|
|
14
|
+
specRoot: string;
|
|
15
|
+
runAgentPrompt: (
|
|
16
|
+
relPrompt: string,
|
|
17
|
+
hint: string,
|
|
18
|
+
globalPrompts: WaterfallGlobalPrompt[],
|
|
19
|
+
orchestrationContext?: string,
|
|
20
|
+
runOpts?: RunAgentPromptOptions,
|
|
21
|
+
) => void;
|
|
22
|
+
runOrchestrate: (
|
|
23
|
+
order: LifecycleStep[],
|
|
24
|
+
batchLabel: string,
|
|
25
|
+
hint: string,
|
|
26
|
+
globalPrompts: WaterfallGlobalPrompt[],
|
|
27
|
+
) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type WaterfallCommand = {
|
|
31
|
+
matches(rest: string[]): boolean;
|
|
32
|
+
run(ctx: CommandRunContext, rest: string[]): number;
|
|
33
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { AGENT_GLOBAL_PROMPTS_LIFECYCLE } from "../agent/global-prompts";
|
|
2
|
+
import { ORCHESTRATE_UC_ALL_ORDER } from "../core/prompt-map";
|
|
3
|
+
import { resolveOperatorHint } from "./resolve-operator-hint";
|
|
4
|
+
import type { WaterfallCommand } from "./types";
|
|
5
|
+
|
|
6
|
+
export const ucAllCommand: WaterfallCommand = {
|
|
7
|
+
matches: (r) => r[0] === "uc" && r[1] === "all",
|
|
8
|
+
run(ctx, rest) {
|
|
9
|
+
const tail = rest.slice(2);
|
|
10
|
+
ctx.runOrchestrate(
|
|
11
|
+
ORCHESTRATE_UC_ALL_ORDER,
|
|
12
|
+
"waterfall uc 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 ucToStoryCommand: WaterfallCommand = {
|
|
7
|
+
matches: (r) => r[0] === "uc" && r[1] === "to" && r[2] === "story",
|
|
8
|
+
run(ctx, rest) {
|
|
9
|
+
assertBranchForCrScopedCommand(ctx.specRoot, "waterfall uc to story");
|
|
10
|
+
const tail = rest.slice(3);
|
|
11
|
+
ctx.runAgentPrompt(
|
|
12
|
+
"prompts/commands/uc-to-story.md",
|
|
13
|
+
resolveOperatorHint(tail, { required: true }).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 ucToTestCommand: WaterfallCommand = {
|
|
7
|
+
matches: (r) => r[0] === "uc" && r[1] === "to" && r[2] === "test",
|
|
8
|
+
run(ctx, rest) {
|
|
9
|
+
assertBranchForCrScopedCommand(ctx.specRoot, "waterfall uc to test");
|
|
10
|
+
const tail = rest.slice(3);
|
|
11
|
+
ctx.runAgentPrompt(
|
|
12
|
+
"prompts/commands/uc-to-test.md",
|
|
13
|
+
resolveOperatorHint(tail, { required: true }).text,
|
|
14
|
+
AGENT_GLOBAL_PROMPTS_LIFECYCLE,
|
|
15
|
+
);
|
|
16
|
+
return 0;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
/** Spec item families that have a canonical `{PREFIX}-{NNN}.md` (same set as export pdf codes, minus CURSOR). */
|
|
6
|
+
export const ITEM_COMMENT_PREFIXES = [
|
|
7
|
+
"CR",
|
|
8
|
+
"RQ",
|
|
9
|
+
"UC",
|
|
10
|
+
"SYS",
|
|
11
|
+
"STORY",
|
|
12
|
+
"BUG",
|
|
13
|
+
"HOR",
|
|
14
|
+
"TST",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export type ItemCommentPrefix = (typeof ITEM_COMMENT_PREFIXES)[number];
|
|
18
|
+
|
|
19
|
+
const PREFIX_PARSE_RE = new RegExp(
|
|
20
|
+
`^(${ITEM_COMMENT_PREFIXES.join("|")})-(\\d+)$`,
|
|
21
|
+
"i",
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const SKIP_WALK_DIRS = new Set([
|
|
25
|
+
"node_modules",
|
|
26
|
+
".git",
|
|
27
|
+
"dist",
|
|
28
|
+
"build",
|
|
29
|
+
"coverage",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export type ItemCommentState = "OPEN" | "RESOLVED" | "REJECTED";
|
|
33
|
+
|
|
34
|
+
export type ItemCommentEntry = {
|
|
35
|
+
id: string;
|
|
36
|
+
/** Display name. */
|
|
37
|
+
from: string;
|
|
38
|
+
/** Author identity (sync key; matches WorkItemComment.email). */
|
|
39
|
+
email: string;
|
|
40
|
+
/** Free-form remark; can later be incorporated into the spec markdown. */
|
|
41
|
+
hint: string;
|
|
42
|
+
state: ItemCommentState;
|
|
43
|
+
createdAt: string;
|
|
44
|
+
updatedAt: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type ItemCommentsFile = {
|
|
48
|
+
itemId: string;
|
|
49
|
+
comments: ItemCommentEntry[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const VALID_STATES = new Set<ItemCommentState>(["OPEN", "RESOLVED", "REJECTED"]);
|
|
53
|
+
|
|
54
|
+
export function parseWorkItemCanonicalId(raw: string): {
|
|
55
|
+
prefix: ItemCommentPrefix;
|
|
56
|
+
canonicalId: string;
|
|
57
|
+
} {
|
|
58
|
+
const t = raw.trim();
|
|
59
|
+
const m = t.match(PREFIX_PARSE_RE);
|
|
60
|
+
if (!m) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Invalid item id "${raw}". Use ${ITEM_COMMENT_PREFIXES.join(", ")}-NNN (e.g. STORY-001, CR-002, HOR-003).`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const prefix = m[1]!.toUpperCase() as ItemCommentPrefix;
|
|
66
|
+
const padded = String(parseInt(m[2]!, 10)).padStart(3, "0");
|
|
67
|
+
const canonicalId = `${prefix}-${padded}`;
|
|
68
|
+
return { prefix, canonicalId };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function mdStemMatchesCanonical(stem: string, canonicalId: string): boolean {
|
|
72
|
+
const s = stem.toLowerCase();
|
|
73
|
+
const id = canonicalId.toLowerCase();
|
|
74
|
+
return s === id || s.startsWith(`${id}-`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Find the markdown file for an item under the spec tree: exact `{id}.md` or `{id}-*.md`
|
|
79
|
+
* (e.g. `CR-001.md`, `HOR-001-scope.md`). If several candidates exist, prefers exact `{id}.md`.
|
|
80
|
+
*/
|
|
81
|
+
export function findCanonicalItemMarkdown(
|
|
82
|
+
specRoot: string,
|
|
83
|
+
canonicalId: string,
|
|
84
|
+
): string {
|
|
85
|
+
const { canonicalId: id } = parseWorkItemCanonicalId(canonicalId);
|
|
86
|
+
const root = path.resolve(specRoot);
|
|
87
|
+
const matches: string[] = [];
|
|
88
|
+
const stack = [root];
|
|
89
|
+
while (stack.length) {
|
|
90
|
+
const d = stack.pop()!;
|
|
91
|
+
let entries: fs.Dirent[];
|
|
92
|
+
try {
|
|
93
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
94
|
+
} catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
for (const e of entries) {
|
|
98
|
+
const p = path.join(d, e.name);
|
|
99
|
+
if (e.isDirectory()) {
|
|
100
|
+
if (e.name.startsWith(".")) continue;
|
|
101
|
+
if (SKIP_WALK_DIRS.has(e.name)) continue;
|
|
102
|
+
stack.push(p);
|
|
103
|
+
} else if (e.isFile() && /\.md$/i.test(e.name)) {
|
|
104
|
+
const stem = e.name.replace(/\.md$/i, "");
|
|
105
|
+
if (mdStemMatchesCanonical(stem, id)) {
|
|
106
|
+
matches.push(p);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
matches.sort();
|
|
112
|
+
if (matches.length === 0) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`No markdown for ${id} under ${specRoot} (expected ${id}.md or ${id}-*.md).`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (matches.length === 1) {
|
|
118
|
+
return matches[0]!;
|
|
119
|
+
}
|
|
120
|
+
const exactName = `${id}.md`.toLowerCase();
|
|
121
|
+
const exactHits = matches.filter(
|
|
122
|
+
(p) => path.basename(p).toLowerCase() === exactName,
|
|
123
|
+
);
|
|
124
|
+
if (exactHits.length === 1) {
|
|
125
|
+
return exactHits[0]!;
|
|
126
|
+
}
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Multiple markdown files for ${id}: ${matches.map((x) => path.relative(root, x)).join(", ")}.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Directory containing the canonical `{id}.md`. */
|
|
133
|
+
export function findWorkItemDir(specRoot: string, canonicalId: string): string {
|
|
134
|
+
return path.dirname(findCanonicalItemMarkdown(specRoot, canonicalId));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `{ITEM-ID}.comments.json` next to the item markdown (same directory as `{ITEM}.md`). */
|
|
138
|
+
export function itemCommentsJsonPath(
|
|
139
|
+
itemDir: string,
|
|
140
|
+
canonicalId: string,
|
|
141
|
+
): string {
|
|
142
|
+
const { canonicalId: id } = parseWorkItemCanonicalId(canonicalId);
|
|
143
|
+
return path.join(itemDir, `${id}.comments.json`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function resolveItemCommentStorage(
|
|
147
|
+
specRoot: string,
|
|
148
|
+
canonicalId: string,
|
|
149
|
+
): { itemDir: string; mdPath: string; commentsPath: string } {
|
|
150
|
+
const mdPath = findCanonicalItemMarkdown(specRoot, canonicalId);
|
|
151
|
+
const itemDir = path.dirname(mdPath);
|
|
152
|
+
const commentsPath = itemCommentsJsonPath(itemDir, canonicalId);
|
|
153
|
+
return { itemDir, mdPath, commentsPath };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseCommentEntry(
|
|
157
|
+
x: unknown,
|
|
158
|
+
index: number,
|
|
159
|
+
filePath: string,
|
|
160
|
+
): ItemCommentEntry {
|
|
161
|
+
if (!x || typeof x !== "object") {
|
|
162
|
+
throw new Error(`${filePath}: comments[${index}] must be an object`);
|
|
163
|
+
}
|
|
164
|
+
const o = x as Record<string, unknown>;
|
|
165
|
+
const id = o.id;
|
|
166
|
+
const from = o.from;
|
|
167
|
+
const emailRaw = o.email;
|
|
168
|
+
const hint = o.hint;
|
|
169
|
+
const state = o.state;
|
|
170
|
+
const createdAt = o.createdAt;
|
|
171
|
+
const updatedAt = o.updatedAt;
|
|
172
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
173
|
+
throw new Error(`${filePath}: comments[${index}].id must be a non-empty string`);
|
|
174
|
+
}
|
|
175
|
+
if (typeof from !== "string") {
|
|
176
|
+
throw new Error(`${filePath}: comments[${index}].from must be a string`);
|
|
177
|
+
}
|
|
178
|
+
let email = "";
|
|
179
|
+
if (emailRaw !== undefined) {
|
|
180
|
+
if (typeof emailRaw !== "string") {
|
|
181
|
+
throw new Error(`${filePath}: comments[${index}].email must be a string`);
|
|
182
|
+
}
|
|
183
|
+
email = emailRaw.trim();
|
|
184
|
+
}
|
|
185
|
+
if (typeof hint !== "string") {
|
|
186
|
+
throw new Error(`${filePath}: comments[${index}].hint must be a string`);
|
|
187
|
+
}
|
|
188
|
+
if (typeof state !== "string" || !VALID_STATES.has(state as ItemCommentState)) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`${filePath}: comments[${index}].state must be OPEN | RESOLVED | REJECTED`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (typeof createdAt !== "string" || typeof updatedAt !== "string") {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`${filePath}: comments[${index}].createdAt and updatedAt must be strings`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
id,
|
|
200
|
+
from,
|
|
201
|
+
email,
|
|
202
|
+
hint,
|
|
203
|
+
state: state as ItemCommentState,
|
|
204
|
+
createdAt,
|
|
205
|
+
updatedAt,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Read and validate a comments file; returns `null` if the file is missing. */
|
|
210
|
+
export function readItemCommentsFile(filePath: string): ItemCommentsFile | null {
|
|
211
|
+
if (!fs.existsSync(filePath)) return null;
|
|
212
|
+
let raw: string;
|
|
213
|
+
try {
|
|
214
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
215
|
+
} catch {
|
|
216
|
+
throw new Error(`Cannot read ${filePath}`);
|
|
217
|
+
}
|
|
218
|
+
let j: unknown;
|
|
219
|
+
try {
|
|
220
|
+
j = JSON.parse(raw);
|
|
221
|
+
} catch (e) {
|
|
222
|
+
throw new Error(`Invalid JSON in ${filePath}: ${(e as Error).message}`);
|
|
223
|
+
}
|
|
224
|
+
if (!j || typeof j !== "object") {
|
|
225
|
+
throw new Error(`${filePath}: root must be an object`);
|
|
226
|
+
}
|
|
227
|
+
const o = j as Record<string, unknown>;
|
|
228
|
+
if (typeof o.itemId !== "string") {
|
|
229
|
+
throw new Error(`${filePath}: missing string itemId`);
|
|
230
|
+
}
|
|
231
|
+
if (!Array.isArray(o.comments)) {
|
|
232
|
+
throw new Error(`${filePath}: comments must be an array`);
|
|
233
|
+
}
|
|
234
|
+
const comments = o.comments.map((c, i) => parseCommentEntry(c, i, filePath));
|
|
235
|
+
comments.sort((a, b) =>
|
|
236
|
+
`${a.email}\t${a.createdAt}\t${a.from}`.localeCompare(
|
|
237
|
+
`${b.email}\t${b.createdAt}\t${b.from}`,
|
|
238
|
+
"en",
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
return {
|
|
242
|
+
itemId: o.itemId,
|
|
243
|
+
comments,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function addItemComment(args: {
|
|
248
|
+
itemId: string;
|
|
249
|
+
email: string;
|
|
250
|
+
from: string;
|
|
251
|
+
hint: string;
|
|
252
|
+
commentsPath: string;
|
|
253
|
+
}): ItemCommentsFile {
|
|
254
|
+
const p = args.commentsPath;
|
|
255
|
+
const now = new Date().toISOString();
|
|
256
|
+
const existing = readItemCommentsFile(p);
|
|
257
|
+
const entry: ItemCommentEntry = {
|
|
258
|
+
id: randomUUID(),
|
|
259
|
+
from: args.from.trim(),
|
|
260
|
+
email: args.email.trim(),
|
|
261
|
+
hint: args.hint.trim(),
|
|
262
|
+
state: "OPEN",
|
|
263
|
+
createdAt: now,
|
|
264
|
+
updatedAt: now,
|
|
265
|
+
};
|
|
266
|
+
let next: ItemCommentsFile;
|
|
267
|
+
if (!existing) {
|
|
268
|
+
next = {
|
|
269
|
+
itemId: args.itemId,
|
|
270
|
+
comments: [entry],
|
|
271
|
+
};
|
|
272
|
+
} else {
|
|
273
|
+
if (existing.itemId !== args.itemId) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`${p}: file itemId "${existing.itemId}" does not match ${args.itemId}`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
next = {
|
|
279
|
+
...existing,
|
|
280
|
+
comments: [...existing.comments, entry],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
fs.writeFileSync(p, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
284
|
+
return next;
|
|
285
|
+
}
|