@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.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/bin/waterfall.mjs +14 -0
  4. package/lib/cli/agent/agent-message.ts +71 -0
  5. package/lib/cli/agent/agent-translators.ts +145 -0
  6. package/lib/cli/agent/backend-invoke.ts +133 -0
  7. package/lib/cli/agent/backends.ts +100 -0
  8. package/lib/cli/agent/global-prompts.ts +55 -0
  9. package/lib/cli/commands/bug-start.ts +115 -0
  10. package/lib/cli/commands/comment-add.ts +47 -0
  11. package/lib/cli/commands/cr-all.ts +18 -0
  12. package/lib/cli/commands/cr-finish.ts +176 -0
  13. package/lib/cli/commands/cr-start.ts +105 -0
  14. package/lib/cli/commands/cr-to-rq.ts +18 -0
  15. package/lib/cli/commands/export-pdf.ts +193 -0
  16. package/lib/cli/commands/horizontal/horizontal.ts +232 -0
  17. package/lib/cli/commands/horizontal-create.ts +34 -0
  18. package/lib/cli/commands/horizontal-update.ts +32 -0
  19. package/lib/cli/commands/join-hint.ts +4 -0
  20. package/lib/cli/commands/registry.ts +59 -0
  21. package/lib/cli/commands/resolve-operator-hint.ts +120 -0
  22. package/lib/cli/commands/rq-all.ts +18 -0
  23. package/lib/cli/commands/rq-to-uc.ts +18 -0
  24. package/lib/cli/commands/story-close.ts +124 -0
  25. package/lib/cli/commands/sync-work-items.ts +59 -0
  26. package/lib/cli/commands/sys-start.ts +96 -0
  27. package/lib/cli/commands/test-all.ts +18 -0
  28. package/lib/cli/commands/test-to-story.ts +18 -0
  29. package/lib/cli/commands/types.ts +33 -0
  30. package/lib/cli/commands/uc-all.ts +18 -0
  31. package/lib/cli/commands/uc-to-story.ts +18 -0
  32. package/lib/cli/commands/uc-to-test.ts +18 -0
  33. package/lib/cli/comments/item-comments.ts +285 -0
  34. package/lib/cli/config/dot-waterfall.ts +404 -0
  35. package/lib/cli/config/global-cli.ts +21 -0
  36. package/lib/cli/config/sync-work-item-config.ts +34 -0
  37. package/lib/cli/core/cli-help-spec.ts +833 -0
  38. package/lib/cli/core/cli-log.ts +124 -0
  39. package/lib/cli/core/exec-file.ts +8 -0
  40. package/lib/cli/core/prompt-map.ts +64 -0
  41. package/lib/cli/core/slug.ts +44 -0
  42. package/lib/cli/entry.ts +4 -0
  43. package/lib/cli/export/collect-md.ts +41 -0
  44. package/lib/cli/export/export-items.ts +104 -0
  45. package/lib/cli/export/export-pdf-path.ts +88 -0
  46. package/lib/cli/export/merge-md.ts +37 -0
  47. package/lib/cli/export/mermaid-run.ts +104 -0
  48. package/lib/cli/export/pandoc-pdf.ts +90 -0
  49. package/lib/cli/export/pdf-bundled-worker.mjs +73 -0
  50. package/lib/cli/export/pdf-bundled.ts +36 -0
  51. package/lib/cli/git/cr-agent-context.ts +62 -0
  52. package/lib/cli/git/git-branch-guards.ts +60 -0
  53. package/lib/cli/git/git-cli-mock.ts +191 -0
  54. package/lib/cli/git/git-cli.ts +24 -0
  55. package/lib/cli/main.ts +434 -0
  56. package/lib/cli/paths.ts +9 -0
  57. package/lib/cli/project/pom-json.ts +55 -0
  58. package/lib/cli/spec/spec-init.ts +216 -0
  59. package/lib/cli/spec/spec-root.ts +93 -0
  60. package/lib/cli/sync/apply-remote-comments.ts +87 -0
  61. package/lib/cli/sync/attachment-category.ts +43 -0
  62. package/lib/cli/sync/diff-work-items.ts +113 -0
  63. package/lib/cli/sync/materialize-remote-bugs.ts +66 -0
  64. package/lib/cli/sync/provider-types.ts +43 -0
  65. package/lib/cli/sync/providers/direct-provider.ts +27 -0
  66. package/lib/cli/sync/providers/jira-provider.ts +34 -0
  67. package/lib/cli/sync/providers/registry.ts +26 -0
  68. package/lib/cli/sync/run-sync-work-items.ts +202 -0
  69. package/lib/cli/sync/spec-work-items.ts +226 -0
  70. package/lib/cli/sync/sync-hint-json.ts +163 -0
  71. package/lib/cli/sync/work-item-meta.ts +117 -0
  72. package/lib/cli/work-items/infer-bug-sys.ts +147 -0
  73. package/lib/cli/work-items/remote-bug-import-scaffold.ts +32 -0
  74. package/lib/cli/work-items/write-bug-to-spec.ts +158 -0
  75. package/package.json +54 -0
  76. package/prompts/commands/bug-start.md +46 -0
  77. package/prompts/commands/cr-finish.md +44 -0
  78. package/prompts/commands/cr-start.md +65 -0
  79. package/prompts/commands/cr-to-rq.md +62 -0
  80. package/prompts/commands/horizontal-create.md +27 -0
  81. package/prompts/commands/horizontal-update.md +39 -0
  82. package/prompts/commands/rq-to-uc.md +62 -0
  83. package/prompts/commands/story-close-all.md +34 -0
  84. package/prompts/commands/story-close.md +44 -0
  85. package/prompts/commands/sync-bugs-refine-imports.md +33 -0
  86. package/prompts/commands/sys-start.md +63 -0
  87. package/prompts/commands/test-to-story.md +64 -0
  88. package/prompts/commands/uc-to-story.md +85 -0
  89. package/prompts/commands/uc-to-test.md +58 -0
  90. package/prompts/global/before-changing-spec.md +62 -0
  91. package/prompts/global/content-requirements-vs-use-cases.md +116 -0
  92. package/prompts/global/cursor-overview.md +31 -0
  93. package/prompts/global/git-usage.md +46 -0
  94. package/prompts/global/horizontal-structure.md +75 -0
  95. package/prompts/global/workflows-index.md +59 -0
  96. package/prompts/items/bug-document-structure.md +23 -0
  97. package/prompts/items/cr-document-structure.md +45 -0
  98. package/prompts/items/rq-theme-document-structure.md +36 -0
  99. package/prompts/items/story-document-structure.md +49 -0
  100. package/prompts/items/sys-document-structure.md +36 -0
  101. package/prompts/items/tst-document-structure.md +55 -0
  102. package/prompts/items/uc-document-structure.md +38 -0
  103. package/spec-template/README.md +11 -0
  104. package/spec-template/full/doc/spec-structure.md +16 -0
  105. package/spec-template/full/prompts/before-changing-spec.md +7 -0
  106. package/spec-template/full/prompts/workflows.md +25 -0
@@ -0,0 +1,34 @@
1
+ import type { WorkItemRecord } from "../work-item-meta";
2
+ import type {
3
+ WorkItemProviderContext,
4
+ WorkItemSyncProvider,
5
+ } from "../provider-types";
6
+
7
+ /**
8
+ * Jira Cloud/Server adapter (skeleton). Configure via .waterfall, e.g.:
9
+ * sync_story_provider=jira
10
+ * sync_story_jira_site=https://your.atlassian.net
11
+ * sync_story_jira_project=BOOK
12
+ * Auth: set JIRA_API_TOKEN + JIRA_USER_EMAIL (or provider-specific env) in .waterfall as UPPER_SNAKE passthrough.
13
+ * Map Jira comments → WorkItemComment (`email` = author id, `user` = display); attachments → WorkItemAttachment + category via attachmentCategoryFromMimeOrName.
14
+ */
15
+ export class JiraWorkItemSyncProvider implements WorkItemSyncProvider {
16
+ readonly providerId = "jira";
17
+
18
+ fetchRemote(ctx: WorkItemProviderContext): WorkItemRecord[] {
19
+ const site = ctx.options.jira_site?.trim();
20
+ const project = ctx.options.jira_project?.trim();
21
+ if (!site || !project) {
22
+ process.stderr.write(
23
+ `[waterfall] Jira provider: sync_${ctx.kind}_jira_site / sync_${ctx.kind}_jira_project not set — remote snapshot empty (stub; no HTTP yet).\n`,
24
+ );
25
+ return [];
26
+ }
27
+ void site;
28
+ void project;
29
+ process.stderr.write(
30
+ `[waterfall] Jira provider: API sync not implemented yet (would query ${project} on ${site}).\n`,
31
+ );
32
+ return [];
33
+ }
34
+ }
@@ -0,0 +1,26 @@
1
+ import type { WorkItemSyncKind } from "../provider-types";
2
+ import type { WorkItemSyncProvider } from "../provider-types";
3
+ import { DirectWorkItemSyncProvider } from "./direct-provider";
4
+ import { JiraWorkItemSyncProvider } from "./jira-provider";
5
+
6
+ const jiraSingleton = new JiraWorkItemSyncProvider();
7
+ const directSingleton = new DirectWorkItemSyncProvider();
8
+
9
+ export function resolveWorkItemSyncProvider(
10
+ name: string | undefined,
11
+ kind: WorkItemSyncKind,
12
+ ): WorkItemSyncProvider {
13
+ const n = name?.trim().toLowerCase();
14
+ const label = kind === "story" ? "stories" : "bugs";
15
+ const keyPrefix = kind === "story" ? "sync_story" : "sync_bug";
16
+ if (!n || n === "none" || n === "off") {
17
+ throw new Error(
18
+ `[waterfall] sync ${label}: set ${keyPrefix}_provider in .waterfall (e.g. jira) or use --dump-spec only.`,
19
+ );
20
+ }
21
+ if (n === "jira") return jiraSingleton;
22
+ if (n === "direct") return directSingleton;
23
+ throw new Error(
24
+ `[waterfall] sync ${label}: unknown provider "${name}". Supported: jira (stub), direct (hint = sync JSON).`,
25
+ );
26
+ }
@@ -0,0 +1,202 @@
1
+ import type { LogLevel } from "../core/cli-log";
2
+ import { traceExportPdfLine } from "../core/cli-log";
3
+ import type { SyncLeadSystem, SyncWorkItemBlock } from "../config/sync-work-item-config";
4
+ import { effectiveSyncLead } from "../config/sync-work-item-config";
5
+ import { diffWorkItemSets, type WorkItemDiffEntry } from "./diff-work-items";
6
+ import { collectBugsFromSpec, collectStoriesFromSpec } from "./spec-work-items";
7
+ import type { WorkItemProviderContext } from "./provider-types";
8
+ import type { WorkItemSyncKind } from "./provider-types";
9
+ import { applyRemoteCommentsToSpec } from "./apply-remote-comments";
10
+ import { materializeRemoteOnlyBugsToSpec } from "./materialize-remote-bugs";
11
+ import { createMetaDocument, stringifyWorkItemMeta } from "./work-item-meta";
12
+ import { resolveWorkItemSyncProvider } from "./providers/registry";
13
+
14
+ export type SyncWorkItemsCliOptions = {
15
+ dumpSpec: boolean;
16
+ hint?: string;
17
+ };
18
+
19
+ export type SyncWorkItemsHooks = {
20
+ /**
21
+ * After remote comments are applied to spec (so `*.comments.json` is current).
22
+ * Used to run `sync-bugs-refine-imports` for newly materialized BUG markdown.
23
+ */
24
+ afterBugsMaterialized?: (args: {
25
+ specRoot: string;
26
+ mdRels: string[];
27
+ }) => void;
28
+ };
29
+
30
+ function formatMismatchLeadingNote(m: WorkItemDiffEntry, lead: SyncLeadSystem): string {
31
+ if (m.field === "state" || m.field === "title") {
32
+ const val = lead === "waterfall" ? m.spec : m.remote;
33
+ return ` → leading=${lead}: canonical ${m.field} is ${lead} ${JSON.stringify(val)}\n`;
34
+ }
35
+ return ` → leading=${lead}: canonical ${m.field} follows ${lead}\n`;
36
+ }
37
+
38
+ function collectSpec(kind: WorkItemSyncKind, specRoot: string) {
39
+ return kind === "story"
40
+ ? collectStoriesFromSpec(specRoot)
41
+ : collectBugsFromSpec(specRoot);
42
+ }
43
+
44
+ export function runSyncWorkItems(
45
+ specRoot: string,
46
+ cwd: string,
47
+ kind: WorkItemSyncKind,
48
+ block: SyncWorkItemBlock | undefined,
49
+ logLevel: LogLevel,
50
+ opts: SyncWorkItemsCliOptions,
51
+ hooks?: SyncWorkItemsHooks,
52
+ ): void {
53
+ let specItems = collectSpec(kind, specRoot);
54
+ const specDoc = createMetaDocument(
55
+ "waterfall-spec",
56
+ kind,
57
+ specItems,
58
+ specRoot,
59
+ );
60
+
61
+ if (opts.dumpSpec) {
62
+ process.stdout.write(stringifyWorkItemMeta(specDoc));
63
+ return;
64
+ }
65
+
66
+ const label = kind === "story" ? "stories" : "bugs";
67
+ const keyPrefix = kind === "story" ? "sync_story" : "sync_bug";
68
+ const lead = effectiveSyncLead(block, kind);
69
+ traceExportPdfLine(
70
+ logLevel,
71
+ `sync ${label} · provider=${block.provider ?? "(none)"} leading=${lead}${opts.hint?.length ? ` hint=${opts.hint.length}ch` : ""}`,
72
+ );
73
+
74
+ if (!block?.provider?.trim()) {
75
+ throw new Error(
76
+ `[waterfall] sync ${label}: set ${keyPrefix}_provider (e.g. jira) in .waterfall for remote compare/push/pull.`,
77
+ );
78
+ }
79
+
80
+ const ctx: WorkItemProviderContext = {
81
+ cwd,
82
+ specRoot,
83
+ kind,
84
+ options: block.options,
85
+ ...(opts.hint !== undefined && opts.hint !== "" ? { hint: opts.hint } : {}),
86
+ };
87
+
88
+ const provider = resolveWorkItemSyncProvider(block.provider, kind);
89
+ const remoteItems = provider.fetchRemote(ctx);
90
+
91
+ let specIds = new Set(specItems.map((x) => x.canonicalId));
92
+ let materializedBugMdRels: string[] = [];
93
+ if (kind === "bug" && lead === "remote") {
94
+ materializedBugMdRels = materializeRemoteOnlyBugsToSpec(
95
+ specRoot,
96
+ remoteItems,
97
+ specIds,
98
+ );
99
+ if (materializedBugMdRels.length > 0) {
100
+ traceExportPdfLine(
101
+ logLevel,
102
+ `sync ${label} · materialized ${materializedBugMdRels.length} bug(s) from remote into technical/`,
103
+ );
104
+ process.stdout.write(
105
+ `[waterfall] sync ${label} · created ${materializedBugMdRels.length} bug folder(s) under technical/ from remote\n`,
106
+ );
107
+ specItems = collectSpec(kind, specRoot);
108
+ specIds = new Set(specItems.map((x) => x.canonicalId));
109
+ }
110
+ }
111
+
112
+ const commentsWritten = applyRemoteCommentsToSpec(
113
+ specRoot,
114
+ kind,
115
+ remoteItems,
116
+ specIds,
117
+ );
118
+ if (commentsWritten > 0) {
119
+ traceExportPdfLine(
120
+ logLevel,
121
+ `sync ${label} · wrote remote comments to ${commentsWritten} work item(s)`,
122
+ );
123
+ process.stdout.write(
124
+ `[waterfall] sync ${label} · wrote remote comments to ${commentsWritten} work item(s)\n`,
125
+ );
126
+ }
127
+ specItems = collectSpec(kind, specRoot);
128
+
129
+ if (
130
+ materializedBugMdRels.length > 0 &&
131
+ hooks?.afterBugsMaterialized
132
+ ) {
133
+ hooks.afterBugsMaterialized({
134
+ specRoot,
135
+ mdRels: materializedBugMdRels,
136
+ });
137
+ specItems = collectSpec(kind, specRoot);
138
+ }
139
+
140
+ const diff = diffWorkItemSets(specItems, remoteItems);
141
+ process.stdout.write(
142
+ `[waterfall] sync ${label} · spec items=${specItems.length} remote items=${remoteItems.length}\n`,
143
+ );
144
+ if (diff.onlyInSpec.length) {
145
+ process.stdout.write(` only in spec: ${diff.onlyInSpec.join(", ")}\n`);
146
+ process.stdout.write(
147
+ lead === "waterfall"
148
+ ? ` → leading=waterfall: on reconcile, remote should gain these (e.g. push).\n`
149
+ : ` → leading=remote: spec-only ids are ahead of remote truth; trim or unlink spec to match remote.\n`,
150
+ );
151
+ }
152
+ if (diff.onlyInRemote.length) {
153
+ process.stdout.write(
154
+ ` only in remote: ${diff.onlyInRemote.join(", ")}\n`,
155
+ );
156
+ process.stdout.write(
157
+ lead === "waterfall"
158
+ ? ` → leading=waterfall: remote-only ids are not in spec; drop remote or add spec rows to match.\n`
159
+ : ` → leading=remote: on reconcile, spec should gain these (e.g. pull).\n`,
160
+ );
161
+ }
162
+ if (diff.mismatches.length) {
163
+ for (const m of diff.mismatches) {
164
+ process.stdout.write(
165
+ ` mismatch ${m.canonicalId} ${m.field}: spec=${JSON.stringify(m.spec)} remote=${JSON.stringify(m.remote)}\n`,
166
+ );
167
+ process.stdout.write(formatMismatchLeadingNote(m, lead));
168
+ }
169
+ }
170
+ if (
171
+ diff.onlyInSpec.length === 0 &&
172
+ diff.onlyInRemote.length === 0 &&
173
+ diff.mismatches.length === 0
174
+ ) {
175
+ process.stdout.write(
176
+ ` (no differences in ids / state / title / comments / attachments)\n`,
177
+ );
178
+ }
179
+
180
+ if (!provider.pullToSpec) {
181
+ process.stdout.write(
182
+ `[waterfall] sync ${label} · pull (state/title/attachments remote→spec) not implemented for provider ${provider.providerId}.\n`,
183
+ );
184
+ }
185
+ if (provider.providerId !== "direct" && !provider.pushToRemote) {
186
+ process.stdout.write(
187
+ `[waterfall] sync ${label} · push (spec→remote) not implemented for provider ${provider.providerId}.\n`,
188
+ );
189
+ }
190
+
191
+ if (provider.providerId === "direct") {
192
+ const outDoc = createMetaDocument(
193
+ "waterfall-direct-out",
194
+ kind,
195
+ specItems,
196
+ specRoot,
197
+ );
198
+ process.stdout.write(`\n`);
199
+ process.stdout.write(stringifyWorkItemMeta(outDoc));
200
+ }
201
+
202
+ }
@@ -0,0 +1,226 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ itemCommentsJsonPath,
5
+ readItemCommentsFile,
6
+ } from "../comments/item-comments";
7
+ import { attachmentCategoryFromMimeOrName } from "./attachment-category";
8
+ import type {
9
+ WorkItemAttachment,
10
+ WorkItemComment,
11
+ WorkItemRecord,
12
+ } from "./work-item-meta";
13
+
14
+ /** Infer state from folder name and optional markdown **Status:** line. */
15
+ export function inferWorkItemState(
16
+ folderBase: string,
17
+ statusLine: string | undefined,
18
+ kind: "story" | "bug",
19
+ ): string {
20
+ const prefix = kind === "story" ? "_STORY-" : "_BUG-";
21
+ if (folderBase.startsWith(prefix)) return "closed";
22
+ const s = statusLine?.trim().toLowerCase();
23
+ if (s === "closed") return "closed";
24
+ if (s === "in progress") return "in_progress";
25
+ if (s === "draft" || s === "open") return s;
26
+ if (s) return s;
27
+ return "open";
28
+ }
29
+
30
+ function readStatusFromMd(mdPath: string): string | undefined {
31
+ try {
32
+ const text = fs.readFileSync(mdPath, "utf8");
33
+ const m = text.match(/^\*\*Status:\*\*\s*(.+)$/im);
34
+ return m?.[1]?.trim();
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }
39
+
40
+ function readTitleFromMd(mdPath: string): string {
41
+ try {
42
+ const text = fs.readFileSync(mdPath, "utf8");
43
+ const m = text.match(/^#\s+([^\n]+)/m);
44
+ return m?.[1]?.trim() || path.basename(mdPath, ".md");
45
+ } catch {
46
+ return path.basename(mdPath, ".md");
47
+ }
48
+ }
49
+
50
+ const STORY_DIR = /^(_)?STORY-(\d{3})-/;
51
+ const BUG_DIR = /^(_)?BUG-(\d{3})-/;
52
+
53
+ function walk(
54
+ root: string,
55
+ kind: "story" | "bug",
56
+ re: RegExp,
57
+ fileRe: RegExp,
58
+ ): WorkItemWalkHit[] {
59
+ const out: WorkItemWalkHit[] = [];
60
+ const stack = [root];
61
+ while (stack.length) {
62
+ const d = stack.pop()!;
63
+ let entries: fs.Dirent[];
64
+ try {
65
+ entries = fs.readdirSync(d, { withFileTypes: true });
66
+ } catch {
67
+ continue;
68
+ }
69
+ for (const e of entries) {
70
+ const p = path.join(d, e.name);
71
+ if (e.isDirectory()) {
72
+ if (re.test(e.name)) {
73
+ const md = fs
74
+ .readdirSync(p)
75
+ .find((f) => fileRe.test(f));
76
+ if (md) {
77
+ out.push({
78
+ dir: p,
79
+ folderBase: e.name,
80
+ mdPath: path.join(p, md),
81
+ paddedId: e.name.match(/\d{3}/)?.[0] ?? "",
82
+ });
83
+ }
84
+ }
85
+ if (!e.name.startsWith(".") && e.name !== "node_modules") {
86
+ stack.push(p);
87
+ }
88
+ }
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
94
+ type WorkItemWalkHit = {
95
+ dir: string;
96
+ folderBase: string;
97
+ mdPath: string;
98
+ paddedId: string;
99
+ };
100
+
101
+ function sysIdFromPath(specRoot: string, dir: string): string | undefined {
102
+ const rel = path.relative(specRoot, dir).split(path.sep).join("/");
103
+ const m = rel.match(/SYS-(\d{3})/i);
104
+ return m ? `SYS-${m[1]}` : undefined;
105
+ }
106
+
107
+ /**
108
+ * Other **files** in the STORY|BUG folder (same directory as the canonical `.md`), excluding that markdown.
109
+ * Not committed as a separate meta file — computed when building the work-item snapshot. Nested subdirs are ignored.
110
+ */
111
+ export function collectAttachmentsFromItemFolder(
112
+ specRoot: string,
113
+ itemDir: string,
114
+ mdFileName: string,
115
+ ): WorkItemAttachment[] {
116
+ const out: WorkItemAttachment[] = [];
117
+ let entries: fs.Dirent[];
118
+ try {
119
+ entries = fs.readdirSync(itemDir, { withFileTypes: true });
120
+ } catch {
121
+ return out;
122
+ }
123
+ const mdLower = mdFileName.toLowerCase();
124
+ for (const e of entries) {
125
+ if (!e.isFile()) continue;
126
+ const name = e.name;
127
+ if (name.startsWith(".")) continue;
128
+ if (name.toLowerCase() === mdLower) continue;
129
+ if (/\.sync\.json$/i.test(name)) continue;
130
+ if (/\.comments\.json$/i.test(name)) continue;
131
+ const abs = path.join(itemDir, name);
132
+ let sizeBytes: number | undefined;
133
+ try {
134
+ sizeBytes = fs.statSync(abs).size;
135
+ } catch {
136
+ /* ignore */
137
+ }
138
+ const relFromSpec = path.relative(specRoot, abs).split(path.sep).join("/");
139
+ out.push({
140
+ id: relFromSpec,
141
+ name,
142
+ category: attachmentCategoryFromMimeOrName(undefined, name),
143
+ sizeBytes,
144
+ href: relFromSpec,
145
+ });
146
+ }
147
+ out.sort((a, b) => a.id.localeCompare(b.id, "en"));
148
+ return out;
149
+ }
150
+
151
+ /** Map `{CANONICAL}.comments.json` entries into {@link WorkItemComment} for sync/dump-spec. */
152
+ export function waterfallCommentsFileToWorkItemComments(
153
+ itemDir: string,
154
+ canonicalId: string,
155
+ ): WorkItemComment[] | undefined {
156
+ const p = itemCommentsJsonPath(itemDir, canonicalId);
157
+ const file = readItemCommentsFile(p);
158
+ if (!file?.comments.length) return undefined;
159
+ const comments: WorkItemComment[] = file.comments.map((c) => ({
160
+ user: c.from,
161
+ email: c.email.trim(),
162
+ createdAt: c.createdAt,
163
+ body:
164
+ c.state === "OPEN" ? c.hint : `[${c.state}] ${c.hint}`,
165
+ externalId: `waterfall:${c.id}`,
166
+ }));
167
+ return comments;
168
+ }
169
+
170
+ export function collectStoriesFromSpec(specRoot: string): WorkItemRecord[] {
171
+ const technical = path.join(specRoot, "technical");
172
+ if (!fs.existsSync(technical)) return [];
173
+ const hits = walk(technical, "story", STORY_DIR, /^STORY-\d{3}\.md$/i);
174
+ const out: WorkItemRecord[] = [];
175
+ for (const h of hits) {
176
+ const id = `STORY-${h.paddedId}`;
177
+ const statusLine = readStatusFromMd(h.mdPath);
178
+ const state = inferWorkItemState(h.folderBase, statusLine, "story");
179
+ const attachments = collectAttachmentsFromItemFolder(
180
+ specRoot,
181
+ h.dir,
182
+ path.basename(h.mdPath),
183
+ );
184
+ const comments = waterfallCommentsFileToWorkItemComments(h.dir, id);
185
+ out.push({
186
+ canonicalId: id,
187
+ kind: "story",
188
+ state,
189
+ title: readTitleFromMd(h.mdPath),
190
+ sysId: sysIdFromPath(specRoot, h.dir),
191
+ relPath: path.relative(specRoot, h.mdPath).split(path.sep).join("/"),
192
+ ...(comments?.length ? { comments } : {}),
193
+ ...(attachments.length ? { attachments } : {}),
194
+ });
195
+ }
196
+ return out;
197
+ }
198
+
199
+ export function collectBugsFromSpec(specRoot: string): WorkItemRecord[] {
200
+ const technical = path.join(specRoot, "technical");
201
+ if (!fs.existsSync(technical)) return [];
202
+ const hits = walk(technical, "bug", BUG_DIR, /^BUG-\d{3}\.md$/i);
203
+ const out: WorkItemRecord[] = [];
204
+ for (const h of hits) {
205
+ const id = `BUG-${h.paddedId}`;
206
+ const statusLine = readStatusFromMd(h.mdPath);
207
+ const state = inferWorkItemState(h.folderBase, statusLine, "bug");
208
+ const attachments = collectAttachmentsFromItemFolder(
209
+ specRoot,
210
+ h.dir,
211
+ path.basename(h.mdPath),
212
+ );
213
+ const comments = waterfallCommentsFileToWorkItemComments(h.dir, id);
214
+ out.push({
215
+ canonicalId: id,
216
+ kind: "bug",
217
+ state,
218
+ title: readTitleFromMd(h.mdPath),
219
+ sysId: sysIdFromPath(specRoot, h.dir),
220
+ relPath: path.relative(specRoot, h.mdPath).split(path.sep).join("/"),
221
+ ...(comments?.length ? { comments } : {}),
222
+ ...(attachments.length ? { attachments } : {}),
223
+ });
224
+ }
225
+ return out;
226
+ }
@@ -0,0 +1,163 @@
1
+ import type {
2
+ WorkItemAttachment,
3
+ WorkItemAttachmentCategory,
4
+ WorkItemComment,
5
+ WorkItemExternalRef,
6
+ WorkItemRecord,
7
+ } from "./work-item-meta";
8
+ import { normalizeWorkItemRecord } from "./work-item-meta";
9
+
10
+ const CATEGORIES = new Set<WorkItemAttachmentCategory>([
11
+ "pdf",
12
+ "image",
13
+ "video",
14
+ "spreadsheet",
15
+ "document",
16
+ "other",
17
+ ]);
18
+
19
+ function fail(msg: string): never {
20
+ throw new Error(`[waterfall] direct provider: ${msg}`);
21
+ }
22
+
23
+ function requireString(x: unknown, ctx: string): string {
24
+ if (typeof x !== "string" || !x.trim()) {
25
+ fail(`${ctx}: expected non-empty string`);
26
+ }
27
+ return x;
28
+ }
29
+
30
+ function optionalString(x: unknown): string | undefined {
31
+ if (x === undefined || x === null) return undefined;
32
+ if (typeof x !== "string") fail(`expected string, got ${typeof x}`);
33
+ return x;
34
+ }
35
+
36
+ function validateExternalRefs(x: unknown, ctx: string): WorkItemExternalRef[] | undefined {
37
+ if (x === undefined || x === null) return undefined;
38
+ if (!Array.isArray(x)) fail(`${ctx}: externalRefs must be an array`);
39
+ return x.map((e, j) => {
40
+ if (!e || typeof e !== "object") fail(`${ctx}[${j}]: object expected`);
41
+ const o = e as Record<string, unknown>;
42
+ return {
43
+ system: requireString(o.system, `${ctx}[${j}].system`),
44
+ id: requireString(o.id, `${ctx}[${j}].id`),
45
+ url: optionalString(o.url),
46
+ };
47
+ });
48
+ }
49
+
50
+ function validateComments(x: unknown, ctx: string): WorkItemComment[] | undefined {
51
+ if (x === undefined || x === null) return undefined;
52
+ if (!Array.isArray(x)) fail(`${ctx}: comments must be an array`);
53
+ return x.map((c, j) => {
54
+ if (!c || typeof c !== "object") fail(`${ctx}[${j}]: object expected`);
55
+ const o = c as Record<string, unknown>;
56
+ return {
57
+ user: requireString(o.user, `${ctx}[${j}].user`),
58
+ email: requireString(o.email, `${ctx}[${j}].email`),
59
+ createdAt: requireString(o.createdAt, `${ctx}[${j}].createdAt`),
60
+ body: requireString(o.body, `${ctx}[${j}].body`),
61
+ externalId: optionalString(o.externalId),
62
+ };
63
+ });
64
+ }
65
+
66
+ function validateAttachments(x: unknown, ctx: string): WorkItemAttachment[] | undefined {
67
+ if (x === undefined || x === null) return undefined;
68
+ if (!Array.isArray(x)) fail(`${ctx}: attachments must be an array`);
69
+ return x.map((a, j) => {
70
+ if (!a || typeof a !== "object") fail(`${ctx}[${j}]: object expected`);
71
+ const o = a as Record<string, unknown>;
72
+ const category = o.category;
73
+ if (typeof category !== "string" || !CATEGORIES.has(category as WorkItemAttachmentCategory)) {
74
+ fail(
75
+ `${ctx}[${j}].category: expected one of ${[...CATEGORIES].join(", ")}`,
76
+ );
77
+ }
78
+ return {
79
+ id: requireString(o.id, `${ctx}[${j}].id`),
80
+ name: requireString(o.name, `${ctx}[${j}].name`),
81
+ category: category as WorkItemAttachmentCategory,
82
+ mimeType: optionalString(o.mimeType),
83
+ sizeBytes:
84
+ o.sizeBytes === undefined || o.sizeBytes === null
85
+ ? undefined
86
+ : typeof o.sizeBytes === "number" && Number.isFinite(o.sizeBytes)
87
+ ? o.sizeBytes
88
+ : fail(`${ctx}[${j}].sizeBytes: number expected`),
89
+ href: optionalString(o.href),
90
+ };
91
+ });
92
+ }
93
+
94
+ function validateWorkItemRecordAt(
95
+ x: unknown,
96
+ index: number,
97
+ expectedKind: "story" | "bug",
98
+ ): WorkItemRecord {
99
+ const ctx = `items[${index}]`;
100
+ if (!x || typeof x !== "object") fail(`${ctx}: object expected`);
101
+ const o = x as Record<string, unknown>;
102
+ const kind = requireString(o.kind, `${ctx}.kind`);
103
+ if (kind !== expectedKind) {
104
+ fail(`${ctx}.kind "${kind}" does not match sync kind "${expectedKind}"`);
105
+ }
106
+ const rec: WorkItemRecord = {
107
+ canonicalId: requireString(o.canonicalId, `${ctx}.canonicalId`),
108
+ kind: expectedKind,
109
+ state: requireString(o.state, `${ctx}.state`),
110
+ title: requireString(o.title, `${ctx}.title`),
111
+ sysId: optionalString(o.sysId),
112
+ relPath: optionalString(o.relPath),
113
+ externalRefs: validateExternalRefs(o.externalRefs, `${ctx}.externalRefs`),
114
+ comments: validateComments(o.comments, `${ctx}.comments`),
115
+ attachments: validateAttachments(o.attachments, `${ctx}.attachments`),
116
+ providerPayload:
117
+ o.providerPayload === undefined || o.providerPayload === null
118
+ ? undefined
119
+ : typeof o.providerPayload === "object" && !Array.isArray(o.providerPayload)
120
+ ? (o.providerPayload as Record<string, unknown>)
121
+ : fail(`${ctx}.providerPayload: object expected`),
122
+ };
123
+ return normalizeWorkItemRecord(rec);
124
+ }
125
+
126
+ /**
127
+ * Parse a sync hint string as JSON and validate the work-item meta shape expected by
128
+ * {@link stringifyWorkItemMeta} / `--dump-spec` (object with `items`, or a top-level array of items).
129
+ * Document `kind`, when present, must match {@link expectedKind}.
130
+ */
131
+ export function parseWorkItemRecordsFromSyncHintJson(
132
+ raw: string,
133
+ expectedKind: "story" | "bug",
134
+ ): WorkItemRecord[] {
135
+ let parsed: unknown;
136
+ try {
137
+ parsed = JSON.parse(raw);
138
+ } catch (e) {
139
+ fail(`hint is not valid JSON: ${(e as Error).message}`);
140
+ }
141
+
142
+ let itemsRaw: unknown;
143
+ if (Array.isArray(parsed)) {
144
+ itemsRaw = parsed;
145
+ } else {
146
+ if (!parsed || typeof parsed !== "object") {
147
+ fail("hint JSON must be an object or a top-level array of work items");
148
+ }
149
+ const o = parsed as Record<string, unknown>;
150
+ if (typeof o.kind === "string" && o.kind !== expectedKind) {
151
+ fail(`document kind "${o.kind}" does not match sync kind "${expectedKind}"`);
152
+ }
153
+ itemsRaw = o.items;
154
+ }
155
+
156
+ if (!Array.isArray(itemsRaw)) {
157
+ fail(
158
+ "hint must be a top-level array of work items or an object with an `items` array",
159
+ );
160
+ }
161
+
162
+ return itemsRaw.map((item, i) => validateWorkItemRecordAt(item, i, expectedKind));
163
+ }