@brainervirus/workit-core 0.10.0 → 1.0.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 (150) hide show
  1. package/README.md +5 -3
  2. package/package.json +8 -3
  3. package/scripts/analyze-release-scope.ts +4 -1
  4. package/scripts/doctor-check.ts +3 -1
  5. package/scripts/install-codex-plugin.sh +28 -0
  6. package/scripts/install-cursor-plugin.sh +1 -0
  7. package/scripts/install-opencode-plugin.sh +1 -0
  8. package/scripts/install-pi-package.sh +56 -0
  9. package/scripts/rewrite-workspace-deps.ts +22 -5
  10. package/scripts/sync-release-manifests.ts +8 -5
  11. package/scripts/sync-runtime.sh +7 -3
  12. package/scripts/validate-cursor-marketplace.ts +16 -62
  13. package/skills/workit-babysit/SKILL.md +33 -0
  14. package/skills/workit-behavioral-tdd/SKILL.md +53 -0
  15. package/skills/workit-blast-radius/SKILL.md +31 -0
  16. package/skills/workit-challenge/SKILL.md +62 -0
  17. package/skills/workit-debug/SKILL.md +61 -0
  18. package/skills/workit-deslop/SKILL.md +36 -0
  19. package/skills/workit-diagram/SKILL.md +32 -0
  20. package/skills/workit-green-run/SKILL.md +29 -0
  21. package/skills/workit-handoff/SKILL.md +43 -0
  22. package/skills/workit-implement/SKILL.md +46 -0
  23. package/skills/workit-mockup/SKILL.md +28 -0
  24. package/skills/workit-plan/SKILL.md +66 -0
  25. package/skills/workit-review/SKILL.md +60 -0
  26. package/skills/workit-steer/SKILL.md +32 -0
  27. package/src/core/authority.ts +918 -0
  28. package/src/core/boundary.ts +29 -0
  29. package/src/core/branch.ts +26 -255
  30. package/src/core/changelog.ts +95 -18
  31. package/src/core/commit-flavors.ts +65 -0
  32. package/src/core/config-conversion.ts +183 -0
  33. package/src/core/config.ts +42 -0
  34. package/src/core/cutover.ts +721 -0
  35. package/src/core/detect-hosts.ts +107 -0
  36. package/src/core/doctor.ts +428 -18
  37. package/src/core/external-action-effects.ts +1171 -0
  38. package/src/core/external-action.ts +541 -0
  39. package/src/core/init.ts +83 -32
  40. package/src/core/methods.ts +125 -0
  41. package/src/core/policy-resolver.ts +523 -0
  42. package/src/core/pr-create.ts +7 -1
  43. package/src/core/registration.ts +69 -4
  44. package/src/core/repo-context.ts +59 -1
  45. package/src/core/setup.ts +28 -0
  46. package/src/core/skill-manifests.ts +40 -42
  47. package/src/core/support-matrix.ts +8 -3
  48. package/src/core/sync-runtime.ts +15 -3
  49. package/src/core/task-context.ts +179 -0
  50. package/src/core/task-contract.ts +1136 -0
  51. package/src/core/task-engine.ts +2562 -0
  52. package/src/core/task-evaluation.ts +679 -0
  53. package/src/core/task-store.ts +1071 -0
  54. package/src/core/tracker-issues.ts +237 -0
  55. package/src/core/uninstall.ts +67 -3
  56. package/src/core/vcs-config.ts +53 -22
  57. package/src/core/workers.ts +166 -0
  58. package/src/core/workspaces.ts +3 -1
  59. package/src/core/youtrack-tools.ts +2 -0
  60. package/src/core/youtrack.ts +145 -10
  61. package/src/core.ts +126 -0
  62. package/templates/execution-contract.md +17 -50
  63. package/templates/plan-template.md +3 -3
  64. package/templates/spec-template.md +15 -3
  65. package/templates/workit-contract.md +12 -0
  66. package/commands/wk-changelog.md +0 -2
  67. package/commands/wk-commit.md +0 -2
  68. package/commands/wk-docs-refresh.md +0 -2
  69. package/commands/wk-handoff.md +0 -2
  70. package/commands/wk-implement.md +0 -2
  71. package/commands/wk-init.md +0 -2
  72. package/commands/wk-issue-update.md +0 -2
  73. package/commands/wk-meetings.md +0 -2
  74. package/commands/wk-pr.md +0 -2
  75. package/commands/wk-release-notes.md +0 -2
  76. package/commands/wk-status.md +0 -2
  77. package/commands/wk-verify.md +0 -2
  78. package/scripts/update-superpowers.sh +0 -82
  79. package/scripts/vendor-assets.ts +0 -37
  80. package/skills/wk-changelog/SKILL.md +0 -15
  81. package/skills/wk-commit/SKILL.md +0 -16
  82. package/skills/wk-docs-refresh/SKILL.md +0 -15
  83. package/skills/wk-handoff/SKILL.md +0 -20
  84. package/skills/wk-implement/SKILL.md +0 -47
  85. package/skills/wk-init/SKILL.md +0 -31
  86. package/skills/wk-issue-update/SKILL.md +0 -27
  87. package/skills/wk-issue-update/references/youtrack-update-style.md +0 -81
  88. package/skills/wk-meetings/SKILL.md +0 -17
  89. package/skills/wk-pr/SKILL.md +0 -27
  90. package/skills/wk-release-notes/SKILL.md +0 -15
  91. package/skills/wk-status/SKILL.md +0 -16
  92. package/skills/wk-verify/SKILL.md +0 -16
  93. package/src/core/detector.ts +0 -239
  94. package/src/core/flow-state.ts +0 -3048
  95. package/src/core/handoff-context.ts +0 -136
  96. package/src/core/handoff-tools.ts +0 -133
  97. package/src/core/menu.ts +0 -70
  98. package/src/core/plan-tasks.ts +0 -43
  99. package/src/core/reminder.ts +0 -124
  100. package/src/core/sdd.ts +0 -353
  101. package/src/state.ts +0 -22
  102. package/templates/superpowers-doc-contract.md +0 -75
  103. package/vendor/superpowers/skills/brainstorming/SKILL.md +0 -159
  104. package/vendor/superpowers/skills/brainstorming/scripts/frame-template.html +0 -213
  105. package/vendor/superpowers/skills/brainstorming/scripts/helper.js +0 -167
  106. package/vendor/superpowers/skills/brainstorming/scripts/server.cjs +0 -723
  107. package/vendor/superpowers/skills/brainstorming/scripts/start-server.sh +0 -209
  108. package/vendor/superpowers/skills/brainstorming/scripts/stop-server.sh +0 -120
  109. package/vendor/superpowers/skills/brainstorming/spec-document-reviewer-prompt.md +0 -49
  110. package/vendor/superpowers/skills/brainstorming/visual-companion.md +0 -291
  111. package/vendor/superpowers/skills/dispatching-parallel-agents/SKILL.md +0 -185
  112. package/vendor/superpowers/skills/executing-plans/SKILL.md +0 -70
  113. package/vendor/superpowers/skills/finishing-a-development-branch/SKILL.md +0 -241
  114. package/vendor/superpowers/skills/receiving-code-review/SKILL.md +0 -213
  115. package/vendor/superpowers/skills/requesting-code-review/SKILL.md +0 -103
  116. package/vendor/superpowers/skills/requesting-code-review/code-reviewer.md +0 -172
  117. package/vendor/superpowers/skills/subagent-driven-development/SKILL.md +0 -426
  118. package/vendor/superpowers/skills/subagent-driven-development/implementer-prompt.md +0 -139
  119. package/vendor/superpowers/skills/subagent-driven-development/scripts/review-package +0 -44
  120. package/vendor/superpowers/skills/subagent-driven-development/scripts/sdd-workspace +0 -22
  121. package/vendor/superpowers/skills/subagent-driven-development/scripts/task-brief +0 -40
  122. package/vendor/superpowers/skills/subagent-driven-development/task-reviewer-prompt.md +0 -188
  123. package/vendor/superpowers/skills/systematic-debugging/CREATION-LOG.md +0 -119
  124. package/vendor/superpowers/skills/systematic-debugging/SKILL.md +0 -296
  125. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting-example.ts +0 -158
  126. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting.md +0 -115
  127. package/vendor/superpowers/skills/systematic-debugging/defense-in-depth.md +0 -122
  128. package/vendor/superpowers/skills/systematic-debugging/find-polluter.sh +0 -63
  129. package/vendor/superpowers/skills/systematic-debugging/root-cause-tracing.md +0 -169
  130. package/vendor/superpowers/skills/systematic-debugging/test-academic.md +0 -14
  131. package/vendor/superpowers/skills/systematic-debugging/test-pressure-1.md +0 -58
  132. package/vendor/superpowers/skills/systematic-debugging/test-pressure-2.md +0 -68
  133. package/vendor/superpowers/skills/systematic-debugging/test-pressure-3.md +0 -69
  134. package/vendor/superpowers/skills/test-driven-development/SKILL.md +0 -371
  135. package/vendor/superpowers/skills/test-driven-development/testing-anti-patterns.md +0 -299
  136. package/vendor/superpowers/skills/using-git-worktrees/SKILL.md +0 -202
  137. package/vendor/superpowers/skills/using-superpowers/SKILL.md +0 -62
  138. package/vendor/superpowers/skills/using-superpowers/references/antigravity-tools.md +0 -23
  139. package/vendor/superpowers/skills/using-superpowers/references/codex-tools.md +0 -39
  140. package/vendor/superpowers/skills/using-superpowers/references/pi-tools.md +0 -16
  141. package/vendor/superpowers/skills/verification-before-completion/SKILL.md +0 -139
  142. package/vendor/superpowers/skills/writing-plans/SKILL.md +0 -174
  143. package/vendor/superpowers/skills/writing-plans/plan-document-reviewer-prompt.md +0 -49
  144. package/vendor/superpowers/skills/writing-skills/SKILL.md +0 -689
  145. package/vendor/superpowers/skills/writing-skills/anthropic-best-practices.md +0 -1150
  146. package/vendor/superpowers/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +0 -189
  147. package/vendor/superpowers/skills/writing-skills/graphviz-conventions.dot +0 -172
  148. package/vendor/superpowers/skills/writing-skills/persuasion-principles.md +0 -187
  149. package/vendor/superpowers/skills/writing-skills/render-graphs.js +0 -168
  150. package/vendor/superpowers/skills/writing-skills/testing-skills-with-subagents.md +0 -384
@@ -1,3 +1,4 @@
1
+ import { statSync } from "node:fs";
1
2
  import type { JsonValue } from "./logger";
2
3
 
3
4
  // Named diagnostic boundary events (DG-04) shared by all adapters so event
@@ -25,3 +26,31 @@ export const errorDetail = (err: unknown): Record<string, JsonValue> => {
25
26
  }
26
27
  return { error_name: "unknown", error: String(err) };
27
28
  };
29
+
30
+ export type SourceMarker = { loadedAt: number; files: string[] };
31
+
32
+ /**
33
+ * Long-lived adapters (OpenCode plugin, MCP servers) load core once per
34
+ * process. A marker plus changedSourcesSinceLoad lets them warn once when the
35
+ * checkout sources move underneath the live process, instead of running stale
36
+ * behavior silently. Warning-only: it never blocks a call.
37
+ */
38
+ export const markSourcesLoaded = (
39
+ files: string[],
40
+ loadedAt: number = Date.now(),
41
+ ): SourceMarker => ({
42
+ loadedAt,
43
+ files: [...files],
44
+ });
45
+
46
+ export const changedSourcesSinceLoad = (marker: SourceMarker): string[] => {
47
+ const changed: string[] = [];
48
+ for (const file of marker.files) {
49
+ try {
50
+ if (statSync(file).mtimeMs > marker.loadedAt) changed.push(file);
51
+ } catch {
52
+ // A missing source tells nothing; ignore it.
53
+ }
54
+ }
55
+ return changed;
56
+ };
@@ -1,21 +1,8 @@
1
- import {
2
- copyFileSync,
3
- cpSync,
4
- existsSync,
5
- mkdirSync,
6
- readFileSync,
7
- readdirSync,
8
- renameSync,
9
- rmSync,
10
- statSync,
11
- writeFileSync,
12
- } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
2
  import { execFileSync } from "node:child_process";
14
- import { createHash } from "node:crypto";
15
- import { tmpdir } from "node:os";
16
3
  import path from "node:path";
17
4
  import { gitContext } from "./git";
18
- import { readConfig, resolveBranchPolicy } from "./config";
5
+ import { readConfig, resolveBranchPolicy, resolveCommitPolicy } from "./config";
19
6
  import { resolveWorkspace } from "./workspaces";
20
7
  import { vcsConfig } from "./vcs-config";
21
8
 
@@ -23,6 +10,10 @@ import { vcsConfig } from "./vcs-config";
23
10
  export const resolveBranchPolicyFor = (workspaceRoot: string) =>
24
11
  resolveBranchPolicy(readConfig(), resolveWorkspace(workspaceRoot));
25
12
 
13
+ /** Commit-flavor equivalent: workspace commitPolicy override, else global. */
14
+ export const resolveCommitPolicyFor = (workspaceRoot: string) =>
15
+ resolveCommitPolicy(readConfig(), resolveWorkspace(workspaceRoot));
16
+
26
17
  const policy = (root: string) => resolveBranchPolicyFor(root);
27
18
  const allowedBranch = (root: string, name: string) =>
28
19
  policy(root).allowed.some((r) => r.test(name));
@@ -38,7 +29,6 @@ const USE_CURRENT_RE = /^\s*\*+Branch:\*+\s*use-current\s*$/im;
38
29
  // Windows portability: journal lines and stash-coverage sets carry
39
30
  // repo-relative paths, which must match git's POSIX separator output even
40
31
  // though path.join emits platform separators.
41
- const toPosix = (p: string) => p.split(path.sep).join("/");
42
32
  const readSafe = (p: string): string | null => {
43
33
  try {
44
34
  return readFileSync(p, "utf8");
@@ -278,114 +268,6 @@ export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; erro
278
268
  return fastForwardBase(cwd, base);
279
269
  };
280
270
 
281
- // CA-05: crash-orphaned guard roots are garbage-collected at snapshot time.
282
- // Fresh roots (any live invocation) and non-workit entries are never touched.
283
- export const purgeStaleFlowGuardRoots = (now: number): void => {
284
- const cutoff = now - 24 * 3600_000;
285
- let entries: string[];
286
- try {
287
- entries = readdirSync(tmpdir());
288
- } catch {
289
- return;
290
- }
291
- for (const entry of entries) {
292
- if (!entry.startsWith("workit-flow-guard-")) continue;
293
- try {
294
- if (statSync(path.join(tmpdir(), entry)).mtimeMs < cutoff)
295
- rmSync(path.join(tmpdir(), entry), { recursive: true, force: true });
296
- } catch {
297
- /* raced removal or unreadable entry: skip */
298
- }
299
- }
300
- };
301
-
302
- // CA-04: flow-state snapshots live under the OS tempdir scoped by a hash of
303
- // the workspace path — never inside the repository or docs/. The pid+hrtime
304
- // suffix makes every invocation unique, so two concurrent setups never share
305
- // (and never clobber) a root; CA-05 GC reclaims anything a crash orphaned.
306
- export const snapshotFlowState = (cwd: string): string => {
307
- purgeStaleFlowGuardRoots(Date.now());
308
- const root = path.join(
309
- tmpdir(),
310
- `workit-flow-guard-${createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16)}.${
311
- process.pid
312
- }.${process.hrtime.bigint()}`,
313
- );
314
- mkdirSync(root, { recursive: true });
315
- const docsDir = path.join(path.resolve(cwd), "docs");
316
- let slugs: string[] = [];
317
- try {
318
- slugs = readdirSync(docsDir);
319
- } catch {
320
- return root; // no docs/ yet — zero-file snapshot
321
- }
322
- for (const slug of slugs) {
323
- // The approval gate digests docs/<slug>/spec.md and docs/<slug>/plan.md,
324
- // so those bytes must survive the stash window too: a stash push -u takes
325
- // untracked spec/plan away and any concurrent effective flow read would
326
- // classify document_missing drift and persist an approval-chain wipe.
327
- for (const rel of [["sdd", "flow.json"], ["spec.md"], ["plan.md"]]) {
328
- const src = path.join(docsDir, slug, ...rel);
329
- try {
330
- if (!statSync(src).isFile()) continue;
331
- } catch {
332
- continue;
333
- }
334
- const dest = path.join(root, "docs", slug, ...rel);
335
- mkdirSync(path.dirname(dest), { recursive: true });
336
- cpSync(src, dest);
337
- }
338
- }
339
- return root;
340
- };
341
-
342
- // CA-04: restore-if-missing keeps the newest working-tree bytes; the snapshot
343
- // root is removed only after every file is handled and retained on failure.
344
- // A caught failure must not vanish: the message is returned so callers can
345
- // surface it to operators as a warning. `restored` lists ONLY the files this
346
- // call actually wrote — present-at-restore files are skipped and excluded,
347
- // which is what makes the redundant-stash coverage check honest.
348
- export const restoreFlowSnapshot = (
349
- snapDir: string,
350
- cwd: string,
351
- ): { restored: string[]; warning?: string } => {
352
- const walk = (dir: string, rel: string): string[] =>
353
- readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
354
- entry.isDirectory()
355
- ? walk(path.join(dir, entry.name), path.join(rel, entry.name))
356
- : [path.join(rel, entry.name)],
357
- );
358
- const restored: string[] = [];
359
- try {
360
- const workspace = path.resolve(cwd);
361
- for (const rel of walk(snapDir, "")) {
362
- const dest = path.join(workspace, rel);
363
- if (existsSync(dest)) continue;
364
- mkdirSync(path.dirname(dest), { recursive: true });
365
- // Atomic publish: a crash mid-copy must never leave a truncated flow.json
366
- // at the destination.
367
- const tmpDest = `${dest}.tmp-${process.pid}`;
368
- try {
369
- copyFileSync(path.join(snapDir, rel), tmpDest);
370
- renameSync(tmpDest, dest);
371
- } catch (error) {
372
- rmSync(tmpDest, { force: true });
373
- throw error;
374
- }
375
- restored.push(toPosix(rel));
376
- }
377
- rmSync(snapDir, { recursive: true, force: true });
378
- return { restored };
379
- } catch (error) {
380
- return {
381
- restored,
382
- warning: `flow state snapshot restore failed: ${
383
- error instanceof Error ? error.message : String(error)
384
- }`,
385
- };
386
- }
387
- };
388
-
389
271
  // Port of scripts/branch/setup-branch.sh
390
272
  export const branchSetup = ({
391
273
  action,
@@ -411,7 +293,11 @@ export const branchSetup = ({
411
293
  const manifestPath = path.isAbsolute(sdd)
412
294
  ? path.join(sdd, "manifest.json")
413
295
  : path.join(cwd, sdd, "manifest.json");
414
- mkdirSync(path.dirname(manifestPath), { recursive: true, mode: 0o755 });
296
+ const ensureManifestDir = () => {
297
+ const dir = path.dirname(manifestPath);
298
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o755 });
299
+ };
300
+ ensureManifestDir();
415
301
  const readManifest = (): Record<string, unknown> => {
416
302
  try {
417
303
  return JSON.parse(readFileSync(manifestPath, "utf8"));
@@ -419,95 +305,29 @@ export const branchSetup = ({
419
305
  return {};
420
306
  }
421
307
  };
422
- const writeManifest = (data: Record<string, unknown>) =>
308
+ const writeManifest = (data: Record<string, unknown>) => {
309
+ ensureManifestDir();
423
310
  writeFileSync(manifestPath, JSON.stringify(data, null, 2) + "\n", "utf8");
424
-
425
- let snapDir: string | null = null;
426
- // CA-01: flow-guard journal brackets the stash/checkout mutation window so a
427
- // mid-window wipe is pinpointable between adjacent checkpoint lines. With no
428
- // logger injected every call below is a no-op and nothing extra runs —
429
- // behaviorally identical to the pre-journal code.
430
- const journal = (message: string) => log?.(`flow-guard: ${message}`);
431
- const snapshotRelPaths = (): string[] => {
432
- const dir = snapDir;
433
- // Not gated on log: the redundant-stash coverage check below needs this
434
- // walk even without a logger (the walk is read-only over the tmpdir
435
- // snapshot).
436
- if (!dir) return [];
437
- try {
438
- const walk = (from: string, rel: string): string[] =>
439
- readdirSync(from, { withFileTypes: true }).flatMap((entry) =>
440
- entry.isDirectory()
441
- ? walk(path.join(from, entry.name), path.join(rel, entry.name))
442
- : [path.join(rel, entry.name)],
443
- );
444
- return walk(dir, "").map(toPosix);
445
- } catch {
446
- return [];
447
- }
448
- };
449
- const journalSnapshot = () => {
450
- const dir = snapDir;
451
- if (!dir || !log) return;
452
- const rels = snapshotRelPaths();
453
- journal(`snapshot: ${rels.length} file(s)`);
454
- for (const rel of rels) {
455
- let shortHash = "";
456
- try {
457
- shortHash = createHash("sha256")
458
- .update(readFileSync(path.join(dir, rel)))
459
- .digest("hex")
460
- .slice(0, 8);
461
- } catch {}
462
- journal(`snapshot: ${rel} sha=${shortHash}`);
463
- }
464
- };
465
- const journalPresence = (phase: string) => {
466
- for (const rel of snapshotRelPaths()) {
467
- journal(`${phase}: ${rel} ${existsSync(path.join(cwd, rel)) ? "present" : "MISSING"}`);
468
- }
469
- };
470
- const restoreWithWarning = (dir: string | null): { restored: string[]; warnings: string[] } => {
471
- if (!dir) return { restored: [], warnings: [] };
472
- const total = log ? snapshotRelPaths().length : 0;
473
- const { restored, warning } = restoreFlowSnapshot(dir, cwd);
474
- if (log)
475
- journal(
476
- `restore: restored=${restored.length} skipped=${total - restored.length}${warning ? ` warning: ${warning}` : ""}`,
477
- );
478
- else if (warning) journal(`restore warning: ${warning}`);
479
- return { restored, warnings: warning ? [warning] : [] };
480
311
  };
481
312
 
313
+ const journal = (message: string) => log?.(`branch-setup: ${message}`);
314
+
482
315
  if (action === "reapply_stash") {
483
316
  const manifest = readManifest();
484
317
  const ref = manifest.stash_ref;
485
318
  if (!ref) return { error: "no stash_ref in manifest" };
486
- // D-03: guard flow.json across the stash pop window.
487
- snapDir = snapshotFlowState(cwd);
488
- journalSnapshot();
489
319
  journal(`pre-pop: ${String(ref)}`);
490
320
  try {
491
321
  exec(["stash", "pop", String(ref)]);
492
322
  } catch (error) {
493
- // CA-03: the snapshot ran before the pop — a failing pop must still
494
- // restore a mid-window-wiped flow.json before returning.
495
323
  journal("pop: failed");
496
- const {
497
- warnings: [warning],
498
- } = restoreWithWarning(snapDir);
499
- return {
500
- error: `${error instanceof Error ? error.message : "stash pop failed"}${
501
- warning ? `; ${warning}` : ""
502
- }`,
503
- };
324
+ return { error: error instanceof Error ? error.message : "stash pop failed" };
504
325
  }
505
326
  journal("pop: ok");
506
327
  delete manifest.stash_ref;
507
328
  delete manifest.stash_created_at;
508
329
  writeManifest(manifest);
509
- const { warnings } = restoreWithWarning(snapDir);
510
- return { action: "reapply_stash", ok: true, ...(warnings.length > 0 ? { warnings } : {}) };
330
+ return { action: "reapply_stash", ok: true };
511
331
  }
512
332
 
513
333
  const target = target_branch ?? "";
@@ -549,14 +369,7 @@ export const branchSetup = ({
549
369
  suffix = " (changes preserved in stash)";
550
370
  }
551
371
  }
552
- // CA-03: the snapshot ran before the stash push, so every error return
553
- // here must still restore a mid-window-wiped flow.json and drop the
554
- // guard root (a retained root is purged by the next run's 24h GC).
555
- // Never masks the original error.
556
- const {
557
- warnings: [warning],
558
- } = restoreWithWarning(snapDir);
559
- return { error: `${message}${suffix}${warning ? `; ${warning}` : ""}` };
372
+ return { error: `${message}${suffix}` };
560
373
  };
561
374
  if (current !== target) {
562
375
  const dirty = Boolean(gitContext(cwd).status_short.trim());
@@ -578,11 +391,7 @@ export const branchSetup = ({
578
391
  }
579
392
  if (dirty) {
580
393
  try {
581
- // CA-03: snapshot before the stash push so flow.json survives the
582
- // stash/checkout window even if the pathspec exclusion misses.
583
- snapDir = snapshotFlowState(cwd);
584
- journalSnapshot();
585
- exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`, "--", ":!docs/*/sdd"]);
394
+ exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`]);
586
395
  } catch (error) {
587
396
  return { error: error instanceof Error ? error.message : "stash push failed" };
588
397
  }
@@ -591,7 +400,7 @@ export const branchSetup = ({
591
400
  }
592
401
  try {
593
402
  exec(["checkout", target]);
594
- journalPresence("post-checkout");
403
+ journal("post-checkout");
595
404
  } catch (error) {
596
405
  const message = error instanceof Error ? error.message : "checkout failed";
597
406
  if (/worktree/i.test(message)) {
@@ -614,7 +423,7 @@ export const branchSetup = ({
614
423
  : ensureBaseBranch(cwd, effectiveBase);
615
424
  if (!baseResult.ok) return failAfterStash(baseResult.error ?? "ensure-base-branch failed");
616
425
  exec(["checkout", "-b", target]);
617
- journalPresence("post-create");
426
+ journal("post-create");
618
427
  } catch (createError) {
619
428
  return failAfterStash(
620
429
  createError instanceof Error ? createError.message : "branch create failed",
@@ -644,50 +453,13 @@ export const branchSetup = ({
644
453
  if (stash_ref === undefined && gitContext(cwd).branch !== current) {
645
454
  try {
646
455
  exec(["checkout", current]);
647
- } catch {}
648
- }
649
- return result;
650
- }
651
- // Files the guard actually wrote back, straight from the restore itself.
652
- const { restored: restoredRels, warnings } = restoreWithWarning(snapDir);
653
- // Round-1 fallout: the guard restores untracked spec/plan right after
654
- // checkout, but the stash pushed the same files — a later reapply_stash pop
655
- // refuses ("untracked working tree files would be overwritten"), stranding
656
- // a stash_ref on every successful setup. When every stashed path was just
657
- // restored byte-identical from the pre-stash snapshot, the stash is
658
- // redundant: drop it and clear the manifest ref. Coverage counts ONLY
659
- // actually-restored paths: a tracked-and-modified file survives its stash
660
- // (reverted to HEAD bytes, still present), so restore skips it — counting
661
- // it as covered would drop the only copy of the user's edit. Any stashed
662
- // path not restored keeps the ref so reapply_stash stays available.
663
- // Best-effort: any failure keeps today's keep-the-ref behavior.
664
- if (stash_ref && snapDir && warnings.length === 0) {
665
- const covered = new Set(restoredRels.map(toPosix));
666
- try {
667
- const stashed = exec([
668
- "stash",
669
- "show",
670
- "--include-untracked",
671
- "--name-only",
672
- String(stash_ref),
673
- ])
674
- .split("\n")
675
- .map((line) => toPosix(line.trim()))
676
- .filter(Boolean);
677
- if (stashed.length > 0 && stashed.every((p) => covered.has(p))) {
678
- exec(["stash", "drop", String(stash_ref)]);
679
- stash_ref = undefined;
680
- journal("dropped redundant stash (fully covered by snapshot restore)");
681
- const manifest = readManifest();
682
- delete manifest.stash_ref;
683
- delete manifest.stash_created_at;
684
- writeManifest(manifest);
685
- } else {
686
- journal(`kept stash ref ${stash_ref} (not fully covered by actual restores)`);
456
+ } catch {
457
+ try {
458
+ exec(["checkout", "-m", current]);
459
+ } catch {}
687
460
  }
688
- } catch {
689
- /* keep the stash ref — reapply_stash stays available */
690
461
  }
462
+ return result;
691
463
  }
692
464
  return {
693
465
  action: "setup",
@@ -696,6 +468,5 @@ export const branchSetup = ({
696
468
  previous_branch: current,
697
469
  stash_ref: stash_ref ?? null,
698
470
  manifest: manifestPath,
699
- ...(warnings.length > 0 ? { warnings } : {}),
700
471
  };
701
472
  };
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { createHash } from "node:crypto";
3
4
  import { resolveWorkspaceRoot } from "./scripts";
4
5
 
5
6
  const CATEGORIES = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"];
@@ -23,6 +24,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
23
24
 
24
25
  `;
25
26
 
27
+ const digestBytes = (value: Buffer | string): string =>
28
+ createHash("sha256").update(value).digest("hex");
29
+
30
+ type ChangelogSource = { exists: boolean; bytes: Buffer; text: string } | { error: string };
31
+
32
+ const readChangelogSource = (target: string): ChangelogSource => {
33
+ if (!fs.existsSync(target)) {
34
+ const bytes = Buffer.from(SKELETON, "utf8");
35
+ return { exists: false, bytes, text: SKELETON };
36
+ }
37
+ try {
38
+ const bytes = fs.readFileSync(target);
39
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
40
+ return { exists: true, bytes, text };
41
+ } catch {
42
+ return { error: "changelog file must contain valid UTF-8" };
43
+ }
44
+ };
45
+
26
46
  function normalizeBullet(text: string): string {
27
47
  text = text.trim();
28
48
  const m = BULLET_RE.exec(text);
@@ -184,13 +204,6 @@ function mergeSections(
184
204
  return [output, counts, added, null];
185
205
  }
186
206
 
187
- function ensureFile(target: string): string {
188
- if (fs.existsSync(target)) return fs.readFileSync(target, "utf8");
189
- fs.mkdirSync(path.dirname(target), { recursive: true });
190
- fs.writeFileSync(target, SKELETON, "utf8");
191
- return SKELETON;
192
- }
193
-
194
207
  function hasUnreleasedHeading(text: string): boolean {
195
208
  return text.split("\n").some((line) => UNRELEASED_RE.test(line.replace(/\n$/, "")));
196
209
  }
@@ -200,7 +213,21 @@ function applyChangelog(
200
213
  entries: Record<string, string[]>,
201
214
  normalizeOnly: boolean,
202
215
  ): Record<string, any> {
203
- let text = ensureFile(target);
216
+ const source = readChangelogSource(target);
217
+ if ("error" in source) return { error: source.error };
218
+ const rendered = renderChangelog(source.text, entries, normalizeOnly);
219
+ if ("error" in rendered) return { error: rendered.error };
220
+ fs.mkdirSync(path.dirname(target), { recursive: true });
221
+ fs.writeFileSync(target, rendered.text, "utf8");
222
+ return { ...rendered.result, path: target };
223
+ }
224
+
225
+ function renderChangelog(
226
+ source: string,
227
+ entries: Record<string, string[]>,
228
+ normalizeOnly: boolean,
229
+ ): { text: string; result: Record<string, any> } | { error: string } {
230
+ let text = source;
204
231
  if (!hasUnreleasedHeading(text)) {
205
232
  const lines = text.split(/(?<=\n)/);
206
233
  let insertAt = lines.length;
@@ -220,13 +247,65 @@ function applyChangelog(
220
247
  if (error) return { error };
221
248
  // before already includes "## [Unreleased]\n"
222
249
  const out = (before.endsWith("\n") ? before : before + "\n") + newBody + after;
223
- fs.writeFileSync(target, out, "utf8");
250
+ return {
251
+ text: out,
252
+ result: { ok: true, normalize_only: normalizeOnly, added: addedCounts, categories: counts },
253
+ };
254
+ }
255
+
256
+ const changelogTarget = (
257
+ workspaceRoot: string,
258
+ changelogPath: string,
259
+ ): { cwd: string; target: string } | { error: string } => {
260
+ try {
261
+ const cwd = fs.realpathSync(resolveWorkspaceRoot(workspaceRoot));
262
+ const target = path.resolve(cwd, changelogPath || "CHANGELOG.md");
263
+ if (target !== cwd && !target.startsWith(cwd + path.sep))
264
+ return { error: "changelog path must be inside workspace_root" };
265
+ let ancestor = target;
266
+ while (!fs.existsSync(ancestor)) ancestor = path.dirname(ancestor);
267
+ const canonicalAncestor = fs.realpathSync(ancestor);
268
+ if (canonicalAncestor !== cwd && !canonicalAncestor.startsWith(cwd + path.sep))
269
+ return { error: "changelog path must be inside workspace_root" };
270
+ return { cwd, target: fs.existsSync(target) ? fs.realpathSync(target) : target };
271
+ } catch {
272
+ return { error: "changelog path must be inside workspace_root" };
273
+ }
274
+ };
275
+
276
+ export function changelogApplyPreview({
277
+ entries,
278
+ path: changelogPath,
279
+ normalize_only,
280
+ workspace_root,
281
+ }: {
282
+ entries?: unknown;
283
+ path?: string;
284
+ normalize_only?: boolean;
285
+ workspace_root: string;
286
+ }): Record<string, any> {
287
+ const normalized = normalize_only
288
+ ? { data: {} as Record<string, string[]> }
289
+ : normalizeEntries(entries);
290
+ if ("error" in normalized) return { error: normalized.error };
291
+ if (!normalize_only && Object.keys(normalized.data).length === 0)
292
+ return { error: "entries required unless normalize_only" };
293
+ const location = changelogTarget(workspace_root, changelogPath || "CHANGELOG.md");
294
+ if ("error" in location) return location;
295
+ const source = readChangelogSource(location.target);
296
+ if ("error" in source) return source;
297
+ const rendered = renderChangelog(source.text, normalized.data, Boolean(normalize_only));
298
+ if ("error" in rendered) return rendered;
224
299
  return {
225
300
  ok: true,
226
- path: target,
227
- normalize_only: normalizeOnly,
228
- added: addedCounts,
229
- categories: counts,
301
+ path: location.target,
302
+ entries: normalized.data,
303
+ normalize_only: Boolean(normalize_only),
304
+ beforeDigest: digestBytes(source.bytes),
305
+ beforeExists: source.exists,
306
+ afterDigest: digestBytes(Buffer.from(rendered.text, "utf8")),
307
+ after: rendered.text,
308
+ ...rendered.result,
230
309
  };
231
310
  }
232
311
 
@@ -280,11 +359,9 @@ export function changelogApply({
280
359
  }
281
360
 
282
361
  const rel = changelogPath || "CHANGELOG.md";
283
- const target = path.resolve(cwd, rel);
284
- const root = path.resolve(cwd);
285
- if (target !== root && !target.startsWith(root + path.sep)) {
286
- return { error: "changelog path must be inside workspace_root" };
287
- }
362
+ const location = changelogTarget(cwd, rel);
363
+ if ("error" in location) return location;
364
+ const target = location.target;
288
365
 
289
366
  try {
290
367
  return applyChangelog(target, normalized.data, Boolean(normalize_only));
@@ -0,0 +1,65 @@
1
+ // Commit message flavors: matchers plus history detection for the git.commit
2
+ // gate. Detection is deterministic (majority vote, no LLM): sample recent
3
+ // subjects, classify each, take the majority above threshold, else null so the
4
+ // caller falls back to the configured preset.
5
+
6
+ export type CommitFlavor = "conventional" | "gitmoji" | "ticket-prefix" | "freeform";
7
+ export type CommitFlavorPreset = CommitFlavor | "custom" | "auto";
8
+
9
+ const CONVENTIONAL_RE =
10
+ /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?(!)?: .+/;
11
+ const TICKET_RE = /^[A-Z]{2,}-\d+\b/;
12
+ const GITMOJI_RE = /^\p{Extended_Pictographic}/u;
13
+
14
+ export const matchCommitFlavor = (
15
+ message: string,
16
+ flavor: CommitFlavorPreset,
17
+ pattern?: string,
18
+ ): boolean => {
19
+ const subject = message.split("\n", 1)[0] ?? "";
20
+ switch (flavor) {
21
+ case "conventional":
22
+ return CONVENTIONAL_RE.test(subject);
23
+ case "gitmoji":
24
+ return GITMOJI_RE.test(subject);
25
+ case "ticket-prefix":
26
+ return TICKET_RE.test(subject);
27
+ case "freeform":
28
+ return subject.trim().length > 0;
29
+ case "custom":
30
+ if (!pattern) return false;
31
+ try {
32
+ return new RegExp(pattern).test(subject);
33
+ } catch {
34
+ return false;
35
+ }
36
+ case "auto":
37
+ return subject.trim().length > 0;
38
+ }
39
+ };
40
+
41
+ const DETECTABLE: CommitFlavor[] = ["conventional", "gitmoji", "ticket-prefix"];
42
+
43
+ export const detectCommitFlavor = (
44
+ subjects: string[],
45
+ threshold = 0.7,
46
+ ): { flavor: CommitFlavor | null; confidence: number } => {
47
+ if (subjects.length === 0) return { flavor: null, confidence: 0 };
48
+ const votes = new Map<CommitFlavor, number>();
49
+ for (const subject of subjects) {
50
+ const hit = DETECTABLE.find((flavor) => matchCommitFlavor(subject, flavor));
51
+ if (hit) votes.set(hit, (votes.get(hit) ?? 0) + 1);
52
+ }
53
+ let best: CommitFlavor | null = null;
54
+ let count = 0;
55
+ for (const [flavor, votesFor] of votes) {
56
+ if (votesFor > count) {
57
+ best = flavor;
58
+ count = votesFor;
59
+ }
60
+ }
61
+ const confidence = count / subjects.length;
62
+ return best !== null && confidence >= threshold
63
+ ? { flavor: best, confidence }
64
+ : { flavor: null, confidence };
65
+ };