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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/package.json +1 -1
- package/src/cli.mjs +148 -24
- package/src/db/migrations/013_question_options.sql +28 -0
- package/src/init/index.mjs +9 -0
- package/src/init/questions.mjs +115 -5
- package/src/product/authoring.mjs +65 -0
- package/src/runtime/hooks.mjs +84 -5
- package/src/service/assets/control-center.html +62 -62
- package/src/service/assets/control-center.manifest.json +4 -4
- package/src/service/manage.mjs +42 -5
- package/src/service/mutations.mjs +78 -30
- package/src/tasks/lifecycle.mjs +45 -0
|
@@ -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
|
+
}
|
package/src/runtime/hooks.mjs
CHANGED
|
@@ -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
|
|
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, {
|
|
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
|
|