@brainervirus/workit-core 0.4.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 (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -0
  3. package/commands/wk-changelog.md +2 -0
  4. package/commands/wk-commit.md +2 -0
  5. package/commands/wk-docs-refresh.md +2 -0
  6. package/commands/wk-handoff.md +2 -0
  7. package/commands/wk-implement.md +2 -0
  8. package/commands/wk-init.md +2 -0
  9. package/commands/wk-issue-update.md +2 -0
  10. package/commands/wk-meetings.md +2 -0
  11. package/commands/wk-pr.md +2 -0
  12. package/commands/wk-release-notes.md +2 -0
  13. package/commands/wk-status.md +2 -0
  14. package/commands/wk-verify.md +2 -0
  15. package/package.json +43 -0
  16. package/scripts/_shared/common.sh +158 -0
  17. package/scripts/changelog-context.sh +42 -0
  18. package/scripts/docs-refresh-context.sh +40 -0
  19. package/scripts/fixtures/sample-plan.md +15 -0
  20. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  21. package/scripts/init/apply.sh +5 -0
  22. package/scripts/init/status.sh +5 -0
  23. package/scripts/init/toolkit-status.sh +5 -0
  24. package/scripts/install-cursor-plugin.sh +83 -0
  25. package/scripts/install-opencode-plugin.sh +76 -0
  26. package/scripts/pr-create.sh +5 -0
  27. package/scripts/pr-ready-context.sh +88 -0
  28. package/scripts/present/ascii-wireframe.sh +5 -0
  29. package/scripts/present/flow-diagram.sh +5 -0
  30. package/scripts/release-notes-context.sh +40 -0
  31. package/scripts/rewrite-workspace-deps.ts +17 -0
  32. package/scripts/run-cursor-mcp.sh +10 -0
  33. package/scripts/sync-runtime.sh +96 -0
  34. package/scripts/update-superpowers.sh +56 -0
  35. package/scripts/vcs/config.sh +5 -0
  36. package/scripts/vcs/merged-style.sh +5 -0
  37. package/scripts/vcs/token-create-urls.sh +5 -0
  38. package/scripts/vcs/verify-token.sh +5 -0
  39. package/scripts/verify-project.sh +140 -0
  40. package/scripts/youtrack/api.sh +5 -0
  41. package/scripts/youtrack/config.sh +5 -0
  42. package/scripts/youtrack/greeting.sh +5 -0
  43. package/scripts/youtrack/parse-duration.sh +5 -0
  44. package/scripts/youtrack/token-create-url.sh +5 -0
  45. package/scripts/youtrack/verify-token.sh +5 -0
  46. package/scripts/youtrack/work-date-ms.sh +5 -0
  47. package/skills/wk-changelog/SKILL.md +15 -0
  48. package/skills/wk-commit/SKILL.md +16 -0
  49. package/skills/wk-docs-refresh/SKILL.md +15 -0
  50. package/skills/wk-handoff/SKILL.md +17 -0
  51. package/skills/wk-implement/SKILL.md +41 -0
  52. package/skills/wk-init/SKILL.md +31 -0
  53. package/skills/wk-issue-update/SKILL.md +27 -0
  54. package/skills/wk-issue-update/references/youtrack-update-style.md +81 -0
  55. package/skills/wk-meetings/SKILL.md +17 -0
  56. package/skills/wk-pr/SKILL.md +27 -0
  57. package/skills/wk-release-notes/SKILL.md +15 -0
  58. package/skills/wk-status/SKILL.md +16 -0
  59. package/skills/wk-verify/SKILL.md +16 -0
  60. package/src/core/branch.ts +246 -0
  61. package/src/core/changelog.ts +312 -0
  62. package/src/core/config-guard.ts +26 -0
  63. package/src/core/config.ts +73 -0
  64. package/src/core/detector.ts +207 -0
  65. package/src/core/doc-render.ts +14 -0
  66. package/src/core/docs-repo.ts +196 -0
  67. package/src/core/docs-validate.ts +255 -0
  68. package/src/core/flow-state.ts +225 -0
  69. package/src/core/git.ts +56 -0
  70. package/src/core/gitignore.ts +43 -0
  71. package/src/core/handoff-context.ts +115 -0
  72. package/src/core/hygiene.ts +77 -0
  73. package/src/core/init.ts +443 -0
  74. package/src/core/parse-sections.ts +24 -0
  75. package/src/core/plan-tasks.ts +33 -0
  76. package/src/core/ports/init-apply.ts +15 -0
  77. package/src/core/ports/init-status.ts +4 -0
  78. package/src/core/ports/init-toolkit-status.ts +4 -0
  79. package/src/core/ports/pr-create.ts +22 -0
  80. package/src/core/ports/present-ascii.ts +10 -0
  81. package/src/core/ports/present-flow.ts +10 -0
  82. package/src/core/ports/vcs-config.ts +14 -0
  83. package/src/core/ports/vcs-merged-style.ts +5 -0
  84. package/src/core/ports/vcs-token-create-urls.ts +4 -0
  85. package/src/core/ports/vcs-verify-token.ts +4 -0
  86. package/src/core/ports/youtrack-api.ts +17 -0
  87. package/src/core/ports/youtrack-config.ts +23 -0
  88. package/src/core/ports/youtrack-greeting.ts +10 -0
  89. package/src/core/ports/youtrack-parse-duration.ts +14 -0
  90. package/src/core/ports/youtrack-token-create-url.ts +4 -0
  91. package/src/core/ports/youtrack-verify-token.ts +12 -0
  92. package/src/core/ports/youtrack-work-date-ms.ts +10 -0
  93. package/src/core/pr-create.ts +212 -0
  94. package/src/core/present.ts +100 -0
  95. package/src/core/reminder.ts +82 -0
  96. package/src/core/repo-tool.ts +9 -0
  97. package/src/core/rules.ts +122 -0
  98. package/src/core/scripts.ts +42 -0
  99. package/src/core/sdd.ts +188 -0
  100. package/src/core/templates.ts +37 -0
  101. package/src/core/vcs-config.ts +252 -0
  102. package/src/core/verify-parse.ts +27 -0
  103. package/src/core/workspaces.ts +81 -0
  104. package/src/core/youtrack.ts +488 -0
  105. package/src/core.ts +62 -0
  106. package/src/state.ts +22 -0
  107. package/src/tools/docs-repo.ts +42 -0
  108. package/src/tools/flow.ts +88 -0
  109. package/src/tools/handoff.ts +160 -0
  110. package/src/tools/index.ts +22 -0
  111. package/src/tools/present.ts +45 -0
  112. package/src/tools/repo.ts +357 -0
  113. package/src/tools/rules.ts +30 -0
  114. package/src/tools/sdd.ts +189 -0
  115. package/src/tools/templates.ts +27 -0
  116. package/src/tools/youtrack.ts +356 -0
  117. package/templates/execution-contract.md +56 -0
  118. package/templates/greeting.md +1 -0
  119. package/templates/headers.md +3 -0
  120. package/templates/hygiene/.editorconfig +8 -0
  121. package/templates/hygiene/.gitattributes +3 -0
  122. package/templates/hygiene/CHANGELOG.md +14 -0
  123. package/templates/hygiene/CONTRIBUTING.md +3 -0
  124. package/templates/hygiene/LICENSE +21 -0
  125. package/templates/hygiene/README.md +3 -0
  126. package/templates/issue-update.md +6 -0
  127. package/templates/plan-template.md +25 -0
  128. package/templates/spec-template.md +51 -0
  129. package/templates/superpowers-doc-contract.md +69 -0
  130. package/vendor/superpowers/skills/brainstorming/SKILL.md +159 -0
  131. package/vendor/superpowers/skills/brainstorming/scripts/frame-template.html +213 -0
  132. package/vendor/superpowers/skills/brainstorming/scripts/helper.js +167 -0
  133. package/vendor/superpowers/skills/brainstorming/scripts/server.cjs +723 -0
  134. package/vendor/superpowers/skills/brainstorming/scripts/start-server.sh +209 -0
  135. package/vendor/superpowers/skills/brainstorming/scripts/stop-server.sh +120 -0
  136. package/vendor/superpowers/skills/brainstorming/spec-document-reviewer-prompt.md +49 -0
  137. package/vendor/superpowers/skills/brainstorming/visual-companion.md +291 -0
  138. package/vendor/superpowers/skills/dispatching-parallel-agents/SKILL.md +185 -0
  139. package/vendor/superpowers/skills/executing-plans/SKILL.md +70 -0
  140. package/vendor/superpowers/skills/finishing-a-development-branch/SKILL.md +241 -0
  141. package/vendor/superpowers/skills/receiving-code-review/SKILL.md +213 -0
  142. package/vendor/superpowers/skills/requesting-code-review/SKILL.md +103 -0
  143. package/vendor/superpowers/skills/requesting-code-review/code-reviewer.md +172 -0
  144. package/vendor/superpowers/skills/subagent-driven-development/SKILL.md +418 -0
  145. package/vendor/superpowers/skills/subagent-driven-development/implementer-prompt.md +139 -0
  146. package/vendor/superpowers/skills/subagent-driven-development/scripts/review-package +44 -0
  147. package/vendor/superpowers/skills/subagent-driven-development/scripts/sdd-workspace +22 -0
  148. package/vendor/superpowers/skills/subagent-driven-development/scripts/task-brief +40 -0
  149. package/vendor/superpowers/skills/subagent-driven-development/task-reviewer-prompt.md +188 -0
  150. package/vendor/superpowers/skills/systematic-debugging/CREATION-LOG.md +119 -0
  151. package/vendor/superpowers/skills/systematic-debugging/SKILL.md +296 -0
  152. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting-example.ts +158 -0
  153. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting.md +115 -0
  154. package/vendor/superpowers/skills/systematic-debugging/defense-in-depth.md +122 -0
  155. package/vendor/superpowers/skills/systematic-debugging/find-polluter.sh +63 -0
  156. package/vendor/superpowers/skills/systematic-debugging/root-cause-tracing.md +169 -0
  157. package/vendor/superpowers/skills/systematic-debugging/test-academic.md +14 -0
  158. package/vendor/superpowers/skills/systematic-debugging/test-pressure-1.md +58 -0
  159. package/vendor/superpowers/skills/systematic-debugging/test-pressure-2.md +68 -0
  160. package/vendor/superpowers/skills/systematic-debugging/test-pressure-3.md +69 -0
  161. package/vendor/superpowers/skills/test-driven-development/SKILL.md +371 -0
  162. package/vendor/superpowers/skills/test-driven-development/testing-anti-patterns.md +299 -0
  163. package/vendor/superpowers/skills/using-git-worktrees/SKILL.md +202 -0
  164. package/vendor/superpowers/skills/using-superpowers/SKILL.md +62 -0
  165. package/vendor/superpowers/skills/using-superpowers/references/antigravity-tools.md +23 -0
  166. package/vendor/superpowers/skills/using-superpowers/references/codex-tools.md +39 -0
  167. package/vendor/superpowers/skills/using-superpowers/references/pi-tools.md +16 -0
  168. package/vendor/superpowers/skills/verification-before-completion/SKILL.md +139 -0
  169. package/vendor/superpowers/skills/writing-plans/SKILL.md +174 -0
  170. package/vendor/superpowers/skills/writing-plans/plan-document-reviewer-prompt.md +49 -0
  171. package/vendor/superpowers/skills/writing-skills/SKILL.md +689 -0
  172. package/vendor/superpowers/skills/writing-skills/anthropic-best-practices.md +1150 -0
  173. package/vendor/superpowers/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +189 -0
  174. package/vendor/superpowers/skills/writing-skills/graphviz-conventions.dot +172 -0
  175. package/vendor/superpowers/skills/writing-skills/persuasion-principles.md +187 -0
  176. package/vendor/superpowers/skills/writing-skills/render-graphs.js +168 -0
  177. package/vendor/superpowers/skills/writing-skills/testing-skills-with-subagents.md +384 -0
@@ -0,0 +1,312 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveWorkspaceRoot } from "./scripts";
4
+
5
+ const CATEGORIES = [
6
+ "Added",
7
+ "Changed",
8
+ "Deprecated",
9
+ "Removed",
10
+ "Fixed",
11
+ "Security",
12
+ ];
13
+
14
+ // Port of scripts/changelog/apply-unreleased.py — merge Keep a Changelog
15
+ // entries into ## [Unreleased] without duplicating ### headings.
16
+ const CAT_RE = /^###\s+(?:Added|Changed|Deprecated|Removed|Fixed|Security)\s*$/i;
17
+ const UNRELEASED_RE = /^##\s+\[Unreleased\]\s*$/i;
18
+ const VERSION_RE = /^##\s+\[/;
19
+ const BULLET_RE = /^([-*]\s+)(.+?)\s*$/;
20
+ const HEADING_RE = /^###\s+/;
21
+
22
+ const SKELETON = `# Changelog
23
+
24
+ All notable changes to this project will be documented in this file.
25
+
26
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
27
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
28
+
29
+ ## [Unreleased]
30
+
31
+ `;
32
+
33
+ function normalizeBullet(text: string): string {
34
+ text = text.trim();
35
+ const m = BULLET_RE.exec(text);
36
+ if (m) return m[2].trim();
37
+ if (text.startsWith("- ") || text.startsWith("* ")) return text.slice(2).trim();
38
+ return text;
39
+ }
40
+
41
+ function formatBullet(text: string): string {
42
+ return `- ${normalizeBullet(text)}`;
43
+ }
44
+
45
+ function splitUnreleased(text: string): [string, string, string] {
46
+ const lines = text.split(/(?<=\n)/);
47
+ let start: number | null = null;
48
+ for (let i = 0; i < lines.length; i++) {
49
+ if (UNRELEASED_RE.test(lines[i].replace(/\n$/, ""))) {
50
+ start = i;
51
+ break;
52
+ }
53
+ }
54
+ if (start === null) return [text, "", ""];
55
+
56
+ let end = lines.length;
57
+ for (let j = start + 1; j < lines.length; j++) {
58
+ const stripped = lines[j].replace(/\n$/, "");
59
+ // Next version heading ends Unreleased (ignore a stray second Unreleased)
60
+ if (VERSION_RE.test(stripped)) {
61
+ if (UNRELEASED_RE.test(stripped)) continue;
62
+ end = j;
63
+ break;
64
+ }
65
+ }
66
+
67
+ const before = lines.slice(0, start + 1).join("");
68
+ const body = lines.slice(start + 1, end).join("");
69
+ const after = lines.slice(end).join("");
70
+ return [before, body, after];
71
+ }
72
+
73
+ function canonicalCategory(heading: string): string | null {
74
+ const match = CAT_RE.exec(heading.replace(/\r?\n$/, ""));
75
+ if (!match) return null;
76
+ return CATEGORIES.find((c) => c.toLowerCase() === match[0].replace(/^###\s+/, "").trim().toLowerCase()) ?? null;
77
+ }
78
+
79
+ function splitSections(body: string): [string[], Array<{ heading: string; category: string | null; body: string[] }>] {
80
+ const preamble: string[] = [];
81
+ const sections: Array<{ heading: string; category: string | null; body: string[] }> = [];
82
+ let current: { heading: string; category: string | null; body: string[] } | null = null;
83
+ for (const line of body.split(/(?<=\n)/)) {
84
+ if (HEADING_RE.test(line)) {
85
+ current = { heading: line, category: canonicalCategory(line), body: [] };
86
+ sections.push(current);
87
+ } else if (current === null) {
88
+ preamble.push(line);
89
+ } else {
90
+ current.body.push(line);
91
+ }
92
+ }
93
+ return [preamble, sections];
94
+ }
95
+
96
+ function bulletBlocks(lines: string[]): Array<[string | null, string[]]> {
97
+ const blocks: Array<[string | null, string[]]> = [];
98
+ let current: string[] = [];
99
+ let key: string | null = null;
100
+ for (const line of lines) {
101
+ const match = BULLET_RE.exec(line.replace(/\r\n$/, "").replace(/\n$/, ""));
102
+ if (match) {
103
+ if (current.length) blocks.push([key, current]);
104
+ key = normalizeBullet(line).toLowerCase();
105
+ current = [line];
106
+ } else {
107
+ current.push(line);
108
+ }
109
+ }
110
+ if (current.length) blocks.push([key, current]);
111
+ return blocks;
112
+ }
113
+
114
+ function mergeSections(
115
+ body: string,
116
+ entries: Record<string, string[]>,
117
+ normalizeOnly: boolean,
118
+ ): [string, Record<string, number>, Record<string, number> | null, string | null] {
119
+ const [preamble, sections] = splitSections(body);
120
+ const first: Record<string, { heading: string; body: string[] }> = {};
121
+ const rendered: Array<{ heading: string; body: string[] }> = [];
122
+ for (const section of sections) {
123
+ if (section.category && section.category in first) {
124
+ first[section.category].body.push(...section.body);
125
+ } else {
126
+ const entry = { heading: section.heading, body: section.body };
127
+ rendered.push(entry);
128
+ if (section.category) first[section.category] = entry;
129
+ }
130
+ }
131
+
132
+ const added: Record<string, number> = {};
133
+ if (!normalizeOnly) {
134
+ for (const [rawCategory, bullets] of Object.entries(entries)) {
135
+ const category = CATEGORIES.find((c) => c.toLowerCase() === rawCategory.toLowerCase());
136
+ if (category === undefined) {
137
+ return ["", {}, {}, `invalid category: ${rawCategory}`];
138
+ }
139
+ let section = first[category];
140
+ if (section === undefined) {
141
+ section = { heading: `### ${category}\n`, body: ["\n"] };
142
+ rendered.push(section);
143
+ first[category] = section;
144
+ }
145
+
146
+ const seen = new Set<string>();
147
+ const kept: string[] = [];
148
+ for (const [key, block] of bulletBlocks(section.body)) {
149
+ if (key !== null && seen.has(key)) continue;
150
+ if (key !== null) seen.add(key);
151
+ kept.push(...block);
152
+ }
153
+
154
+ const fresh: string[] = [];
155
+ for (const bullet of bullets ?? []) {
156
+ const key = normalizeBullet(bullet).toLowerCase();
157
+ if (!key || seen.has(key)) continue;
158
+ seen.add(key);
159
+ fresh.push(formatBullet(bullet) + "\n");
160
+ }
161
+ if (fresh.length) {
162
+ let insertAt = 0;
163
+ while (insertAt < kept.length && !kept[insertAt].trim()) insertAt++;
164
+ section.body = [...kept.slice(0, insertAt), ...fresh, ...kept.slice(insertAt)];
165
+ } else {
166
+ section.body = kept;
167
+ }
168
+ added[category] = fresh.length;
169
+ }
170
+ }
171
+
172
+ let output = preamble.join("");
173
+ for (const section of rendered) {
174
+ output += section.heading + section.body.join("");
175
+ }
176
+ const counts: Record<string, number> = {};
177
+ for (const [category, section] of Object.entries(first)) {
178
+ counts[category] = bulletBlocks(section.body).filter(([key]) => key !== null).length;
179
+ }
180
+ return [output, counts, added, null];
181
+ }
182
+
183
+ function ensureFile(target: string): string {
184
+ if (fs.existsSync(target)) return fs.readFileSync(target, "utf8");
185
+ fs.mkdirSync(path.dirname(target), { recursive: true });
186
+ fs.writeFileSync(target, SKELETON, "utf8");
187
+ return SKELETON;
188
+ }
189
+
190
+ function hasUnreleasedHeading(text: string): boolean {
191
+ return text.split("\n").some((line) => UNRELEASED_RE.test(line.replace(/\n$/, "")));
192
+ }
193
+
194
+ function applyChangelog(
195
+ target: string,
196
+ entries: Record<string, string[]>,
197
+ normalizeOnly: boolean,
198
+ ): Record<string, any> {
199
+ let text = ensureFile(target);
200
+ if (!hasUnreleasedHeading(text)) {
201
+ const lines = text.split(/(?<=\n)/);
202
+ let insertAt = lines.length;
203
+ for (let i = 0; i < lines.length; i++) {
204
+ const stripped = lines[i].replace(/\n$/, "");
205
+ if (VERSION_RE.test(stripped) && !UNRELEASED_RE.test(stripped)) {
206
+ insertAt = i;
207
+ break;
208
+ }
209
+ }
210
+ text = lines.slice(0, insertAt).join("") + "## [Unreleased]\n\n" + lines.slice(insertAt).join("");
211
+ }
212
+
213
+ const [before, body, after] = splitUnreleased(text);
214
+ const [newBody, counts, addedCounts, error] = mergeSections(body, entries, normalizeOnly);
215
+ if (error) return { error };
216
+ // before already includes "## [Unreleased]\n"
217
+ const out = (before.endsWith("\n") ? before : before + "\n") + newBody + after;
218
+ fs.writeFileSync(target, out, "utf8");
219
+ return {
220
+ ok: true,
221
+ path: target,
222
+ normalize_only: normalizeOnly,
223
+ added: addedCounts,
224
+ categories: counts,
225
+ };
226
+ }
227
+
228
+ function normalizeEntries(entries: any): { data: Record<string, string[]> } | { error: string } {
229
+ if (!entries) return { data: {} };
230
+ if (Array.isArray(entries)) {
231
+ const grouped: Record<string, string[]> = {};
232
+ for (const item of entries) {
233
+ const cat = item.category ?? item.type;
234
+ const text = item.text ?? item.entry ?? "";
235
+ if (!cat || !text) {
236
+ return { error: "each entry needs category + text" };
237
+ }
238
+ const canon = CATEGORIES.find((c) => c.toLowerCase() === String(cat).toLowerCase());
239
+ if (!canon) return { error: `invalid category: ${cat}` };
240
+ (grouped[canon] ??= []).push(text);
241
+ }
242
+ return { data: grouped };
243
+ }
244
+ if (typeof entries === "object") {
245
+ const grouped: Record<string, string[]> = {};
246
+ for (const [cat, bullets] of Object.entries(entries)) {
247
+ const canon = CATEGORIES.find((c) => c.toLowerCase() === String(cat).toLowerCase());
248
+ if (!canon) return { error: `invalid category: ${cat}` };
249
+ const list = Array.isArray(bullets) ? bullets : [bullets];
250
+ grouped[canon] = list.filter(Boolean).map(String);
251
+ }
252
+ return { data: grouped };
253
+ }
254
+ return { error: "entries must be object or array" };
255
+ }
256
+
257
+ export function changelogApply({
258
+ entries,
259
+ path: changelogPath,
260
+ normalize_only,
261
+ workspace_root,
262
+ }: {
263
+ entries?: any;
264
+ path?: string;
265
+ normalize_only?: boolean;
266
+ workspace_root: string;
267
+ }): Record<string, any> {
268
+ const cwd = resolveWorkspaceRoot(workspace_root);
269
+ const normalized = normalize_only
270
+ ? { data: {} as Record<string, string[]> }
271
+ : normalizeEntries(entries);
272
+ if ("error" in normalized) return { error: normalized.error };
273
+ if (!normalize_only && Object.keys(normalized.data).length === 0) {
274
+ return { error: "entries required unless normalize_only" };
275
+ }
276
+
277
+ const rel = changelogPath || "CHANGELOG.md";
278
+ const target = path.resolve(cwd, rel);
279
+ const root = path.resolve(cwd);
280
+ if (target !== root && !target.startsWith(root + path.sep)) {
281
+ return { error: "changelog path must be inside workspace_root" };
282
+ }
283
+
284
+ try {
285
+ return applyChangelog(target, normalized.data, Boolean(normalize_only));
286
+ } catch (err) {
287
+ return { error: err instanceof Error ? err.message : "changelog apply failed" };
288
+ }
289
+ }
290
+
291
+ export function changelogUnreleasedStats(workspace_root: string, changelogPath = "CHANGELOG.md") {
292
+ const cwd = resolveWorkspaceRoot(workspace_root);
293
+ const abs = path.isAbsolute(changelogPath)
294
+ ? changelogPath
295
+ : path.join(cwd, changelogPath);
296
+ if (!fs.existsSync(abs)) return { exists: false };
297
+ const text = fs.readFileSync(abs, "utf8");
298
+ const m = text.match(/##\s+\[Unreleased\]([\s\S]*?)(?=\n##\s+\[|$)/i);
299
+ if (!m) return { exists: true, has_unreleased: false };
300
+ const body = m[1];
301
+ const headings = [...body.matchAll(/^###\s+(\w+)\s*$/gim)].map((x) => x[1]);
302
+ const dupes = headings.filter((h, i) =>
303
+ headings.slice(0, i).some((p) => p.toLowerCase() === h.toLowerCase()),
304
+ );
305
+ return {
306
+ exists: true,
307
+ has_unreleased: true,
308
+ category_headings: headings,
309
+ duplicate_category_headings: dupes,
310
+ needs_normalize: dupes.length > 0,
311
+ };
312
+ }
@@ -0,0 +1,26 @@
1
+ import { initStatus } from "./init";
2
+
3
+ export const ALL_ITEM_IDS = ["youtrack_json", "youtrack_token", "vcs_json", "gitlab_token", "github_token"];
4
+ export const CONFIG_GAP_MARKER = "workflow config missing";
5
+
6
+ export function describeConfigGaps(scope?: string[]): { missing: string[]; ok: boolean } {
7
+ const all = scope ?? ALL_ITEM_IDS;
8
+ try {
9
+ const status: unknown = initStatus();
10
+ if (!status || typeof status !== "object" || (status as Record<string, unknown>).error) return { missing: all, ok: false };
11
+ const items = (status as Record<string, unknown>).items;
12
+ if (!Array.isArray(items) || items.length === 0) return { missing: all, ok: false };
13
+ const known = all;
14
+ const missing = items
15
+ .filter((item) => item && (item as Record<string, unknown>).ok === false)
16
+ .map((item) => String((item as Record<string, unknown>).id))
17
+ .filter((id) => known.includes(id));
18
+ return { missing, ok: missing.length === 0 };
19
+ } catch {
20
+ return { missing: all, ok: false };
21
+ }
22
+ }
23
+
24
+ export function configGuardError(missing: string[]): string {
25
+ return `${CONFIG_GAP_MARKER}: ${missing.join(", ")}. Run \`npx workit init\` or \`/wk-init\` to configure.`;
26
+ }
@@ -0,0 +1,73 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export type BranchPreset = "gitflow" | "github-flow" | "trunk-based" | "custom";
6
+
7
+ export type ToolkitConfig = {
8
+ locale: string;
9
+ localeOptions: string[];
10
+ timezone: string;
11
+ branchPolicy: { preset: BranchPreset; allowed: string[]; protected: string[] };
12
+ };
13
+
14
+ export const PRESETS: Record<BranchPreset, { allowed: string[]; protected: string[] }> = {
15
+ gitflow: { allowed: ["feature/*", "bugfix/*", "hotfix/*", "release/*"], protected: ["main", "develop", "master", "prod", "production"] },
16
+ "github-flow": { allowed: ["*"], protected: ["main"] },
17
+ "trunk-based": { allowed: ["*"], protected: ["main"] },
18
+ custom: { allowed: [], protected: [] },
19
+ };
20
+
21
+ export const configDir = (): string =>
22
+ process.env.WORKFLOW_TOOLKIT_CONFIG
23
+ ?? process.env.WORKFLOW_TOOLKIT_CONFIG_DIR
24
+ ?? path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "workflow-toolkit");
25
+
26
+ export const LOCALE_RE = /^[a-z]{2,3}(-[A-Z]{2})?$/;
27
+
28
+ const DEFAULTS: ToolkitConfig = {
29
+ locale: "en",
30
+ localeOptions: ["en", "es-CL", "es-MX", "es-AR", "pt-BR"],
31
+ timezone: "America/Santiago",
32
+ branchPolicy: { preset: "gitflow", allowed: [...PRESETS.gitflow.allowed], protected: [...PRESETS.gitflow.protected] },
33
+ };
34
+
35
+ const readSafe = (p: string): string | null => {
36
+ try { return readFileSync(p, "utf8"); } catch { return null; }
37
+ };
38
+
39
+ export const readConfig = (): ToolkitConfig => {
40
+ const raw = readSafe(path.join(configDir(), "config.json"));
41
+ if (!raw) return DEFAULTS;
42
+ try {
43
+ const parsed = JSON.parse(raw) as Partial<ToolkitConfig>;
44
+ const locale = LOCALE_RE.test(String(parsed.locale ?? "")) ? parsed.locale as string : DEFAULTS.locale;
45
+ const preset = (parsed.branchPolicy?.preset ?? "gitflow") as BranchPreset;
46
+ const presetOk = Object.hasOwn(PRESETS, preset) ? preset : "gitflow";
47
+ const presetDefs = PRESETS[presetOk];
48
+ return {
49
+ locale,
50
+ localeOptions: Array.isArray(parsed.localeOptions) ? parsed.localeOptions : DEFAULTS.localeOptions,
51
+ timezone: parsed.timezone ?? DEFAULTS.timezone,
52
+ branchPolicy: {
53
+ preset: presetOk,
54
+ allowed: Array.isArray(parsed.branchPolicy?.allowed) ? parsed.branchPolicy.allowed : presetDefs.allowed,
55
+ protected: Array.isArray(parsed.branchPolicy?.protected) ? parsed.branchPolicy.protected : presetDefs.protected,
56
+ },
57
+ };
58
+ } catch {
59
+ return DEFAULTS;
60
+ }
61
+ };
62
+
63
+ export const writeConfig = (config: ToolkitConfig): void => {
64
+ const dir = configDir();
65
+ mkdirSync(dir, { recursive: true });
66
+ writeFileSync(path.join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n", "utf8");
67
+ };
68
+
69
+ export const resolveBranchPolicy = (config: ToolkitConfig): { allowed: RegExp[]; protected: Set<string> } => {
70
+ const allowed = config.branchPolicy.allowed.map((p) =>
71
+ new RegExp(`^${p.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`, "i"));
72
+ return { allowed, protected: new Set(config.branchPolicy.protected.map((p) => p.toLowerCase())) };
73
+ };
@@ -0,0 +1,207 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { CONFIG_GAP_MARKER } from "./config-guard";
4
+ import { parseTasksFromPlan } from "./docs-validate";
5
+
6
+ export type Detection = { choices: string[]; pattern: "alpha" | "numeric" } | null;
7
+
8
+ export const detectConfigGapError = (text: string): boolean =>
9
+ text.includes(CONFIG_GAP_MARKER);
10
+
11
+ // Enforcement-rail detectors: case-insensitive word-boundary heuristics.
12
+ // Conservative bias (D-03): require 1+ signal word AND 0 evidence words;
13
+ // when any evidence wording appears, do NOT fire.
14
+
15
+ const COMPLETION_CLAIMS = /\b(?:done|fixed|passing|green|complete|all set)\b/i;
16
+ const VERIFICATION_EVIDENCE =
17
+ /\bbun run check\b|\bworkflow_verify\b|\bbun test\b|\bchecks?\s+pass(?:es|ing)?\b|\btests?\s+pass(?:es|ing)?\b/i;
18
+
19
+ // Claims completion without verification-command evidence in the same text.
20
+ export const detectVerificationClaim = (text: string): boolean =>
21
+ COMPLETION_CLAIMS.test(text) && !VERIFICATION_EVIDENCE.test(text);
22
+
23
+ const IMPLEMENTATION_SIGNALS =
24
+ /\b(?:changed|implemented|implementing|added|refactored|commit(?:ted|s)?|edited)\b/i;
25
+ const FAILING_TEST_WORDS =
26
+ /\b(?:failing test|test failed|watch it fail|red-green|tdd|test first)\b/i;
27
+
28
+ // Implementation signal without a preceding failing-test mention.
29
+ export const detectUntestedImplementation = (text: string): boolean =>
30
+ IMPLEMENTATION_SIGNALS.test(text) && !FAILING_TEST_WORDS.test(text);
31
+
32
+ const IMPLEMENTATION_ACTION =
33
+ /\b(?:implement|add the feature|write the code|create the component|build (?:the )?(?:feature|component|module|command|screen|service))\b/i;
34
+ const DESIGN_WORDS =
35
+ /\b(?:design|spec|brainstorm|approved|plan|requested|instruction|as you asked|as you said)\b/i;
36
+
37
+ // Implementation action without a presented/approved design.
38
+ export const detectImplementationWithoutDesign = (text: string): boolean =>
39
+ IMPLEMENTATION_ACTION.test(text) && !DESIGN_WORDS.test(text);
40
+
41
+ const FIX_SIGNALS = /\b(?:fixed|fix|patch|solved)\b/i;
42
+ const ROOT_CAUSE_WORDS =
43
+ /\b(?:root cause|caused by|reproduced|stack trace|investigation|because)\b/i;
44
+
45
+ // Fix proposal without root-cause evidence.
46
+ export const detectFixWithoutRootCause = (text: string): boolean =>
47
+ FIX_SIGNALS.test(text) && !ROOT_CAUSE_WORDS.test(text);
48
+
49
+ const ACCEPTANCE_SIGNALS = /\b(?:agreed|makes sense|good point|will implement)\b/i;
50
+ const REVIEW_VERIFICATION_WORDS =
51
+ /\b(?:verif(?:y|ied)|check(?:ed|ing)?|reproduced|tested|confirmed)\b/i;
52
+
53
+ // Review acceptance without verification wording.
54
+ export const detectBlindReviewAcceptance = (text: string): boolean =>
55
+ ACCEPTANCE_SIGNALS.test(text) && !REVIEW_VERIFICATION_WORDS.test(text);
56
+
57
+ // Instruction-option detector: a clickable `question` option whose label is an
58
+ // instruction to type free text. Clicking such an option returns the label
59
+ // literal, not the typed value. Conservative: instruction verb + free-text noun.
60
+ export const INSTRUCTION_OPTION_RE =
61
+ /^(type|provide|paste|enter|write|give me)\b.*\b(url|id|issue|text|notes|number)\b/i;
62
+
63
+ // Accepts the question tool-call input: an array of questions OR { questions: [...] }.
64
+ export const detectInstructionOption = (questions: unknown): boolean => {
65
+ const list = Array.isArray(questions)
66
+ ? questions
67
+ : (questions as { questions?: unknown } | null)?.questions;
68
+ if (!Array.isArray(list)) return false;
69
+ for (const q of list) {
70
+ if (!q || typeof q !== "object") continue;
71
+ const options = (q as { options?: unknown }).options;
72
+ if (!Array.isArray(options)) continue;
73
+ for (const opt of options) {
74
+ if (!opt || typeof opt !== "object") continue;
75
+ const label = (opt as { label?: unknown }).label;
76
+ if (typeof label !== "string" || !label) continue;
77
+ if (INSTRUCTION_OPTION_RE.test(label)) return true;
78
+ }
79
+ }
80
+ return false;
81
+ };
82
+
83
+ // Raw delivery: an UNLABELED fenced block carrying doc markers — the agent pasted
84
+ // the doc instead of rendering it. Rendered docs keep labeled fences (```mermaid)
85
+ // and never match; reminders use angle-bracket blocks, so no false positives.
86
+ // Labeled blocks are stripped first so their plain closing fence (```) can't match.
87
+ export const detectRawDocDelivery = (text: string): boolean =>
88
+ /^```\s*$/m.test(text.replace(/```\S[^\n]*\r?\n[\s\S]*?```/g, "")) &&
89
+ (text.includes("# Spec") || text.includes("# Plan") ||
90
+ text.includes("**Spec:**") || text.includes("**Branch:**"));
91
+
92
+ // Interrogative gate: a literal question mark OR explicit interrogative phrases.
93
+ // Plain "I want to confirm..." or "the script which runs" must NOT match.
94
+ const INTERROGATIVE = /[?¿]|which\s+one|choose\s+(?:one|between|among)|do\s+you\s+(?:want|prefer)|want\s+me\s+to/i;
95
+
96
+ export const detectProseChoices = (text: string): Detection => {
97
+ if (!INTERROGATIVE.test(text)) return null;
98
+
99
+ const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
100
+
101
+ const alphaAll = [...text.matchAll(/([a-dA-D])[.)]\s+([^\n]*?)(?=\s+[a-dA-D][.)]\s|$)/g)]
102
+ .map((m) => ({ letter: m[1].toLowerCase(), choice: m[2].trim() }));
103
+ const alphaLines = lines
104
+ .map((l) => /^([a-dA-D])[.)]\s+(.+)$/.exec(l))
105
+ .filter((m): m is RegExpExecArray => Boolean(m))
106
+ .map((m) => ({ letter: m[1].toLowerCase(), choice: m[2] }));
107
+ const alpha = alphaAll.length >= alphaLines.length ? alphaAll : alphaLines;
108
+
109
+ if (alpha.length >= 2) {
110
+ const letters = alpha.map((a) => a.letter);
111
+ const expected = ["a", "b", "c", "d"].slice(0, alpha.length);
112
+ if (letters.every((l, i) => l === expected[i])) {
113
+ return { choices: alpha.map((a) => a.choice), pattern: "alpha" };
114
+ }
115
+ }
116
+
117
+ const numericAll = [...text.matchAll(/(\d+)[.)]\s+([^\n]*?)(?=\s+\d+[.)]\s|$)/g)]
118
+ .map((m) => ({ num: Number(m[1]), choice: m[2].trim() }));
119
+ const numericLines = lines
120
+ .map((l) => /^(\d+)[.)]\s+(.+)$/.exec(l))
121
+ .filter((m): m is RegExpExecArray => Boolean(m))
122
+ .map((m) => ({ num: Number(m[1]), choice: m[2] }));
123
+ const numeric = numericAll.length >= numericLines.length ? numericAll : numericLines;
124
+
125
+ if (numeric.length >= 2) {
126
+ const nums = numeric.map((n) => n.num);
127
+ if (nums.every((n, i) => n === i + 1)) {
128
+ return { choices: numeric.map((n) => n.choice), pattern: "numeric" };
129
+ }
130
+ }
131
+
132
+ return null;
133
+ };
134
+
135
+ const stripFences = (text: string): string => {
136
+ const lines = text.split("\n");
137
+ const out: string[] = [];
138
+ let inFence = false;
139
+ for (const line of lines) {
140
+ if (line.startsWith("```")) { inFence = !inFence; continue; }
141
+ if (!inFence) out.push(line);
142
+ }
143
+ return out.join("\n");
144
+ };
145
+
146
+ export const detectBacktickDocRefs = (text: string): string[] | null => {
147
+ const body = stripFences(text);
148
+ const refs = [...body.matchAll(/`docs\/[^`\s]+\.md`/g)].map((m) => m[0]);
149
+ if (!refs.length) return null;
150
+ if (/\[[^\]]+\]\(docs\//.test(body)) return null;
151
+ return refs;
152
+ };
153
+
154
+ // The rail has no terminal FlowStatus — a fully completed SDD ledger is done,
155
+ // but only if its complete set covers every task id in the plan (the last
156
+ // task's append may have been skipped, leaving an all-complete partial ledger).
157
+ const isPlanComplete = (slugDir: string): boolean => {
158
+ const ledger = path.join(slugDir, "sdd", "progress.md");
159
+ if (!existsSync(ledger)) return false; // no ledger yet — not provably complete
160
+ let taskLines: string[];
161
+ try {
162
+ taskLines = readFileSync(ledger, "utf8")
163
+ .split("\n")
164
+ .map((l) => l.trim())
165
+ .filter((l) => /^Task \s*\d+:/i.test(l));
166
+ } catch {
167
+ return true; // unreadable ledger → exclude the slug
168
+ }
169
+ if (taskLines.length === 0 || !taskLines.every((l) => /^Task \s*\d+:\s*complete\b/i.test(l))) {
170
+ return false;
171
+ }
172
+ try {
173
+ const planTasks = parseTasksFromPlan(readFileSync(path.join(slugDir, "plan.md"), "utf8"));
174
+ if (planTasks.length === 0) return true;
175
+ const completeIds = new Set(
176
+ taskLines.map((l) => Number(/^Task\s*(\d+):/i.exec(l)?.[1])).filter(Number.isFinite),
177
+ );
178
+ return planTasks.every((t) => completeIds.has(t.id));
179
+ } catch {
180
+ return false; // unreadable/missing plan.md — not provably complete, rail stays on
181
+ }
182
+ };
183
+
184
+ export const findActiveSubagentDrivenPlans = (root: string): string[] => {
185
+ const docsDir = path.join(root, "docs");
186
+ if (!existsSync(docsDir)) return [];
187
+ const slugs: string[] = [];
188
+ for (const slug of readdirSync(docsDir)) {
189
+ const file = path.join(docsDir, slug, "sdd", "flow.json");
190
+ try {
191
+ const flow = JSON.parse(readFileSync(file, "utf8")) as {
192
+ menu?: { chosen?: string };
193
+ plan?: { status?: string };
194
+ };
195
+ if (
196
+ flow.menu?.chosen === "subagent-driven" &&
197
+ flow.plan?.status === "approved" &&
198
+ !isPlanComplete(path.join(docsDir, slug))
199
+ ) {
200
+ slugs.push(slug);
201
+ }
202
+ } catch {
203
+ // skip unreadable or malformed flow.json
204
+ }
205
+ }
206
+ return slugs;
207
+ };
@@ -0,0 +1,14 @@
1
+ export const MAX_LINES = 150;
2
+ export const MAX_BYTES = 8192;
3
+ export const MAX_MERMAID = 3;
4
+
5
+ const MERMAID_FENCE = /^```mermaid\s*$/gm;
6
+
7
+ export const shouldRenderDoc = (text: string): boolean => {
8
+ const lines = text.split(/\r?\n/);
9
+ if (lines[lines.length - 1] === "") lines.pop();
10
+ if (lines.length > MAX_LINES) return false;
11
+ if (Buffer.byteLength(text, "utf8") > MAX_BYTES) return false;
12
+ const mermaidCount = text.match(MERMAID_FENCE)?.length ?? 0;
13
+ return mermaidCount <= MAX_MERMAID;
14
+ };