superdev-cli 0.1.2 → 0.2.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.
@@ -582,3 +582,68 @@ export async function scopeList(root) {
582
582
  return query(root, (db) => db.all(
583
583
  "SELECT * FROM project_scope_items ORDER BY direction, sequence, id"));
584
584
  }
585
+
586
+ // -------------------------------------------------------- capability areas
587
+
588
+ /** States a settled area can be in, and how each one reads. */
589
+ const SETTLED = { specified: "specified", not_applicable: "not applicable", deferred: "deferred" };
590
+
591
+ /**
592
+ * Settle a readiness area: say what was chosen, or say it does not apply.
593
+ *
594
+ * The readiness checklist warned, at high severity, that an area was "awaiting a
595
+ * decision with no question raised", and told the reader to "raise the question,
596
+ * or record the area as not applicable with a reason". Neither was a command.
597
+ * capability_areas was written by init and by answering a question that init had
598
+ * linked to an area, so an area init left unquestioned could never move, and the
599
+ * warning's own remedy was unreachable.
600
+ *
601
+ * Nothing here can quieten the checklist without saying something: specifying
602
+ * needs the choice, and not applicable needs the reason.
603
+ */
604
+ export async function settleCapabilityArea(root, areaId, { choice = null, evidence = null, reason = null, notApplicable = false, actor = "superdev", apply = false } = {}) {
605
+ if (notApplicable && !clean(reason)) {
606
+ throw new AuthoringError(E.REQUIRED,
607
+ "Say why it does not apply. An area dismissed without a reason is indistinguishable from one nobody looked at.");
608
+ }
609
+ if (!notApplicable && !clean(choice)) {
610
+ throw new AuthoringError(E.REQUIRED,
611
+ "Say what was chosen. An area cannot be specified by declaring it specified.");
612
+ }
613
+ const found = await query(root, (db) => db.get("SELECT * FROM capability_areas WHERE id = ?", areaId));
614
+ if (!found) {
615
+ throw new AuthoringError(E.NOT_FOUND, `There is no capability area ${areaId}. List them with superdev capability list.`);
616
+ }
617
+ const plan = {
618
+ areaId,
619
+ area: found.area,
620
+ was: found.state,
621
+ state: notApplicable ? "not_applicable" : "specified",
622
+ choice: clean(choice, 1000),
623
+ evidence: clean(evidence, 500),
624
+ reason: clean(reason, 1000),
625
+ };
626
+ if (!apply) return { applied: false, ...plan };
627
+
628
+ return mutate(root, async (db) => {
629
+ const area = await db.get("SELECT * FROM capability_areas WHERE id = ?", areaId);
630
+ await patch(db, "capability_area", areaId, area.version, plan.state === "not_applicable"
631
+ ? { state: "not_applicable", reason: plan.reason, choice: null, evidence_ref: plan.evidence, revisit_trigger: null }
632
+ : { state: "specified", choice: plan.choice, evidence_ref: plan.evidence, reason: null, revisit_trigger: null },
633
+ { projectId: area.project_id, actor, activityType: "specification_changed",
634
+ activitySummary: clean(plan.state === "not_applicable"
635
+ ? `${area.area} recorded as not applicable`
636
+ : `${area.area} specified`, 200) });
637
+ return { applied: true, ...plan };
638
+ });
639
+ }
640
+
641
+ /** Every readiness area and stack slot, with what settled it. */
642
+ export async function capabilityList(root, { catalog = null, unsettled = false } = {}) {
643
+ return query(root, (db) => db.all(
644
+ `SELECT * FROM capability_areas
645
+ WHERE (? IS NULL OR catalog = ?)
646
+ AND (? = 0 OR state IN ('awaiting_decision','deferred'))
647
+ ORDER BY catalog, sequence, id`,
648
+ catalog, catalog, unsettled ? 1 : 0));
649
+ }
@@ -22,7 +22,7 @@
22
22
  // Secrets never reach the output: everything user facing goes through
23
23
  // sanitizeExternal, which strips secret-shaped values and home paths.
24
24
 
25
- import { existsSync, mkdirSync, readFileSync, writeFileSync, writeSync, realpathSync } from "node:fs";
25
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, writeSync, realpathSync } from "node:fs";
26
26
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
27
27
  import { paths, query } from "../db/store.mjs";
28
28
  import { availableMigrations } from "../db/migrate.mjs";
@@ -472,11 +472,28 @@ async function postToolUse(root, payload) {
472
472
  if (!MUTATING_TOOLS.has(tool) && !viaShell) return EMPTY;
473
473
 
474
474
  let file = payload?.tool_input?.file_path ?? payload?.tool_input?.notebook_path ?? payload?.tool_input?.path;
475
+ // Whether anything is newly changed, decided before the paths are marked.
476
+ //
477
+ // git reports a file as changed until it is committed, so a shell command run
478
+ // after a task is completed re-reported the very files that task had produced,
479
+ // and the readiness report raised its only high-severity warning about work
480
+ // that had just been finished, evidenced and recorded. Any redirect or heredoc
481
+ // is enough to make the hook look, so this happened on the first command after
482
+ // every completion.
483
+ //
484
+ // The paths already seen are the answer. Work noticed twice is one piece of
485
+ // work; a path not seen before is a genuine change. Computed here because
486
+ // markTouched below would otherwise record these paths and make every one of
487
+ // them look familiar.
488
+ let fresh = true;
475
489
  if (viaShell) {
476
490
  const changed = await changedFiles(root);
477
491
  // A command that looked like a write and changed nothing is not work.
478
492
  if (!changed.length) return EMPTY;
493
+ const moved = movedSince(root, changed, readTouched(root).attributed);
494
+ fresh = moved.length > 0;
479
495
  markTouched(root, changed);
496
+ attribute(root, moved);
480
497
  file = file ?? changed[0];
481
498
  }
482
499
  markTouched(root, [file]);
@@ -487,7 +504,7 @@ async function postToolUse(root, payload) {
487
504
  // files touched by the active task; when there is no active task, that
488
505
  // absence is the fact worth keeping. Rate limited so a long editing run
489
506
  // leaves one honest marker rather than one per keystroke.
490
- await noteUntrackedWork(root, file).catch(() => {});
507
+ if (fresh) await noteUntrackedWork(root, file).catch(() => {});
491
508
  await markDocsPossiblyStale(root, file).catch(() => {});
492
509
  return EMPTY;
493
510
  }
@@ -521,9 +538,12 @@ function readTouched(root) {
521
538
  total: Number.isFinite(parsed.total) ? parsed.total : 0,
522
539
  since: typeof parsed.since === "string" ? parsed.since : null,
523
540
  lastEventAt: typeof parsed.lastEventAt === "string" ? parsed.lastEventAt : null,
541
+ // When each path was last accounted for, which `paths` cannot answer
542
+ // because a flush empties it.
543
+ attributed: parsed.attributed && typeof parsed.attributed === "object" ? parsed.attributed : {},
524
544
  };
525
545
  } catch {
526
- return { paths: [], total: 0, since: null, lastEventAt: null };
546
+ return { paths: [], total: 0, since: null, lastEventAt: null, attributed: {} };
527
547
  }
528
548
  }
529
549
 
@@ -537,6 +557,61 @@ function writeTouched(root, state) {
537
557
  }
538
558
  }
539
559
 
560
+ /**
561
+ * Whether a path is the product, rather than Superdev's own bookkeeping about it.
562
+ *
563
+ * markTouched has always skipped `.superdev/`, and the freshness comparison did
564
+ * not, so the runtime directory was picking up modification stamps. One of those
565
+ * files is the database write-ahead log, which moves on every single command, so
566
+ * every command would have looked like new work and the comparison would have
567
+ * decided nothing. Both callers now ask the same question in the same place.
568
+ */
569
+ const isProjectWork = (rel) => Boolean(rel) && !rel.startsWith(".superdev/");
570
+
571
+ /**
572
+ * Which of these paths have actually moved since they were last accounted for.
573
+ *
574
+ * git reports a file as changed until it is committed, so every shell command run
575
+ * between an edit and its commit re-reports the same files. That made the hook
576
+ * record fresh untracked work for edits a completed task had already produced and
577
+ * evidenced, and the readiness report then raised its only high-severity warning
578
+ * about work that was finished. Any redirect or heredoc is enough to make the hook
579
+ * look, so it happened on the first command after every completion.
580
+ *
581
+ * The pending path list cannot answer this, because a flush empties it. A
582
+ * modification time can: a path re-reported without moving is the same work seen
583
+ * again, and a path whose file has changed since is new work whoever is holding
584
+ * the task. Missing stats count as moved, because failing to notice real work is
585
+ * the worse mistake of the two.
586
+ */
587
+ export function movedSince(root, candidates, attributed) {
588
+ candidates = candidates.filter(isProjectWork);
589
+ const moved = [];
590
+ for (const path of candidates) {
591
+ let stamp = null;
592
+ try {
593
+ stamp = statSync(join(root, path)).mtimeMs;
594
+ } catch {
595
+ // Deleted, or unreadable. Either way it is not the file we accounted for.
596
+ moved.push([path, Date.now()]);
597
+ continue;
598
+ }
599
+ if (stamp > (Number(attributed[path]) || 0)) moved.push([path, stamp]);
600
+ }
601
+ return moved;
602
+ }
603
+
604
+ /** Record that these paths are accounted for, at the state they are in now. */
605
+ function attribute(root, moved) {
606
+ if (!moved.length) return;
607
+ const state = readTouched(root);
608
+ const next = { ...state.attributed };
609
+ for (const [path, stamp] of moved) next[path] = stamp;
610
+ // Bounded, oldest first, so a long-lived project does not grow this forever.
611
+ const entries = Object.entries(next).sort((a, b) => b[1] - a[1]).slice(0, MAX_TOUCHED);
612
+ writeTouched(root, { ...state, attributed: Object.fromEntries(entries) });
613
+ }
614
+
540
615
  /**
541
616
  * Record which project-relative paths changed.
542
617
  *
@@ -550,7 +625,7 @@ export function markTouched(root, files) {
550
625
  if (!file) continue;
551
626
  const rel = relative(root, canonical(root, file)).split(sep).join("/");
552
627
  if (!rel || rel.startsWith("../") || isAbsolute(rel)) continue;
553
- if (rel.startsWith(".superdev/")) continue; // Superdev's own runtime state is not project work.
628
+ if (!isProjectWork(rel)) continue;
554
629
  kept.push(rel);
555
630
  }
556
631
  if (!kept.length) return null;
@@ -562,6 +637,7 @@ export function markTouched(root, files) {
562
637
  total: state.total + kept.length,
563
638
  since: state.since ?? new Date().toISOString(),
564
639
  lastEventAt: state.lastEventAt,
640
+ attributed: state.attributed,
565
641
  };
566
642
  writeTouched(root, next);
567
643
  return next;
@@ -633,7 +709,10 @@ export async function flushTouched(root, { force = false } = {}) {
633
709
  metadata: { files: state.paths.length, changes: state.total },
634
710
  }).catch((err) => ({ recorded: false, reason: message(err) }));
635
711
 
636
- writeTouched(root, { paths: [], total: 0, since: null, lastEventAt: new Date().toISOString() });
712
+ writeTouched(root, {
713
+ paths: [], total: 0, since: null, lastEventAt: new Date().toISOString(),
714
+ attributed: state.attributed,
715
+ });
637
716
  return result;
638
717
  }
639
718