@brainervirus/workit-core 0.5.6 → 0.6.1

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 (43) hide show
  1. package/README.md +4 -17
  2. package/package.json +9 -9
  3. package/scripts/_shared/common.sh +18 -3
  4. package/scripts/install-opencode-plugin.sh +14 -9
  5. package/scripts/lib/config-dir.sh +26 -0
  6. package/scripts/sync-runtime.sh +4 -1
  7. package/src/core/branch.ts +143 -48
  8. package/src/core/changelog.ts +17 -14
  9. package/src/core/config-guard.ts +9 -2
  10. package/src/core/config.ts +95 -15
  11. package/src/core/detector.ts +22 -11
  12. package/src/core/docs-repo.ts +50 -15
  13. package/src/core/docs-validate.ts +163 -37
  14. package/src/core/flow-state.ts +6 -2
  15. package/src/core/gitignore.ts +11 -2
  16. package/src/core/handoff-context.ts +18 -5
  17. package/src/core/hygiene.ts +27 -5
  18. package/src/core/init.ts +86 -21
  19. package/src/core/parse-sections.ts +2 -2
  20. package/src/core/plan-tasks.ts +13 -3
  21. package/src/core/ports/youtrack-api.ts +3 -1
  22. package/src/core/ports/youtrack-config.ts +1 -3
  23. package/src/core/pr-create.ts +47 -15
  24. package/src/core/present.ts +11 -2
  25. package/src/core/reminder.ts +1 -2
  26. package/src/core/repo-tool.ts +4 -1
  27. package/src/core/rules.ts +10 -7
  28. package/src/core/scripts.ts +7 -2
  29. package/src/core/sdd.ts +11 -3
  30. package/src/core/templates.ts +14 -4
  31. package/src/core/vcs-config.ts +94 -36
  32. package/src/core/verify-parse.ts +4 -2
  33. package/src/core/workspaces.ts +2 -2
  34. package/src/core/youtrack.ts +233 -58
  35. package/src/core.ts +18 -3
  36. package/src/tools/docs-repo.ts +12 -3
  37. package/src/tools/flow.ts +24 -13
  38. package/src/tools/handoff.ts +28 -23
  39. package/src/tools/present.ts +14 -10
  40. package/src/tools/repo.ts +220 -87
  41. package/src/tools/sdd.ts +93 -66
  42. package/src/tools/youtrack.ts +119 -52
  43. package/templates/superpowers-doc-contract.md +1 -1
@@ -1,4 +1,12 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import {
2
+ copyFileSync,
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ writeFileSync,
9
+ } from "node:fs";
2
10
  import os from "node:os";
3
11
  import path from "node:path";
4
12
 
@@ -12,16 +20,69 @@ export type ToolkitConfig = {
12
20
  };
13
21
 
14
22
  export const PRESETS: Record<BranchPreset, { allowed: string[]; protected: string[] }> = {
15
- gitflow: { allowed: ["feature/*", "bugfix/*", "hotfix/*", "release/*"], protected: ["main", "develop", "master", "prod", "production"] },
23
+ gitflow: {
24
+ allowed: ["feature/*", "bugfix/*", "hotfix/*", "release/*"],
25
+ protected: ["main", "develop", "master", "prod", "production"],
26
+ },
16
27
  "github-flow": { allowed: ["*"], protected: ["main"] },
17
28
  "trunk-based": { allowed: ["*"], protected: ["main"] },
18
29
  custom: { allowed: [], protected: [] },
19
30
  };
20
31
 
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");
32
+ export const resolveConfigDir = (): string =>
33
+ process.env.WORKFLOW_TOOLKIT_CONFIG ??
34
+ process.env.WORKFLOW_TOOLKIT_CONFIG_DIR ??
35
+ path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "workit");
36
+
37
+ // One-time lazy migration from the legacy ~/.config/workflow-toolkit dir.
38
+ // migratedDir remembers the resolved dir already checked: configDir() is on
39
+ // hot paths, so subsequent calls are one string compare. Re-checking per
40
+ // unique dir also keeps tests with swapped env working.
41
+ // ponytail: cache keyed by dir value — an env override explicitly set to the
42
+ // default path caches before migration could trigger; only matters if that
43
+ // env is cleared mid-process (next unique dir value re-checks).
44
+ let migratedDir: string | null = null;
45
+ // A mid-loop copy failure leaves the new dir half-populated; keep retrying
46
+ // until a full pass succeeds instead of silently skipping the failed entry.
47
+ let migrationFailed = false;
48
+
49
+ export const ensureConfigDir = (dir: string = resolveConfigDir()): string => {
50
+ if (migratedDir === dir) return dir;
51
+ if (process.env.WORKFLOW_TOOLKIT_CONFIG || process.env.WORKFLOW_TOOLKIT_CONFIG_DIR) {
52
+ migratedDir = dir;
53
+ return dir;
54
+ }
55
+ const legacy = path.join(
56
+ process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
57
+ "workflow-toolkit",
58
+ );
59
+ if (!existsSync(legacy)) {
60
+ migratedDir = dir;
61
+ return dir;
62
+ }
63
+ if (!migrationFailed && existsSync(dir)) {
64
+ migratedDir = dir;
65
+ return dir;
66
+ }
67
+ migrationFailed = false;
68
+ mkdirSync(dir, { recursive: true });
69
+ for (const entry of readdirSync(legacy, { withFileTypes: true })) {
70
+ const src = path.join(legacy, entry.name);
71
+ const dest = path.join(dir, entry.name);
72
+ if (existsSync(dest)) continue;
73
+ try {
74
+ if (entry.isDirectory()) cpSync(src, dest, { recursive: true });
75
+ else if (entry.isFile()) copyFileSync(src, dest);
76
+ } catch (err) {
77
+ migrationFailed = true;
78
+ console.warn(`[workit] config migration: failed to copy ${src}: ${(err as Error).message}`);
79
+ }
80
+ }
81
+ if (!migrationFailed) migratedDir = dir;
82
+ return dir;
83
+ };
84
+
85
+ export const configDir = (): string => ensureConfigDir();
25
86
 
26
87
  export const LOCALE_RE = /^[a-z]{2,3}(-[A-Z]{2})?$/;
27
88
 
@@ -29,11 +90,19 @@ const DEFAULTS: ToolkitConfig = {
29
90
  locale: "en",
30
91
  localeOptions: ["en", "es-CL", "es-MX", "es-AR", "pt-BR"],
31
92
  timezone: "America/Santiago",
32
- branchPolicy: { preset: "gitflow", allowed: [...PRESETS.gitflow.allowed], protected: [...PRESETS.gitflow.protected] },
93
+ branchPolicy: {
94
+ preset: "gitflow",
95
+ allowed: [...PRESETS.gitflow.allowed],
96
+ protected: [...PRESETS.gitflow.protected],
97
+ },
33
98
  };
34
99
 
35
100
  const readSafe = (p: string): string | null => {
36
- try { return readFileSync(p, "utf8"); } catch { return null; }
101
+ try {
102
+ return readFileSync(p, "utf8");
103
+ } catch {
104
+ return null;
105
+ }
37
106
  };
38
107
 
39
108
  export const readConfig = (): ToolkitConfig => {
@@ -41,18 +110,26 @@ export const readConfig = (): ToolkitConfig => {
41
110
  if (!raw) return DEFAULTS;
42
111
  try {
43
112
  const parsed = JSON.parse(raw) as Partial<ToolkitConfig>;
44
- const locale = LOCALE_RE.test(String(parsed.locale ?? "")) ? parsed.locale as string : DEFAULTS.locale;
113
+ const locale = LOCALE_RE.test(String(parsed.locale ?? ""))
114
+ ? (parsed.locale as string)
115
+ : DEFAULTS.locale;
45
116
  const preset = (parsed.branchPolicy?.preset ?? "gitflow") as BranchPreset;
46
117
  const presetOk = Object.hasOwn(PRESETS, preset) ? preset : "gitflow";
47
118
  const presetDefs = PRESETS[presetOk];
48
119
  return {
49
120
  locale,
50
- localeOptions: Array.isArray(parsed.localeOptions) ? parsed.localeOptions : DEFAULTS.localeOptions,
121
+ localeOptions: Array.isArray(parsed.localeOptions)
122
+ ? parsed.localeOptions
123
+ : DEFAULTS.localeOptions,
51
124
  timezone: parsed.timezone ?? DEFAULTS.timezone,
52
125
  branchPolicy: {
53
126
  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,
127
+ allowed: Array.isArray(parsed.branchPolicy?.allowed)
128
+ ? parsed.branchPolicy.allowed
129
+ : presetDefs.allowed,
130
+ protected: Array.isArray(parsed.branchPolicy?.protected)
131
+ ? parsed.branchPolicy.protected
132
+ : presetDefs.protected,
56
133
  },
57
134
  };
58
135
  } catch {
@@ -66,8 +143,11 @@ export const writeConfig = (config: ToolkitConfig): void => {
66
143
  writeFileSync(path.join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n", "utf8");
67
144
  };
68
145
 
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"));
146
+ export const resolveBranchPolicy = (
147
+ config: ToolkitConfig,
148
+ ): { allowed: RegExp[]; protected: Set<string> } => {
149
+ const allowed = config.branchPolicy.allowed.map(
150
+ (p) => new RegExp(`^${p.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`, "i"),
151
+ );
72
152
  return { allowed, protected: new Set(config.branchPolicy.protected.map((p) => p.toLowerCase())) };
73
153
  };
@@ -5,8 +5,7 @@ import { parseTasksFromPlan } from "./docs-validate";
5
5
 
6
6
  export type Detection = { choices: string[]; pattern: "alpha" | "numeric" } | null;
7
7
 
8
- export const detectConfigGapError = (text: string): boolean =>
9
- text.includes(CONFIG_GAP_MARKER);
8
+ export const detectConfigGapError = (text: string): boolean => text.includes(CONFIG_GAP_MARKER);
10
9
 
11
10
  // Enforcement-rail detectors: case-insensitive word-boundary heuristics.
12
11
  // Conservative bias (D-03): require 1+ signal word AND 0 evidence words;
@@ -86,20 +85,27 @@ export const detectInstructionOption = (questions: unknown): boolean => {
86
85
  // Labeled blocks are stripped first so their plain closing fence (```) can't match.
87
86
  export const detectRawDocDelivery = (text: string): boolean =>
88
87
  /^```\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:**"));
88
+ (text.includes("# Spec") ||
89
+ text.includes("# Plan") ||
90
+ text.includes("**Spec:**") ||
91
+ text.includes("**Branch:**"));
91
92
 
92
93
  // Interrogative gate: a literal question mark OR explicit interrogative phrases.
93
94
  // 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
+ const INTERROGATIVE =
96
+ /[?¿]|which\s+one|choose\s+(?:one|between|among)|do\s+you\s+(?:want|prefer)|want\s+me\s+to/i;
95
97
 
96
98
  export const detectProseChoices = (text: string): Detection => {
97
99
  if (!INTERROGATIVE.test(text)) return null;
98
100
 
99
- const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
101
+ const lines = text
102
+ .split("\n")
103
+ .map((l) => l.trim())
104
+ .filter(Boolean);
100
105
 
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() }));
106
+ const alphaAll = [...text.matchAll(/([a-dA-D])[.)]\s+([^\n]*?)(?=\s+[a-dA-D][.)]\s|$)/g)].map(
107
+ (m) => ({ letter: m[1].toLowerCase(), choice: m[2].trim() }),
108
+ );
103
109
  const alphaLines = lines
104
110
  .map((l) => /^([a-dA-D])[.)]\s+(.+)$/.exec(l))
105
111
  .filter((m): m is RegExpExecArray => Boolean(m))
@@ -114,8 +120,10 @@ export const detectProseChoices = (text: string): Detection => {
114
120
  }
115
121
  }
116
122
 
117
- const numericAll = [...text.matchAll(/(\d+)[.)]\s+([^\n]*?)(?=\s+\d+[.)]\s|$)/g)]
118
- .map((m) => ({ num: Number(m[1]), choice: m[2].trim() }));
123
+ const numericAll = [...text.matchAll(/(\d+)[.)]\s+([^\n]*?)(?=\s+\d+[.)]\s|$)/g)].map((m) => ({
124
+ num: Number(m[1]),
125
+ choice: m[2].trim(),
126
+ }));
119
127
  const numericLines = lines
120
128
  .map((l) => /^(\d+)[.)]\s+(.+)$/.exec(l))
121
129
  .filter((m): m is RegExpExecArray => Boolean(m))
@@ -137,7 +145,10 @@ const stripFences = (text: string): string => {
137
145
  const out: string[] = [];
138
146
  let inFence = false;
139
147
  for (const line of lines) {
140
- if (line.startsWith("```")) { inFence = !inFence; continue; }
148
+ if (line.startsWith("```")) {
149
+ inFence = !inFence;
150
+ continue;
151
+ }
141
152
  if (!inFence) out.push(line);
142
153
  }
143
154
  return out.join("\n");
@@ -1,11 +1,10 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
- import os from "node:os";
4
3
  import path from "node:path";
4
+ import { configDir } from "./config";
5
5
 
6
6
  const configPath = () =>
7
- process.env.WORKFLOW_DOCS_REPO_CONFIG
8
- ?? path.join(os.homedir(), ".config", "workflow-toolkit", "docs-repo.json");
7
+ process.env.WORKFLOW_DOCS_REPO_CONFIG ?? path.join(configDir(), "docs-repo.json");
9
8
 
10
9
  export const readDocsRepoConfig = (): { path: string } | null => {
11
10
  try {
@@ -25,7 +24,8 @@ export const writeDocsRepoConfig = (docsPath: string): void => {
25
24
  export const docsRepoPath = (): string | null => readDocsRepoConfig()?.path ?? null;
26
25
 
27
26
  export const validateDocsRepo = (docsPath: string): { ok: true } | { ok: false; error: string } => {
28
- if (!existsSync(docsPath)) return { ok: false, error: `docs repo path does not exist: ${docsPath}` };
27
+ if (!existsSync(docsPath))
28
+ return { ok: false, error: `docs repo path does not exist: ${docsPath}` };
29
29
  try {
30
30
  execFileSync("git", ["-C", docsPath, "rev-parse", "--is-inside-work-tree"], { stdio: "pipe" });
31
31
  } catch {
@@ -49,7 +49,10 @@ export const linkDocsRepo = (
49
49
 
50
50
  export const listSpecs = (
51
51
  workspaceRoot: string,
52
- ): { docs_repo: string | null; specs: { slug: string; spec: string; promoted: boolean; target: string | null }[] } => {
52
+ ): {
53
+ docs_repo: string | null;
54
+ specs: { slug: string; spec: string; promoted: boolean; target: string | null }[];
55
+ } => {
53
56
  const repoPath = docsRepoPath();
54
57
  const specs: { slug: string; spec: string; promoted: boolean; target: string | null }[] = [];
55
58
  const docsDir = path.join(workspaceRoot, "docs");
@@ -63,7 +66,9 @@ export const listSpecs = (
63
66
  if (repoPath) {
64
67
  const featuresDir = path.join(repoPath, "features");
65
68
  if (existsSync(featuresDir)) {
66
- const match = readdirSync(featuresDir).find((d) => new RegExp(`^20\\d{2}-\\d{2}-${slug}$`).test(d));
69
+ const match = readdirSync(featuresDir).find((d) =>
70
+ new RegExp(`^20\\d{2}-\\d{2}-${slug}$`).test(d),
71
+ );
67
72
  if (match) {
68
73
  promoted = true;
69
74
  target = path.join(repoPath, "features", match);
@@ -84,13 +89,20 @@ const monthPrefix = () => {
84
89
  };
85
90
 
86
91
  const readSafe = (p: string): string | null => {
87
- try { return readFileSync(p, "utf8"); } catch { return null; }
92
+ try {
93
+ return readFileSync(p, "utf8");
94
+ } catch {
95
+ return null;
96
+ }
88
97
  };
89
98
 
90
99
  const specSummary = (specText: string): string => {
91
- const contextMatch = specText.match(/## Context\n\n([\s\S]*?)(?=\n## |\Z)/);
100
+ const contextMatch = specText.match(/## Context\n\n([\s\S]*?)(?=\n## |Z)/);
92
101
  if (!contextMatch) return "";
93
- const first = contextMatch[1].trim().split("\n").find((l) => l.trim() && !l.startsWith("<!--"));
102
+ const first = contextMatch[1]
103
+ .trim()
104
+ .split("\n")
105
+ .find((l) => l.trim() && !l.startsWith("<!--"));
94
106
  return (first ?? "").trim();
95
107
  };
96
108
 
@@ -105,7 +117,8 @@ export const promoteSpec = (
105
117
  workspaceRoot: string,
106
118
  slug: string,
107
119
  opts: { confirmed: boolean; force?: boolean },
108
- ): { ok: true; target_dir: string; files: string[]; index_updated: boolean }
120
+ ):
121
+ | { ok: true; target_dir: string; files: string[]; index_updated: boolean }
109
122
  | { ok: false; error: string; findings?: unknown[] } => {
110
123
  if (!opts.confirmed) return { ok: false, error: "confirmed: true required" };
111
124
  if (!SLUG_RE.test(slug)) return { ok: false, error: `invalid slug: ${JSON.stringify(slug)}` };
@@ -121,14 +134,22 @@ export const promoteSpec = (
121
134
 
122
135
  const planText = readSafe(path.join(workspaceRoot, planRel));
123
136
  if (planText !== null) {
124
- const validated = docsValidate({ spec_path: specRel, plan_path: planRel, workspace_root: workspaceRoot });
137
+ const validated = docsValidate({
138
+ spec_path: specRel,
139
+ plan_path: planRel,
140
+ workspace_root: workspaceRoot,
141
+ });
125
142
  if (validated.ok === false) return { ok: false, error: validated.error };
126
143
  }
127
144
 
128
145
  const findings = qualitySpec(specText);
129
146
  const hardFindings = findings.filter((f) => f.severity === "hard");
130
147
  if (hardFindings.length > 0 && !opts.force) {
131
- return { ok: false, error: "spec has hard quality findings; pass force: true to override", findings };
148
+ return {
149
+ ok: false,
150
+ error: "spec has hard quality findings; pass force: true to override",
151
+ findings,
152
+ };
132
153
  }
133
154
 
134
155
  // SDD working state must be gitignored before promotion
@@ -136,9 +157,21 @@ export const promoteSpec = (
136
157
  const sddDir = path.join(workspaceRoot, "docs", slug, "sdd");
137
158
  if (existsSync(sddDir)) {
138
159
  try {
139
- execFileSync("git", ["-C", workspaceRoot, "check-ignore", path.posix.join("docs", slug, "sdd", "progress.md")], { stdio: "pipe" });
160
+ execFileSync(
161
+ "git",
162
+ [
163
+ "-C",
164
+ workspaceRoot,
165
+ "check-ignore",
166
+ path.posix.join("docs", slug, "sdd", "progress.md"),
167
+ ],
168
+ { stdio: "pipe" },
169
+ );
140
170
  } catch {
141
- return { ok: false, error: `docs/${slug}/sdd/ is not gitignored — add 'docs/*/sdd/' to .gitignore or pass force: true` };
171
+ return {
172
+ ok: false,
173
+ error: `docs/${slug}/sdd/ is not gitignored — add 'docs/*/sdd/' to .gitignore or pass force: true`,
174
+ };
142
175
  }
143
176
  }
144
177
  }
@@ -179,7 +212,9 @@ ${planText !== null ? "| [plan.md](./plan.md) | Plan de implementación |\n" : "
179
212
  files.push("README.md");
180
213
 
181
214
  const indexPath = path.join(repoPath, "features", "README.md");
182
- const indexText = readSafe(indexPath) ?? `# Features\n\nEspecificaciones y planes por feature.\n\n## Features documentadas\n\n| Feature | Repos afectados | Estado |\n| --- | --- | --- |\n`;
215
+ const indexText =
216
+ readSafe(indexPath) ??
217
+ `# Features\n\nEspecificaciones y planes por feature.\n\n## Features documentadas\n\n| Feature | Repos afectados | Estado |\n| --- | --- | --- |\n`;
183
218
  const row = `| [${slug}](./${prefix}-${slug}/) | ${specRepos(specText)} | Spec en revisión |`;
184
219
  const rowRe = new RegExp(`^\\| \\[${slug}\\]\\([^)]*\\) \\|.*$`, "m");
185
220
  let newIndex: string;