@brainervirus/workit-core 0.6.0 → 0.7.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 (95) hide show
  1. package/package.json +10 -14
  2. package/scripts/doctor-check.ts +20 -0
  3. package/scripts/install-cursor-plugin.sh +51 -28
  4. package/scripts/install-opencode-plugin.sh +25 -22
  5. package/scripts/rewrite-workspace-deps.ts +15 -9
  6. package/scripts/sync-runtime.sh +71 -19
  7. package/scripts/vendor-assets.ts +37 -0
  8. package/skills/wk-implement/SKILL.md +2 -2
  9. package/skills/wk-pr/SKILL.md +1 -1
  10. package/src/core/boundary.ts +27 -0
  11. package/src/core/branch-policy.ts +63 -0
  12. package/src/core/branch.ts +166 -57
  13. package/src/core/changelog.ts +17 -14
  14. package/src/core/config-guard.ts +9 -2
  15. package/src/core/config.ts +227 -32
  16. package/src/core/detector.ts +22 -11
  17. package/src/core/docs-layout.ts +251 -0
  18. package/src/core/docs-migration.ts +639 -0
  19. package/src/core/docs-repo.ts +58 -21
  20. package/src/core/docs-validate.ts +181 -43
  21. package/src/core/doctor.ts +801 -0
  22. package/src/core/flow-state.ts +1579 -137
  23. package/src/core/git.ts +22 -5
  24. package/src/core/gitignore.ts +11 -2
  25. package/src/core/handoff-context.ts +18 -5
  26. package/src/{tools/handoff.ts → core/handoff-tools.ts} +16 -63
  27. package/src/core/hygiene.ts +53 -17
  28. package/src/core/init.ts +128 -31
  29. package/src/core/logger.ts +321 -0
  30. package/src/core/package-root.ts +28 -0
  31. package/src/core/parse-sections.ts +2 -2
  32. package/src/core/plan-tasks.ts +13 -3
  33. package/src/core/ports/init-toolkit-status.ts +1 -1
  34. package/src/core/ports/vcs-verify-token.ts +1 -1
  35. package/src/core/ports/youtrack-api.ts +4 -2
  36. package/src/core/ports/youtrack-config.ts +1 -3
  37. package/src/core/ports/youtrack-verify-token.ts +1 -1
  38. package/src/core/pr-create.ts +156 -29
  39. package/src/core/present.ts +11 -2
  40. package/src/core/registration.ts +215 -0
  41. package/src/core/reminder.ts +1 -2
  42. package/src/core/repo-context.ts +447 -0
  43. package/src/core/repo-tool.ts +4 -1
  44. package/src/core/repo-tools.ts +23 -0
  45. package/src/core/rules.ts +10 -7
  46. package/src/core/safe-write.ts +22 -0
  47. package/src/core/scripts.ts +3 -39
  48. package/src/core/sdd.ts +56 -31
  49. package/src/core/setup-state.ts +54 -0
  50. package/src/core/setup.ts +1216 -0
  51. package/src/core/skill-manifests.ts +95 -0
  52. package/src/core/support-matrix.ts +12 -0
  53. package/src/core/sync-runtime.ts +348 -0
  54. package/src/core/templates.ts +16 -6
  55. package/src/core/vcs-config.ts +191 -62
  56. package/src/core/verify-parse.ts +4 -2
  57. package/src/core/verify-project.ts +181 -0
  58. package/src/core/workspaces.ts +136 -17
  59. package/src/core/youtrack-tools.ts +228 -0
  60. package/src/core/youtrack.ts +320 -87
  61. package/src/core.ts +18 -3
  62. package/templates/execution-contract.md +9 -7
  63. package/templates/superpowers-doc-contract.md +5 -4
  64. package/scripts/_shared/common.sh +0 -158
  65. package/scripts/changelog-context.sh +0 -42
  66. package/scripts/docs-refresh-context.sh +0 -40
  67. package/scripts/init/apply.sh +0 -5
  68. package/scripts/init/status.sh +0 -5
  69. package/scripts/init/toolkit-status.sh +0 -5
  70. package/scripts/pr-create.sh +0 -5
  71. package/scripts/pr-ready-context.sh +0 -88
  72. package/scripts/present/ascii-wireframe.sh +0 -5
  73. package/scripts/present/flow-diagram.sh +0 -5
  74. package/scripts/release-notes-context.sh +0 -40
  75. package/scripts/vcs/config.sh +0 -5
  76. package/scripts/vcs/merged-style.sh +0 -5
  77. package/scripts/vcs/token-create-urls.sh +0 -5
  78. package/scripts/vcs/verify-token.sh +0 -5
  79. package/scripts/verify-project.sh +0 -140
  80. package/scripts/youtrack/api.sh +0 -5
  81. package/scripts/youtrack/config.sh +0 -5
  82. package/scripts/youtrack/greeting.sh +0 -5
  83. package/scripts/youtrack/parse-duration.sh +0 -5
  84. package/scripts/youtrack/token-create-url.sh +0 -5
  85. package/scripts/youtrack/verify-token.sh +0 -5
  86. package/scripts/youtrack/work-date-ms.sh +0 -5
  87. package/src/tools/docs-repo.ts +0 -42
  88. package/src/tools/flow.ts +0 -88
  89. package/src/tools/index.ts +0 -22
  90. package/src/tools/present.ts +0 -45
  91. package/src/tools/repo.ts +0 -357
  92. package/src/tools/rules.ts +0 -30
  93. package/src/tools/sdd.ts +0 -189
  94. package/src/tools/templates.ts +0 -27
  95. package/src/tools/youtrack.ts +0 -360
package/src/core/git.ts CHANGED
@@ -2,7 +2,13 @@ import { execFileSync } from "node:child_process";
2
2
 
3
3
  const run = (cwd: string, args: string[]): { stdout: string; stderr: string; exitCode: number } => {
4
4
  try {
5
- const stdout = execFileSync("git", args, { cwd, encoding: "utf8" });
5
+ const stdout = execFileSync("git", args, {
6
+ cwd,
7
+ encoding: "utf8",
8
+ // AR-14: expected negative fixtures (non-repo dirs, unborn HEADs, bogus
9
+ // shas) must not inherit raw git usage/fatal stderr into the suite output.
10
+ stdio: ["pipe", "pipe", "pipe"],
11
+ });
6
12
  return { stdout: stdout.trimEnd(), stderr: "", exitCode: 0 };
7
13
  } catch (error) {
8
14
  const e = error as { stdout?: string; stderr?: string; status?: number };
@@ -16,8 +22,18 @@ const run = (cwd: string, args: string[]): { stdout: string; stderr: string; exi
16
22
 
17
23
  export const gitContext = (workspaceRoot: string, paths: string[] = []) => {
18
24
  const cwd = workspaceRoot;
19
- const branch = run(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout || "unknown";
20
- const status_short = run(cwd, ["status", "--porcelain"]).stdout;
25
+ let failure: { stderr: string; exitCode: number } | undefined;
26
+
27
+ const runHere = (args: string[]) => {
28
+ const result = run(cwd, args);
29
+ if (!failure && result.exitCode !== 0) {
30
+ failure = { stderr: result.stderr, exitCode: result.exitCode };
31
+ }
32
+ return result.stdout;
33
+ };
34
+
35
+ const branch = runHere(["rev-parse", "--abbrev-ref", "HEAD"]) || "unknown";
36
+ const status_short = runHere(["status", "--porcelain"]);
21
37
 
22
38
  const staged: string[] = [];
23
39
  const unstaged: string[] = [];
@@ -35,8 +51,8 @@ export const gitContext = (workspaceRoot: string, paths: string[] = []) => {
35
51
  }
36
52
 
37
53
  const pathArgs = paths.length ? ["--", ...paths] : [];
38
- const diff_stat = run(cwd, ["diff", "--stat", ...pathArgs]).stdout;
39
- const cached_stat = run(cwd, ["diff", "--cached", "--stat", ...pathArgs]).stdout;
54
+ const diff_stat = runHere(["diff", "--stat", ...pathArgs]);
55
+ const cached_stat = runHere(["diff", "--cached", "--stat", ...pathArgs]);
40
56
 
41
57
  const stagedSet = new Set(staged);
42
58
  const unstagedSet = new Set(unstaged);
@@ -52,5 +68,6 @@ export const gitContext = (workspaceRoot: string, paths: string[] = []) => {
52
68
  diff_stat: [diff_stat, cached_stat].filter(Boolean).join("\n"),
53
69
  partial_staged: partial_staged.length > 0,
54
70
  partial_staged_files: partial_staged,
71
+ ...(failure ? { stderr: failure.stderr, exitCode: failure.exitCode } : {}),
55
72
  };
56
73
  };
@@ -25,7 +25,12 @@ export const ensureProjectGitignore = (
25
25
  if (!confirmed) return { ok: false, error: "confirmed: true required" };
26
26
  const file = path.join(workspaceRoot, ".gitignore");
27
27
  const existing = existsSync(file) ? readFileSync(file, "utf8") : "";
28
- const existingLines = new Set(existing.split("\n").map((l) => l.trim()).filter(Boolean));
28
+ const existingLines = new Set(
29
+ existing
30
+ .split("\n")
31
+ .map((l) => l.trim())
32
+ .filter(Boolean),
33
+ );
29
34
  const added: string[] = [];
30
35
  const append: string[] = [];
31
36
  for (const entry of GITIGNORE_ENTRIES) {
@@ -35,7 +40,11 @@ export const ensureProjectGitignore = (
35
40
  }
36
41
  if (append.length) {
37
42
  const separator = existing && !existing.endsWith("\n") ? "\n" : "";
38
- writeFileSync(file, existing + separator + (existing ? "\n" : "") + append.join("\n") + "\n", "utf8");
43
+ writeFileSync(
44
+ file,
45
+ existing + separator + (existing ? "\n" : "") + append.join("\n") + "\n",
46
+ "utf8",
47
+ );
39
48
  } else if (!existsSync(file)) {
40
49
  writeFileSync(file, "", "utf8");
41
50
  }
@@ -25,7 +25,8 @@ const resolveFromMessagePaths = (root: string, message: string): Resolved => {
25
25
  const paths = extractMessagePaths(message);
26
26
  if (paths.length === 0) return { error: "no paths" };
27
27
  const slugs = [...new Set(paths.map((p) => p.split("/")[1]))];
28
- if (slugs.length !== 1) return { error: "multiple features in message — use exactly one docs/<slug>/ pair" };
28
+ if (slugs.length !== 1)
29
+ return { error: "multiple features in message — use exactly one docs/<slug>/ pair" };
29
30
  const slug = slugs[0];
30
31
  const plan = `docs/${slug}/plan.md`;
31
32
  const spec = `docs/${slug}/spec.md`;
@@ -49,8 +50,15 @@ const resolveActivePair = (root: string): Resolved => {
49
50
  const plan = path.join("docs", slug, "plan.md");
50
51
  const spec = path.join("docs", slug, "spec.md");
51
52
  if (!existsSync(path.join(root, plan)) || !existsSync(path.join(root, spec))) continue;
52
- const score = Math.max(statSync(path.join(root, spec)).mtimeMs, statSync(path.join(root, plan)).mtimeMs);
53
- if (best === null || score > best.score || (score === best.score && slug < best.spec.split("/")[1])) {
53
+ const score = Math.max(
54
+ statSync(path.join(root, spec)).mtimeMs,
55
+ statSync(path.join(root, plan)).mtimeMs,
56
+ );
57
+ if (
58
+ best === null ||
59
+ score > best.score ||
60
+ (score === best.score && slug < best.spec.split("/")[1])
61
+ ) {
54
62
  best = { score, spec, plan, source: "active_pair" };
55
63
  }
56
64
  }
@@ -69,7 +77,10 @@ export const resolveWorkflowPaths = (root: string, message: string): Resolved =>
69
77
  if (!existsSync(docsDir) || listMd(docsDir).length === 0) {
70
78
  return { error: "no docs/<slug>/ features found under docs/" };
71
79
  }
72
- return { error: "could not resolve spec and plan — mention docs/<slug>/plan.md or create docs/<slug>/{spec.md,plan.md}" };
80
+ return {
81
+ error:
82
+ "could not resolve spec and plan — mention docs/<slug>/plan.md or create docs/<slug>/{spec.md,plan.md}",
83
+ };
73
84
  };
74
85
 
75
86
  export const buildHandoffContract = ({
@@ -85,7 +96,9 @@ export const buildHandoffContract = ({
85
96
  }): { prompt: string } | { error: string } => {
86
97
  const validated = docsValidate({ spec_path: spec, plan_path: plan, workspace_root: root });
87
98
  if (validated.ok === false) {
88
- return { error: `docs validation failed\n${JSON.stringify({ ok: false, errors: validated.errors })}` };
99
+ return {
100
+ error: `docs validation failed\n${JSON.stringify({ ok: false, errors: validated.errors })}`,
101
+ };
89
102
  }
90
103
  const branchResolved = resolveBranch({ spec_path: spec, plan_path: plan, workspace_root: root });
91
104
  if ("error" in branchResolved) return { error: branchResolved.error as string };
@@ -1,10 +1,7 @@
1
1
  import path from "node:path";
2
- import { fileURLToPath } from "node:url";
3
- import { tool, type PluginInput } from "@opencode-ai/plugin";
4
2
  import { fail, ok, type Result } from "../core";
5
- import { assertFlowGates } from "../core/flow-state";
6
- import { resolveWorkflowPaths, buildHandoffContract } from "../core/handoff-context";
7
- import { WorkflowStateStore } from "../state";
3
+ import { resolveWorkflowPaths, buildHandoffContract } from "./handoff-context";
4
+ import { assetRoot } from "./package-root";
8
5
 
9
6
  type ApiResponse<T> = { data?: T; error?: unknown };
10
7
  type ApiResult<T> = Promise<ApiResponse<T>>;
@@ -31,19 +28,6 @@ export type HandoffClient = {
31
28
  };
32
29
  };
33
30
 
34
- export const adaptPluginHandoffClient = (client: PluginInput["client"]): HandoffClient => ({
35
- session: {
36
- create: (input) => client.session.create(input),
37
- promptAsync: (input) => client.session.promptAsync(input),
38
- },
39
- tui: {
40
- selectSession: ({ body, query }) => client.tui.publish({
41
- body: { type: "tui.session.select", properties: { sessionID: body.sessionID } } as never,
42
- query,
43
- }),
44
- },
45
- });
46
-
47
31
  export type HandoffRequest = {
48
32
  directory: string;
49
33
  title: string;
@@ -58,12 +42,13 @@ type HandoffData = {
58
42
  stage?: "create" | "seed" | "select";
59
43
  };
60
44
 
61
- const message = (error: unknown) => error instanceof Error
62
- ? error.message
63
- : typeof error === "object" && error !== null && "message" in error
64
- ? String(error.message)
65
- : String(error);
66
- const apiError = (response: ApiResponse<unknown> | void) => response?.error;
45
+ export const message = (error: unknown) =>
46
+ error instanceof Error
47
+ ? error.message
48
+ : typeof error === "object" && error !== null && "message" in error
49
+ ? String(error.message)
50
+ : String(error);
51
+ export const apiError = (response: ApiResponse<unknown> | void) => response?.error;
67
52
 
68
53
  export async function handoffSession(
69
54
  client: HandoffClient,
@@ -108,8 +93,6 @@ export async function handoffSession(
108
93
  }
109
94
  }
110
95
 
111
- const output = (value: unknown) => JSON.stringify(value, null, 2);
112
-
113
96
  export type HandoffContextResult =
114
97
  | { prompt: string; spec: string; plan: string; sdd: string }
115
98
  | { error: string };
@@ -117,44 +100,14 @@ export type HandoffContextResult =
117
100
  export const buildHandoffPrompt = (root: string, message: string): HandoffContextResult => {
118
101
  const resolved = resolveWorkflowPaths(root, message);
119
102
  if ("error" in resolved) return { error: resolved.error };
120
- const templatePath = path.resolve(
121
- path.dirname(fileURLToPath(import.meta.url)),
122
- "../../templates/execution-contract.md",
123
- );
124
- const contract = buildHandoffContract({ root, spec: resolved.spec, plan: resolved.plan, templatePath });
103
+ const templatePath = path.join(assetRoot(), "templates", "execution-contract.md");
104
+ const contract = buildHandoffContract({
105
+ root,
106
+ spec: resolved.spec,
107
+ plan: resolved.plan,
108
+ templatePath,
109
+ });
125
110
  if ("error" in contract) return { error: contract.error };
126
111
  const sdd = `docs/${path.basename(path.dirname(resolved.plan))}/sdd`;
127
112
  return { prompt: contract.prompt, spec: resolved.spec, plan: resolved.plan, sdd };
128
113
  };
129
-
130
-
131
-
132
- export function createHandoffTools(
133
- client: HandoffClient,
134
- state: WorkflowStateStore,
135
- ) {
136
- return {
137
- workflow_handoff_session: tool({
138
- description: "Create, seed, and select a continuation session; --stay in the message skips selection",
139
- args: { message: tool.schema.string() },
140
- execute: async ({ message: userMessage }, context) => {
141
- const built = buildHandoffPrompt(context.directory, userMessage);
142
- if ("error" in built) return output(fail(built.error));
143
- const active = built;
144
- try {
145
- const gate = assertFlowGates(context.directory, active.plan);
146
- if (!gate.ok) return output(fail(gate.error));
147
- state.set(context.sessionID, { spec: active.spec, plan: active.plan, sdd: active.sdd });
148
- return output(await handoffSession(client, {
149
- directory: context.directory,
150
- title: `Continue ${path.basename(path.dirname(active.plan))}`,
151
- prompt: active.prompt,
152
- stay: /(?:^|\s)--stay(?:\s|$)/.test(userMessage),
153
- }));
154
- } catch (error) {
155
- return output(fail(message(error)));
156
- }
157
- },
158
- }),
159
- };
160
- }
@@ -1,17 +1,27 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
3
  import { changelogUnreleasedStats } from "./changelog";
4
+ import { assetRoot } from "./package-root";
5
5
 
6
- export type HygieneFile = "CHANGELOG.md" | "README.md" | ".editorconfig" | ".gitattributes" | "LICENSE" | "CONTRIBUTING.md";
6
+ export type HygieneFile =
7
+ | "CHANGELOG.md"
8
+ | "README.md"
9
+ | ".editorconfig"
10
+ | ".gitattributes"
11
+ | "LICENSE"
12
+ | "CONTRIBUTING.md";
7
13
  type State = "missing" | "invalid" | "ok" | "skip";
8
14
 
9
- const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
15
+ const repoRoot = assetRoot();
10
16
  const templatesDir = () => path.join(repoRoot, "templates", "hygiene");
11
17
 
12
18
  const packageJson = (root: string): Record<string, unknown> | null => {
13
19
  if (!existsSync(path.join(root, "package.json"))) return null;
14
- try { return JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")); } catch { return null; }
20
+ try {
21
+ return JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
22
+ } catch {
23
+ return null;
24
+ }
15
25
  };
16
26
 
17
27
  const isOpenSource = (root: string): boolean => {
@@ -34,15 +44,27 @@ const licenseHolder = (root: string): string => {
34
44
  return "";
35
45
  };
36
46
 
37
- export const hygieneFiles = (root: string): { state: Record<HygieneFile, State>; openSource: boolean } => {
47
+ export const hygieneFiles = (
48
+ root: string,
49
+ ): { state: Record<HygieneFile, State>; openSource: boolean } => {
38
50
  const openSource = isOpenSource(root);
39
51
  const state = {} as Record<HygieneFile, State>;
40
- for (const file of ["CHANGELOG.md", "README.md", ".editorconfig", ".gitattributes", "LICENSE", "CONTRIBUTING.md"] as HygieneFile[]) {
52
+ for (const file of [
53
+ "CHANGELOG.md",
54
+ "README.md",
55
+ ".editorconfig",
56
+ ".gitattributes",
57
+ "LICENSE",
58
+ "CONTRIBUTING.md",
59
+ ] as HygieneFile[]) {
41
60
  if (file === "LICENSE" || file === "CONTRIBUTING.md") {
42
61
  state[file] = openSource ? (existsSync(path.join(root, file)) ? "ok" : "missing") : "skip";
43
62
  continue;
44
63
  }
45
- if (!existsSync(path.join(root, file))) { state[file] = "missing"; continue; }
64
+ if (!existsSync(path.join(root, file))) {
65
+ state[file] = "missing";
66
+ continue;
67
+ }
46
68
  if (file === "CHANGELOG.md") {
47
69
  const stats = changelogUnreleasedStats(root);
48
70
  state[file] = stats.exists && stats.has_unreleased ? "ok" : "invalid";
@@ -53,25 +75,39 @@ export const hygieneFiles = (root: string): { state: Record<HygieneFile, State>;
53
75
  return { state, openSource };
54
76
  };
55
77
 
56
- export const ensureHygieneFiles = (
78
+ // Pure planner: which hygiene files would be created and with what content,
79
+ // without touching the filesystem. Drives the wizard preview (WZ-08) and the
80
+ // apply path; ensureHygieneFiles is just "plan then write".
81
+ export const planHygieneFiles = (
57
82
  root: string,
58
- opts: { confirmed: boolean; includeOpenSource?: boolean },
59
- ): { ok: true; created: string[] } | { ok: false; error: string } => {
60
- if (!opts.confirmed) return { ok: false, error: "confirmed: true required" };
83
+ opts: { includeOpenSource?: boolean } = {},
84
+ ): Array<{ path: string; content: string }> => {
85
+ const openSource = opts.includeOpenSource ?? isOpenSource(root);
61
86
  const files = ["CHANGELOG.md", "README.md", ".editorconfig", ".gitattributes"] as HygieneFile[];
62
- if (opts.includeOpenSource) files.push("LICENSE", "CONTRIBUTING.md");
63
- const created: string[] = [];
64
- const tplDir = templatesDir();
87
+ if (openSource) files.push("LICENSE", "CONTRIBUTING.md");
88
+ const planned: Array<{ path: string; content: string }> = [];
65
89
  for (const file of files) {
66
90
  if (existsSync(path.join(root, file))) continue;
67
- const tpl = path.join(tplDir, file);
91
+ const tpl = path.join(templatesDir(), file);
68
92
  if (!existsSync(tpl)) continue; // skip missing template, never fail
69
93
  const content = readFileSync(tpl, "utf8")
70
94
  .replace(/<PROJECT>/g, path.basename(root))
71
95
  .replace(/<YEAR>/g, String(new Date().getFullYear()))
72
96
  .replace(/<HOLDER>\s*/g, licenseHolder(root));
73
- writeFileSync(path.join(root, file), content, "utf8");
74
- created.push(file);
97
+ planned.push({ path: path.join(root, file), content });
98
+ }
99
+ return planned;
100
+ };
101
+
102
+ export const ensureHygieneFiles = (
103
+ root: string,
104
+ opts: { confirmed: boolean; includeOpenSource?: boolean },
105
+ ): { ok: true; created: string[] } | { ok: false; error: string } => {
106
+ if (!opts.confirmed) return { ok: false, error: "confirmed: true required" };
107
+ const created: string[] = [];
108
+ for (const planned of planHygieneFiles(root, opts)) {
109
+ writeFileSync(planned.path, planned.content, "utf8");
110
+ created.push(path.basename(planned.path));
75
111
  }
76
112
  return { ok: true, created };
77
113
  };