@brainervirus/workit-core 0.9.1 → 0.9.2

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/core/branch.ts +181 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "private": false,
5
5
  "description": "Workit — workflow rails for agentic coding: specs, plans, YouTrack, CI-gated commits (shared core)",
6
6
  "keywords": [
@@ -35,6 +35,10 @@ const baseBranch = (cwd: string): { base: string } | { error: string } => {
35
35
  };
36
36
  const DECLARE_RE = /^\s*\*+Branch:\*+\s*`?([^`\s|]+)`?\s*$/gim;
37
37
  const USE_CURRENT_RE = /^\s*\*+Branch:\*+\s*use-current\s*$/im;
38
+ // Windows portability: journal lines and stash-coverage sets carry
39
+ // repo-relative paths, which must match git's POSIX separator output even
40
+ // though path.join emits platform separators.
41
+ const toPosix = (p: string) => p.split(path.sep).join("/");
38
42
  const readSafe = (p: string): string | null => {
39
43
  try {
40
44
  return readFileSync(p, "utf8");
@@ -274,14 +278,39 @@ export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; erro
274
278
  return fastForwardBase(cwd, base);
275
279
  };
276
280
 
277
- // CA-05: flow-state snapshots live under the OS tempdir scoped by a hash of
278
- // the workspace path never inside the repository or docs/.
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.
279
306
  export const snapshotFlowState = (cwd: string): string => {
307
+ purgeStaleFlowGuardRoots(Date.now());
280
308
  const root = path.join(
281
309
  tmpdir(),
282
- `workit-flow-guard-${createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16)}`,
310
+ `workit-flow-guard-${createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16)}.${
311
+ process.pid
312
+ }.${process.hrtime.bigint()}`,
283
313
  );
284
- rmSync(root, { recursive: true, force: true }); // drop a stale guard from a crashed run
285
314
  mkdirSync(root, { recursive: true });
286
315
  const docsDir = path.join(path.resolve(cwd), "docs");
287
316
  let slugs: string[] = [];
@@ -291,15 +320,21 @@ export const snapshotFlowState = (cwd: string): string => {
291
320
  return root; // no docs/ yet — zero-file snapshot
292
321
  }
293
322
  for (const slug of slugs) {
294
- const src = path.join(docsDir, slug, "sdd", "flow.json");
295
- try {
296
- if (!statSync(src).isFile()) continue;
297
- } catch {
298
- continue;
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);
299
337
  }
300
- const dest = path.join(root, "docs", slug, "sdd", "flow.json");
301
- mkdirSync(path.dirname(dest), { recursive: true });
302
- cpSync(src, dest);
303
338
  }
304
339
  return root;
305
340
  };
@@ -307,14 +342,20 @@ export const snapshotFlowState = (cwd: string): string => {
307
342
  // CA-04: restore-if-missing keeps the newest working-tree bytes; the snapshot
308
343
  // root is removed only after every file is handled and retained on failure.
309
344
  // A caught failure must not vanish: the message is returned so callers can
310
- // surface it to operators as a warning.
311
- export const restoreFlowSnapshot = (snapDir: string, cwd: string): string | undefined => {
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 } => {
312
352
  const walk = (dir: string, rel: string): string[] =>
313
353
  readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
314
354
  entry.isDirectory()
315
355
  ? walk(path.join(dir, entry.name), path.join(rel, entry.name))
316
356
  : [path.join(rel, entry.name)],
317
357
  );
358
+ const restored: string[] = [];
318
359
  try {
319
360
  const workspace = path.resolve(cwd);
320
361
  for (const rel of walk(snapDir, "")) {
@@ -331,13 +372,17 @@ export const restoreFlowSnapshot = (snapDir: string, cwd: string): string | unde
331
372
  rmSync(tmpDest, { force: true });
332
373
  throw error;
333
374
  }
375
+ restored.push(toPosix(rel));
334
376
  }
335
377
  rmSync(snapDir, { recursive: true, force: true });
336
- return undefined;
378
+ return { restored };
337
379
  } catch (error) {
338
- return `flow state snapshot restore failed: ${
339
- error instanceof Error ? error.message : String(error)
340
- }`;
380
+ return {
381
+ restored,
382
+ warning: `flow state snapshot restore failed: ${
383
+ error instanceof Error ? error.message : String(error)
384
+ }`,
385
+ };
341
386
  }
342
387
  };
343
388
 
@@ -348,12 +393,14 @@ export const branchSetup = ({
348
393
  target_branch,
349
394
  stash,
350
395
  workspace_root,
396
+ log,
351
397
  }: {
352
398
  action?: string;
353
399
  sdd_dir?: string;
354
400
  target_branch?: string;
355
401
  stash?: string;
356
402
  workspace_root: string;
403
+ log?: (message: string) => void;
357
404
  }) => {
358
405
  const cwd = path.resolve(workspace_root);
359
406
  const exec = (args: string[]): string =>
@@ -376,9 +423,60 @@ export const branchSetup = ({
376
423
  writeFileSync(manifestPath, JSON.stringify(data, null, 2) + "\n", "utf8");
377
424
 
378
425
  let snapDir: string | null = null;
379
- const restoreWithWarning = (dir: string | null): string[] => {
380
- const warning = dir ? restoreFlowSnapshot(dir, cwd) : undefined;
381
- return warning ? [warning] : [];
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] : [] };
382
480
  };
383
481
 
384
482
  if (action === "reapply_stash") {
@@ -387,22 +485,28 @@ export const branchSetup = ({
387
485
  if (!ref) return { error: "no stash_ref in manifest" };
388
486
  // D-03: guard flow.json across the stash pop window.
389
487
  snapDir = snapshotFlowState(cwd);
488
+ journalSnapshot();
489
+ journal(`pre-pop: ${String(ref)}`);
390
490
  try {
391
491
  exec(["stash", "pop", String(ref)]);
392
492
  } catch (error) {
393
493
  // CA-03: the snapshot ran before the pop — a failing pop must still
394
494
  // restore a mid-window-wiped flow.json before returning.
395
- const [warning] = restoreWithWarning(snapDir);
495
+ journal("pop: failed");
496
+ const {
497
+ warnings: [warning],
498
+ } = restoreWithWarning(snapDir);
396
499
  return {
397
500
  error: `${error instanceof Error ? error.message : "stash pop failed"}${
398
501
  warning ? `; ${warning}` : ""
399
502
  }`,
400
503
  };
401
504
  }
505
+ journal("pop: ok");
402
506
  delete manifest.stash_ref;
403
507
  delete manifest.stash_created_at;
404
508
  writeManifest(manifest);
405
- const warnings = restoreWithWarning(snapDir);
509
+ const { warnings } = restoreWithWarning(snapDir);
406
510
  return { action: "reapply_stash", ok: true, ...(warnings.length > 0 ? { warnings } : {}) };
407
511
  }
408
512
 
@@ -427,6 +531,7 @@ export const branchSetup = ({
427
531
  if ("error" in baseResolved) return { error: baseResolved.error };
428
532
  base = baseResolved.base;
429
533
  }
534
+ journal(`entry: current=${current} target=${target} base=${base ?? "-"}`);
430
535
 
431
536
  let stash_ref: string | undefined;
432
537
  // Best-effort restore; if the pop itself fails, the caller's error gains a
@@ -434,18 +539,23 @@ export const branchSetup = ({
434
539
  const failAfterStash = (message: string): { error: string } => {
435
540
  let suffix = "";
436
541
  if (stash_ref) {
542
+ journal(`pre-pop: ${stash_ref}`);
437
543
  try {
438
544
  exec(["stash", "pop", stash_ref]);
439
545
  stash_ref = undefined;
546
+ journal("pop: ok");
440
547
  } catch {
548
+ journal("pop: failed");
441
549
  suffix = " (changes preserved in stash)";
442
550
  }
443
551
  }
444
552
  // CA-03: the snapshot ran before the stash push, so every error return
445
553
  // here must still restore a mid-window-wiped flow.json and drop the
446
- // guard root (a retained root is destroyed by the next run's
447
- // stale-root rmSync). Never masks the original error.
448
- const [warning] = restoreWithWarning(snapDir);
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);
449
559
  return { error: `${message}${suffix}${warning ? `; ${warning}` : ""}` };
450
560
  };
451
561
  if (current !== target) {
@@ -471,14 +581,17 @@ export const branchSetup = ({
471
581
  // CA-03: snapshot before the stash push so flow.json survives the
472
582
  // stash/checkout window even if the pathspec exclusion misses.
473
583
  snapDir = snapshotFlowState(cwd);
584
+ journalSnapshot();
474
585
  exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`, "--", ":!docs/*/sdd"]);
475
586
  } catch (error) {
476
587
  return { error: error instanceof Error ? error.message : "stash push failed" };
477
588
  }
478
589
  stash_ref = "stash@{0}";
590
+ journal(`stash push: ${stash_ref}`);
479
591
  }
480
592
  try {
481
593
  exec(["checkout", target]);
594
+ journalPresence("post-checkout");
482
595
  } catch (error) {
483
596
  const message = error instanceof Error ? error.message : "checkout failed";
484
597
  if (/worktree/i.test(message)) {
@@ -501,6 +614,7 @@ export const branchSetup = ({
501
614
  : ensureBaseBranch(cwd, effectiveBase);
502
615
  if (!baseResult.ok) return failAfterStash(baseResult.error ?? "ensure-base-branch failed");
503
616
  exec(["checkout", "-b", target]);
617
+ journalPresence("post-create");
504
618
  } catch (createError) {
505
619
  return failAfterStash(
506
620
  createError instanceof Error ? createError.message : "branch create failed",
@@ -534,7 +648,47 @@ export const branchSetup = ({
534
648
  }
535
649
  return result;
536
650
  }
537
- const warnings = restoreWithWarning(snapDir);
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)`);
687
+ }
688
+ } catch {
689
+ /* keep the stash ref — reapply_stash stays available */
690
+ }
691
+ }
538
692
  return {
539
693
  action: "setup",
540
694
  ok: true,