@pilotspace/add 1.16.1 → 2.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.
- package/CHANGELOG.md +227 -0
- package/GETTING-STARTED.md +20 -64
- package/README.md +128 -56
- package/agents/add.md +68 -0
- package/bin/cli.js +229 -29
- package/package.json +2 -3
- package/personas-teacher/VENDOR.md +6 -0
- package/skill/add/SKILL.md +100 -134
- package/skill/add/adopt.md +28 -0
- package/skill/add/beyond.md +36 -0
- package/skill/add/deltas.md +25 -2
- package/skill/add/design.md +56 -74
- package/skill/add/{report-template.md → gate-udd.md} +28 -37
- package/skill/add/intake.md +65 -29
- package/skill/add/loop.md +35 -23
- package/skill/add/phases/build.md +78 -0
- package/skill/add/phases/direction.md +280 -0
- package/skill/add/phases/verify.md +160 -0
- package/skill/add/run.md +13 -30
- package/skill/add/terms.md +18 -0
- package/tooling/add.py +2520 -2933
- package/tooling/add_engine/autonomy.py +2 -37
- package/tooling/add_engine/components.py +4 -87
- package/tooling/add_engine/constants.py +153 -87
- package/tooling/add_engine/guidelines.py +9 -11
- package/tooling/add_engine/io_state.py +82 -2
- package/tooling/add_engine/predicates.py +90 -2
- package/tooling/add_engine/search.py +6 -6
- package/tooling/add_engine/taskdoc.py +15 -10
- package/tooling/templates/CONVENTIONS.md.tmpl +1 -1
- package/tooling/templates/GLOSSARY.md.tmpl +10 -4
- package/tooling/templates/MILESTONE.md.tmpl +20 -16
- package/tooling/templates/PLAN.md.tmpl +128 -0
- package/tooling/templates/PROJECT.md.tmpl +6 -1
- package/tooling/templates/PROMPT.persona.md.tmpl +17 -21
- package/tooling/templates/personas/_template.md.tmpl +35 -29
- package/tooling/templates/personas/build-engineer.md.tmpl +45 -0
- package/tooling/templates/personas/data-steward.md.tmpl +45 -0
- package/tooling/templates/personas/evidence-verifier.md.tmpl +45 -0
- package/tooling/templates/personas/platform-engineer.md.tmpl +45 -0
- package/tooling/templates/personas/product-lead.md.tmpl +51 -0
- package/tooling/templates/personas/quality-auditor.md.tmpl +47 -0
- package/tooling/templates/personas/release-manager.md.tmpl +52 -0
- package/tooling/templates/personas/security-gatekeeper.md.tmpl +45 -0
- package/tooling/templates/personas/software-architect.md.tmpl +45 -0
- package/tooling/templates/personas/stream-orchestrator.md.tmpl +44 -0
- package/tooling/templates/personas/technical-writer.md.tmpl +49 -0
- package/tooling/templates/personas/ux-experience-lead.md.tmpl +48 -0
- package/tooling/templates/specs/SPEC.md.tmpl +18 -0
- package/tooling/templates/udd-wireframe.md +1 -1
- package/agents/add-advisor.md +0 -31
- package/agents/add-build.md +0 -29
- package/agents/add-design.md +0 -32
- package/agents/add-persona.md +0 -31
- package/agents/add-verify.md +0 -34
- package/docs/00-introduction.md +0 -46
- package/docs/01-principles.md +0 -71
- package/docs/02-the-flow.md +0 -113
- package/docs/03-step-1-specify.md +0 -123
- package/docs/04-step-2-scenarios.md +0 -90
- package/docs/05-step-3-contract.md +0 -80
- package/docs/06-step-4-tests.md +0 -85
- package/docs/07-step-5-build.md +0 -82
- package/docs/08-step-6-verify.md +0 -105
- package/docs/09-the-loop.md +0 -75
- package/docs/10-setup-and-stages.md +0 -118
- package/docs/11-governance.md +0 -91
- package/docs/12-roles.md +0 -99
- package/docs/13-adoption.md +0 -67
- package/docs/14-foundation.md +0 -142
- package/docs/15-foundations-and-lineage.md +0 -106
- package/docs/16-releasing.md +0 -182
- package/docs/17-components.md +0 -143
- package/docs/18-personas.md +0 -80
- package/docs/README.md +0 -77
- package/docs/add-competencies.png +0 -0
- package/docs/add-flow.png +0 -0
- package/docs/add-foundation.png +0 -0
- package/docs/add-hierarchy.png +0 -0
- package/docs/add-milestone-task-lifecycle.png +0 -0
- package/docs/add-task-growth-wheel.png +0 -0
- package/docs/appendix-a-templates.md +0 -88
- package/docs/appendix-b-prompts.md +0 -154
- package/docs/appendix-c-glossary.md +0 -176
- package/docs/appendix-d-worked-example.md +0 -231
- package/docs/appendix-e-checklists.md +0 -92
- package/docs/appendix-f-requirements-matrix.md +0 -171
- package/docs/appendix-g-references.md +0 -106
- package/skill/add/advisor.md +0 -80
- package/skill/add/compact-foundation.md +0 -40
- package/skill/add/components.md +0 -52
- package/skill/add/confidence.md +0 -32
- package/skill/add/fold.md +0 -63
- package/skill/add/graduate.md +0 -39
- package/skill/add/phases/0-ground.md +0 -64
- package/skill/add/phases/0-setup.md +0 -103
- package/skill/add/phases/1-specify.md +0 -61
- package/skill/add/phases/2-scenarios.md +0 -48
- package/skill/add/phases/3-contract.md +0 -60
- package/skill/add/phases/4-tests.md +0 -59
- package/skill/add/phases/5-build.md +0 -62
- package/skill/add/phases/6-verify.md +0 -69
- package/skill/add/phases/7-observe.md +0 -47
- package/skill/add/phases/fast-lane.md +0 -31
- package/skill/add/release.md +0 -61
- package/skill/add/scope.md +0 -72
- package/skill/add/sensitivity.md +0 -51
- package/skill/add/setup-review.md +0 -48
- package/skill/add/soul.md +0 -58
- package/skill/add/streams.md +0 -268
- package/tooling/add_engine/release.py +0 -86
- package/tooling/templates/TASK.fast.md.tmpl +0 -77
- package/tooling/templates/TASK.md.tmpl +0 -216
package/bin/cli.js
CHANGED
|
@@ -6,10 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* npx @pilotspace/add init [targetDir] [--force] [--stage <stage>] [--name <name>] [--yes|--non-interactive]
|
|
8
8
|
*
|
|
9
|
-
* Installs the ADD skill + tooling
|
|
9
|
+
* Installs the ADD skill + tooling into a target project:
|
|
10
10
|
* <target>/.claude/skills/add/ (the skill Claude loads)
|
|
11
11
|
* <target>/.add/tooling/ (add.py scaffolder + state tracker)
|
|
12
|
-
* <target>/.add/docs/ (the AIDD book — the trust layer)
|
|
13
12
|
* It DROPS FILES ONLY — it does NOT run `add.py init`. Initialisation is deferred to
|
|
14
13
|
* the AI (via `/add`, which runs `init --await-lock` to arm the v12 lock-down gate) or
|
|
15
14
|
* to a CLI user. A pre-run plain init would grandfather-lock the gate before `/add` runs
|
|
@@ -58,9 +57,9 @@ function parseArgs(argv) {
|
|
|
58
57
|
// non-interactive selector the interactive() gate honors (CI/pipes do this too).
|
|
59
58
|
else if (a === "--yes" || a === "-y") args.yes = true;
|
|
60
59
|
else if (a === "--non-interactive") args.nonInteractive = true;
|
|
61
|
-
// --no-skill: drop the engine
|
|
60
|
+
// --no-skill: drop the engine ONLY, not the skill. The Claude Code plugin
|
|
62
61
|
// already provides the `add` skill, so a plugin bootstrap uses this to materialize
|
|
63
|
-
// .add/tooling/
|
|
62
|
+
// .add/tooling/ into the project without a duplicate .claude/skills/add.
|
|
64
63
|
else if (a === "--no-skill") args.noSkill = true;
|
|
65
64
|
// --rule-file: write the ADD block to .claude/rules/add-workflows.md + reference it from
|
|
66
65
|
// CLAUDE.md instead of inlining (auto-on for ccsk projects with a .ccsk/ dir).
|
|
@@ -217,7 +216,7 @@ function agentPointerBlock(profile) {
|
|
|
217
216
|
GUIDE_BEGIN + "\n" +
|
|
218
217
|
"## ADD — how to work in this repo\n" +
|
|
219
218
|
"\n" +
|
|
220
|
-
"This project uses **ADD (AI-Driven Development)**. The engine
|
|
219
|
+
"This project uses **ADD (AI-Driven Development)**. The engine is installed.\n" +
|
|
221
220
|
"To begin: run `python3 .add/tooling/add.py status` (the resume point), read\n" +
|
|
222
221
|
"`.add/PROJECT.md`, then `python3 .add/tooling/add.py guide` for the current phase.\n" +
|
|
223
222
|
"\n" +
|
|
@@ -410,10 +409,11 @@ async function loadClack() {
|
|
|
410
409
|
// --- brand + feature showcase (interactive path only; fail-soft) -------------
|
|
411
410
|
// Wordmark + value line + the 7-step Specify->Observe loop, rendered BEFORE the first
|
|
412
411
|
// prompt on the interactive path only — so the non-interactive byte stream is unchanged.
|
|
413
|
-
// The 7 labels are the real ADD phases (grounded in the method, never invented
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
|
|
412
|
+
// The 7 labels are the real ADD phases (grounded in the method, never invented; grounding
|
|
413
|
+
// itself is folded into step 3, Plan, not a separate phase). Fail-soft: any draw error is
|
|
414
|
+
// swallowed so a banner can never abort the install. No color is emitted (default accent:
|
|
415
|
+
// none); the glyphs / tagline / accent are a SWAPPABLE content slot.
|
|
416
|
+
const BRAND_LOOP = ["Specify", "Plan", "Tests", "Build", "Verify"];
|
|
417
417
|
|
|
418
418
|
function terminalCaps(env, stream) {
|
|
419
419
|
const width = Number(env.COLUMNS) || (stream && stream.columns) || 80;
|
|
@@ -481,7 +481,7 @@ async function runClackPreamble(clack, target, detected, askScope) {
|
|
|
481
481
|
initialValue: target, defaultValue: target,
|
|
482
482
|
});
|
|
483
483
|
if (clack.isCancel(chosen)) return { cancelled: true, target: target };
|
|
484
|
-
const ok = await clack.confirm({ message: "Write the ADD skill + tooling
|
|
484
|
+
const ok = await clack.confirm({ message: "Write the ADD skill + tooling here?" });
|
|
485
485
|
if (clack.isCancel(ok) || !ok) return { cancelled: true, target: target };
|
|
486
486
|
// global-first SCOPE step (after the target confirm, before agent-detect) — recommended
|
|
487
487
|
// global home, explicit pick; skipped when --global already chose. global stays ADDITIVE.
|
|
@@ -671,7 +671,7 @@ function dropFiles(args, target, profile, intent) {
|
|
|
671
671
|
// NO step 4: the installer DROPS FILES ONLY. Initialisation is deferred to the AI
|
|
672
672
|
// (via `/add`) or a CLI user — a pre-run plain `add.py init` would grandfather-lock
|
|
673
673
|
// the v12 lock-down gate before `/add` runs (see file header). So no Python is run here.
|
|
674
|
-
log("\nDone. " + (args.noSkill ? "The engine
|
|
674
|
+
log("\nDone. " + (args.noSkill ? "The engine is" : "The `add` skill + tooling are") +
|
|
675
675
|
" installed (no project state yet — that's intentional).");
|
|
676
676
|
if (profile.id === "generic") {
|
|
677
677
|
// the generic onramp line — kept literal so the conversational-only handoff is stable
|
|
@@ -753,7 +753,6 @@ const MANAGED = [
|
|
|
753
753
|
["skill/add", [".claude", "skills", "add"], false],
|
|
754
754
|
["agents", [".claude", "agents"], false],
|
|
755
755
|
["tooling", [".add", "tooling"], true],
|
|
756
|
-
["docs", [".add", "docs"], false],
|
|
757
756
|
["personas-teacher", [".add", "personas-teacher"], false],
|
|
758
757
|
];
|
|
759
758
|
// Optional managed trees: an ENHANCEMENT the persona phase reads, not core runtime. The real
|
|
@@ -762,6 +761,18 @@ const MANAGED = [
|
|
|
762
761
|
// _installer.py:OPTIONAL. Design-for-failure. `agents` joins here (roster-install-drift): the
|
|
763
762
|
// phase-agent roster is a spawn-acceleration enhancement, not core runtime.
|
|
764
763
|
const OPTIONAL = new Set(["personas-teacher", "agents"]);
|
|
764
|
+
// SHARED-namespace managed trees (installer-shared-namespace-guard): destinations OTHER
|
|
765
|
+
// TOOLS also write — `.claude/agents` holds the user's own Claude Code subagents. A
|
|
766
|
+
// whole-dir clean-replace there sweeps the user's files as "orphans" (the reported
|
|
767
|
+
// data-loss bug), so these trees route to sharedFileReplace: per-file atomic landings of
|
|
768
|
+
// the shipped files + removal of ONLY the explicit tombstones below. Twin of
|
|
769
|
+
// _installer.py:_SHARED / _RETIRED_AGENTS.
|
|
770
|
+
const SHARED = new Set(["agents"]);
|
|
771
|
+
// Roster names retired upstream — the ONLY names the shared lander may remove (never a
|
|
772
|
+
// pattern/prefix heuristic: a USER file named add-anything.md must survive).
|
|
773
|
+
// roster-distill (ADD 2.0 M1): the 5-agent roster collapsed into the ONE `add` agent.
|
|
774
|
+
const RETIRED_AGENTS = ["add-design.md", "add-build.md", "add-verify.md",
|
|
775
|
+
"add-persona.md", "add-advisor.md"];
|
|
765
776
|
const STAMP_FILE = ".add-version";
|
|
766
777
|
const LOCK_FILE = ".update.lock"; // the `update --global` home lock (never user-data)
|
|
767
778
|
const LOCK_STALE_DEFAULT = 600; // seconds (10 min); ADD_LOCK_STALE_SECONDS env-overridable
|
|
@@ -796,6 +807,37 @@ function sleepSync(ms) {
|
|
|
796
807
|
Atomics.wait(ia, 0, 0, ms);
|
|
797
808
|
}
|
|
798
809
|
|
|
810
|
+
// An async, event-loop-YIELDING sleep (unlike sleepSync's Atomics.wait, which blocks the very
|
|
811
|
+
// thread a setInterval-based heartbeat needs to fire on) — js-reclaim-lock-heartbeat.
|
|
812
|
+
function sleepAsync(ms) {
|
|
813
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// js-reclaim-lock-heartbeat: the JS analog of the Python `_lock_heartbeat` fix
|
|
817
|
+
// (reclaim-ticket-race) — refreshes a held lock file's own mtime on a `.unref()`'d
|
|
818
|
+
// `setInterval` for as long as it is held, so a live-but-slow holder is never misjudged
|
|
819
|
+
// stale by a sibling racer purely because wall-clock age crosses `staleAfterMs`. `.unref()`
|
|
820
|
+
// means the timer never keeps the process alive on its own; `stop()` clears it explicitly on
|
|
821
|
+
// release (belt AND suspenders, mirrors the interval's own `max(50ms, min(staleAfterMs/4,
|
|
822
|
+
// 5000ms))` formula the frozen contract cites). A crash simply stops the interval firing — the
|
|
823
|
+
// file still ages out and self-heals via the existing reclaim-ticket mechanism, unchanged.
|
|
824
|
+
//
|
|
825
|
+
// This is a probabilistic mitigation, not a mathematical guarantee: a callback fires only
|
|
826
|
+
// between synchronous JS turns, so whole-event-loop starvation (a long synchronous operation
|
|
827
|
+
// blocking the process) can defeat it exactly as whole-process scheduling starvation once
|
|
828
|
+
// defeated the Python fix's own real OS thread on real CI (see TASK.md's least-sure flag) —
|
|
829
|
+
// accepted given production defaults (600s/120s) make that starvation window vanishingly
|
|
830
|
+
// unlikely in practice.
|
|
831
|
+
function startLockHeartbeat(lockPath, staleAfterMs) {
|
|
832
|
+
const intervalMs = Math.max(50, Math.min(staleAfterMs / 4, 5000));
|
|
833
|
+
const timer = setInterval(() => {
|
|
834
|
+
const now = new Date();
|
|
835
|
+
try { fs.utimesSync(lockPath, now, now); } catch (_e) {} // released/reclaimed — best-effort
|
|
836
|
+
}, intervalMs);
|
|
837
|
+
timer.unref();
|
|
838
|
+
return { stop: () => clearInterval(timer) };
|
|
839
|
+
}
|
|
840
|
+
|
|
799
841
|
function pkgVersion() {
|
|
800
842
|
try { return require(path.join(PKG_ROOT, "package.json")).version; }
|
|
801
843
|
catch (_e) { return "0.0.0"; }
|
|
@@ -917,7 +959,7 @@ function cleanReplaceTree(src, dest, stripTests) {
|
|
|
917
959
|
return { restored: restored, refreshed: refreshed };
|
|
918
960
|
}
|
|
919
961
|
|
|
920
|
-
const TREE_LABEL = { "skill/add": "skill", "agents": "agents", "tooling": "tooling", "
|
|
962
|
+
const TREE_LABEL = { "skill/add": "skill", "agents": "agents", "tooling": "tooling", "personas-teacher": "personas" };
|
|
921
963
|
|
|
922
964
|
// Per managed tree: "missing" (dest absent OR empty) or "present".
|
|
923
965
|
function managedStatus(target) {
|
|
@@ -930,6 +972,36 @@ function managedStatus(target) {
|
|
|
930
972
|
return status;
|
|
931
973
|
}
|
|
932
974
|
|
|
975
|
+
// installer-shared-namespace-guard: land a SHARED-namespace managed tree per FILE.
|
|
976
|
+
// `.claude/agents` belongs to the user as much as to ADD — only the shipped files are
|
|
977
|
+
// written (temp-sibling + atomic rename each, so a crash never leaves a torn file) and
|
|
978
|
+
// only the explicit RETIRED_AGENTS tombstones are removed; every other destination file
|
|
979
|
+
// is never opened. Returns the same { restored, refreshed } roll-up shape as
|
|
980
|
+
// cleanReplaceTree so the reconcile reporting is agnostic. Mirror of
|
|
981
|
+
// _installer.py:_shared_file_replace.
|
|
982
|
+
function sharedFileReplace(src, dest) {
|
|
983
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
984
|
+
let restored = 0, refreshed = 0;
|
|
985
|
+
const entries = fs.readdirSync(src).filter((n) => fs.statSync(path.join(src, n)).isFile()).sort();
|
|
986
|
+
for (const name of entries) {
|
|
987
|
+
const target = path.join(dest, name);
|
|
988
|
+
const existed = fs.existsSync(target);
|
|
989
|
+
const tmp = path.join(dest, name + ".add-tmp-" + Math.random().toString(36).slice(2, 10));
|
|
990
|
+
try {
|
|
991
|
+
fs.copyFileSync(path.join(src, name), tmp);
|
|
992
|
+
fs.renameSync(tmp, target); // atomic overwrite (same directory)
|
|
993
|
+
} catch (e) {
|
|
994
|
+
fs.rmSync(tmp, { force: true }); // dest entry untouched or already whole
|
|
995
|
+
throw e;
|
|
996
|
+
}
|
|
997
|
+
if (existed) refreshed++; else restored++;
|
|
998
|
+
}
|
|
999
|
+
for (const name of RETIRED_AGENTS) {
|
|
1000
|
+
fs.rmSync(path.join(dest, name), { force: true }); // absent tombstone — never fatal
|
|
1001
|
+
}
|
|
1002
|
+
return { restored: restored, refreshed: refreshed };
|
|
1003
|
+
}
|
|
1004
|
+
|
|
933
1005
|
// reconcile: restore-missing + refresh-present (sweep orphans) across the managed trees,
|
|
934
1006
|
// reporting per-tree status. Honors --no-skill (the plugin provides the skill). Touches
|
|
935
1007
|
// ONLY managed trees — never user data. Prechecks ALL sources first (design-for-failure:
|
|
@@ -947,7 +1019,9 @@ function reconcile(args, target, srcRoot) {
|
|
|
947
1019
|
const status = managedStatus(target);
|
|
948
1020
|
let restored = 0, refreshed = 0;
|
|
949
1021
|
for (const [sub, destParts, stripTests] of trees) {
|
|
950
|
-
const roll =
|
|
1022
|
+
const roll = SHARED.has(sub)
|
|
1023
|
+
? sharedFileReplace(path.join(srcRoot, sub), path.join(target, ...destParts))
|
|
1024
|
+
: cleanReplaceTree(path.join(srcRoot, sub), path.join(target, ...destParts), stripTests);
|
|
951
1025
|
restored += roll.restored;
|
|
952
1026
|
refreshed += roll.refreshed;
|
|
953
1027
|
const dest = destParts.join("/");
|
|
@@ -961,7 +1035,7 @@ function reconcile(args, target, srcRoot) {
|
|
|
961
1035
|
return { restored: restored, refreshed: refreshed, trees: status };
|
|
962
1036
|
}
|
|
963
1037
|
|
|
964
|
-
// --- global home: an OPT-IN shared install (engine+
|
|
1038
|
+
// --- global home: an OPT-IN shared install (engine+skill) updated for all projects ----
|
|
965
1039
|
// Resolution is PURE + total (never throws); the home MIRRORS the bundled managed layer so
|
|
966
1040
|
// `update --global` propagation reuses reconcile() unchanged. Mirror of _installer.py.
|
|
967
1041
|
function resolveGlobalHome(env) {
|
|
@@ -1003,12 +1077,14 @@ function writeRegistry(home, paths) {
|
|
|
1003
1077
|
fs.renameSync(tmp, target); // atomic on the same filesystem (POSIX + Windows)
|
|
1004
1078
|
}
|
|
1005
1079
|
|
|
1006
|
-
// The home mirrors the bundled layout (skill/add + tooling
|
|
1080
|
+
// The home mirrors the bundled layout (skill/add + tooling at the SAME relative paths
|
|
1007
1081
|
// the package ships) so reconcile(args, project, home) reuses MANAGED unchanged.
|
|
1008
1082
|
const GLOBAL_TREES = [
|
|
1009
1083
|
["skill/add", ["skill", "add"], false],
|
|
1084
|
+
// roster-drift fix: absent here, `update --global` propagation (sourced FROM the home)
|
|
1085
|
+
// soft-skipped the roster forever — no refresh, no retired-agent tombstones.
|
|
1086
|
+
["agents", ["agents"], false],
|
|
1010
1087
|
["tooling", ["tooling"], true],
|
|
1011
|
-
["docs", ["docs"], false],
|
|
1012
1088
|
["personas-teacher", ["personas-teacher"], false],
|
|
1013
1089
|
];
|
|
1014
1090
|
|
|
@@ -1031,7 +1107,7 @@ function reconcileGlobal(home, claudeDir, noSkill) {
|
|
|
1031
1107
|
// --- global DATA: an OPT-IN per-project user-data snapshot under <home>/data/<key> ----------
|
|
1032
1108
|
// Strictly additive; copies ONLY user-data (managed trees + transient excluded), clean-replaced,
|
|
1033
1109
|
// one-way (project->home). Mirror of _installer.py (identical key + include/exclude rule).
|
|
1034
|
-
const DATA_EXCLUDE = ["tooling", "docs", ".update-cache", STAMP_FILE, LOCK_FILE, PROJECT_LOCK_FILE]; // managed trees + meta + both locks
|
|
1110
|
+
const DATA_EXCLUDE = ["tooling", "docs", ".update-cache", STAMP_FILE, LOCK_FILE, PROJECT_LOCK_FILE]; // managed trees + meta + both locks ("docs" = legacy 1.x tree: still never user-data)
|
|
1035
1111
|
|
|
1036
1112
|
// data_key twin: <sanitized-basename>-<sha1(abspath_utf8)[:12]>. Pure · total · separator-free.
|
|
1037
1113
|
function dataKey(projectAbspath) {
|
|
@@ -1249,17 +1325,44 @@ function installGlobalDataRestore(chosenTarget, force) {
|
|
|
1249
1325
|
else log(" (no snapshot for this project at " + snap + " — nothing restored)");
|
|
1250
1326
|
}
|
|
1251
1327
|
|
|
1328
|
+
// Every "<lockFileName>.reclaim-*" directly under dir aged past staleAfterSeconds — a LEAKED
|
|
1329
|
+
// per-generation reclaim ticket (its own holder crashed between winning it and its own
|
|
1330
|
+
// best-effort cleanup; a live, currently-in-flight ticket is never this old). Returns full
|
|
1331
|
+
// paths, sorted; [] if dir does not exist. Mirror of _installer.py:_aged_reclaim_tickets.
|
|
1332
|
+
function agedReclaimTickets(dir, lockFileName, staleAfterSeconds, nowMs) {
|
|
1333
|
+
if (!fs.existsSync(dir)) return [];
|
|
1334
|
+
const prefix = lockFileName + ".reclaim-";
|
|
1335
|
+
return fs.readdirSync(dir).filter((name) => name.startsWith(prefix)).map((name) => path.join(dir, name))
|
|
1336
|
+
.filter((p) => {
|
|
1337
|
+
try { return (nowMs - fs.statSync(p).mtimeMs) / 1000 > staleAfterSeconds; }
|
|
1338
|
+
catch (_e) { return false; } // vanished mid-sweep — nothing to report
|
|
1339
|
+
}).sort();
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1252
1342
|
// prune-data: reclaim ORPHANED snapshots under <home>/data. An orphan is a <home>/data/<key>
|
|
1253
1343
|
// whose key is owned by NO LIVE registry entry (LIVE = a registered path that still EXISTS on
|
|
1254
1344
|
// disk) — so unregistered AND registered-but-vanished are BOTH orphans (the explicit reclaim;
|
|
1255
1345
|
// DIVERGES from update --global's keep-vanished). Reads the registry FIRST (corrupt throws,
|
|
1256
|
-
// before any removal).
|
|
1346
|
+
// before any removal).
|
|
1347
|
+
//
|
|
1348
|
+
// sweep-orphan-reclaim-tickets: ALSO sweeps LEAKED per-generation reclaim tickets — a
|
|
1349
|
+
// "<LOCK_FILE>.reclaim-*" under home (home-scope) and a "<PROJECT_LOCK_FILE>.reclaim-*" under
|
|
1350
|
+
// every LIVE registered project's own .add/ (project-scope) — each aged past its OWN kind's
|
|
1351
|
+
// existing staleness constant (LOCK_TICKET_STALE_SECONDS / PROJECT_LOCK_TICKET_STALE_SECONDS,
|
|
1352
|
+
// reused verbatim; no new threshold). Reuses the registry already read for the data-orphan
|
|
1353
|
+
// sweep — no new read.
|
|
1354
|
+
//
|
|
1355
|
+
// Returns {orphans, removed, ticketOrphans, ticketsRemoved} — extends the prior {orphans,
|
|
1356
|
+
// removed} object non-breakingly (existing dot-access callers are unaffected). ticketOrphans/
|
|
1357
|
+
// ticketsRemoved are full paths; ticketsRemoved == [] on a dry-run, == ticketOrphans under
|
|
1358
|
+
// force (each unlinked best-effort, matching the existing reclaim code's own swallow-errors
|
|
1359
|
+
// convention). Mirror of _installer.py:_prune_data.
|
|
1257
1360
|
function pruneData(home, force) {
|
|
1258
1361
|
const reg = readRegistry(home); // corrupt -> throw (LOUD, zero removal)
|
|
1259
|
-
const
|
|
1362
|
+
const livePaths = reg.filter((p) => fs.existsSync(p));
|
|
1363
|
+
const live = new Set(livePaths.map(dataKey));
|
|
1260
1364
|
const dataDir = path.join(home, "data");
|
|
1261
|
-
|
|
1262
|
-
const orphans = fs.readdirSync(dataDir).filter((name) => {
|
|
1365
|
+
const orphans = !fs.existsSync(dataDir) ? [] : fs.readdirSync(dataDir).filter((name) => {
|
|
1263
1366
|
try { return fs.statSync(path.join(dataDir, name)).isDirectory() && !live.has(name); }
|
|
1264
1367
|
catch (_e) { return false; }
|
|
1265
1368
|
}).sort();
|
|
@@ -1270,23 +1373,67 @@ function pruneData(home, force) {
|
|
|
1270
1373
|
removed.push(key);
|
|
1271
1374
|
}
|
|
1272
1375
|
}
|
|
1273
|
-
|
|
1376
|
+
|
|
1377
|
+
const now = Date.now();
|
|
1378
|
+
let ticketCandidates = agedReclaimTickets(home, LOCK_FILE, LOCK_TICKET_STALE_SECONDS, now)
|
|
1379
|
+
.map((p) => ({ path: p, staleAfter: LOCK_TICKET_STALE_SECONDS }));
|
|
1380
|
+
for (const project of livePaths) {
|
|
1381
|
+
ticketCandidates = ticketCandidates.concat(
|
|
1382
|
+
agedReclaimTickets(path.join(project, ".add"), PROJECT_LOCK_FILE, PROJECT_LOCK_TICKET_STALE_SECONDS, now)
|
|
1383
|
+
.map((p) => ({ path: p, staleAfter: PROJECT_LOCK_TICKET_STALE_SECONDS }))
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
const ticketOrphans = ticketCandidates.map((c) => c.path);
|
|
1387
|
+
const ticketsRemoved = [];
|
|
1388
|
+
if (force) {
|
|
1389
|
+
for (const { path: ticket, staleAfter } of ticketCandidates) {
|
|
1390
|
+
try {
|
|
1391
|
+
if ((Date.now() - fs.statSync(ticket).mtimeMs) / 1000 <= staleAfter) continue; // no longer stale at unlink time — §5 safety rule
|
|
1392
|
+
fs.unlinkSync(ticket);
|
|
1393
|
+
ticketsRemoved.push(ticket);
|
|
1394
|
+
} catch (_e) { /* already gone — harmless, matches reclaim's own convention */ }
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
return { orphans: orphans, removed: removed, ticketOrphans: ticketOrphans, ticketsRemoved: ticketsRemoved };
|
|
1274
1399
|
}
|
|
1275
1400
|
|
|
1276
1401
|
// prune-data command: dry-run lists orphans (removes nothing); --force deletes. no_global_home /
|
|
1277
1402
|
// registry_corrupt = fail-closed (LOUD, nothing removed). Mirror of pip _installer.prune_data.
|
|
1403
|
+
//
|
|
1404
|
+
// prune-data-update-lock: the registry-read + orphan-computation + removal critical section now
|
|
1405
|
+
// holds the SAME home lock (acquireUpdateLock) `update --global` already holds during its own
|
|
1406
|
+
// reconcile (which refreshes an existing project's <home>/data/<key> snapshot) — so the two can
|
|
1407
|
+
// never interleave. Reuses the existing, proven primitive verbatim (fail-fast, no poll — the
|
|
1408
|
+
// primitive's own fail() call handles a contended lock, no extra catch needed here).
|
|
1409
|
+
//
|
|
1410
|
+
// sweep-orphan-reclaim-tickets: ALSO reports (and, with --force, removes) leaked reclaim
|
|
1411
|
+
// tickets found by pruneData — see its own comment; a separate, additive count from the
|
|
1412
|
+
// data-orphan sweep above.
|
|
1278
1413
|
function cmdPruneData(args) {
|
|
1279
1414
|
const home = resolveGlobalHome(process.env);
|
|
1280
1415
|
if (!fs.existsSync(path.join(home, STAMP_FILE))) {
|
|
1281
1416
|
fail("no global ADD install at " + home + " (.add-version not found) — nothing to prune");
|
|
1282
1417
|
}
|
|
1418
|
+
acquireUpdateLock(home, { timeout: null }, process.env);
|
|
1283
1419
|
let result;
|
|
1284
1420
|
try { result = pruneData(home, args.force); }
|
|
1285
1421
|
catch (_e) { fail("global registry " + registryPath(home) + " is corrupt — fix or delete it; not pruning"); }
|
|
1286
|
-
if (result.orphans.length === 0
|
|
1287
|
-
|
|
1422
|
+
if (result.orphans.length === 0 && result.ticketOrphans.length === 0) {
|
|
1423
|
+
log(" no orphaned snapshots — nothing to prune");
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
if (args.force) {
|
|
1427
|
+
if (result.orphans.length > 0) log(" ✓ " + result.removed.length + " removed");
|
|
1428
|
+
if (result.ticketOrphans.length > 0) log(" ✓ " + result.ticketsRemoved.length + " reclaim ticket(s) removed");
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1288
1431
|
for (const key of result.orphans) log(" orphan: " + key);
|
|
1289
|
-
log(" " + result.orphans.length + " orphan(s); re-run with --force to remove");
|
|
1432
|
+
if (result.orphans.length > 0) log(" " + result.orphans.length + " orphan(s); re-run with --force to remove");
|
|
1433
|
+
for (const ticket of result.ticketOrphans) log(" ticket orphan: " + ticket);
|
|
1434
|
+
if (result.ticketOrphans.length > 0) {
|
|
1435
|
+
log(" " + result.ticketOrphans.length + " reclaim ticket orphan(s); re-run with --force to remove");
|
|
1436
|
+
}
|
|
1290
1437
|
}
|
|
1291
1438
|
|
|
1292
1439
|
// init --global: install the managed layer ONCE to the shared home + register this project,
|
|
@@ -1477,7 +1624,9 @@ function acquireUpdateLock(home, { timeout = null } = {}, env = process.env) {
|
|
|
1477
1624
|
}
|
|
1478
1625
|
try { fs.writeSync(fd, process.pid + " " + new Date().toISOString() + "\n"); }
|
|
1479
1626
|
catch (_e) {} // diagnostics are best-effort — never fail an acquired lock over this
|
|
1627
|
+
const heartbeat = startLockHeartbeat(lockPath, staleAfterMs); // js-reclaim-lock-heartbeat
|
|
1480
1628
|
const release = () => {
|
|
1629
|
+
heartbeat.stop(); // cleared FIRST — never outlives the fd/lockPath it refreshes
|
|
1481
1630
|
try { fs.closeSync(fd); } catch (_e) {}
|
|
1482
1631
|
try { fs.unlinkSync(lockPath); } catch (_e) {}
|
|
1483
1632
|
};
|
|
@@ -1631,7 +1780,9 @@ function acquireProjectLock(addDir, env = process.env) {
|
|
|
1631
1780
|
}
|
|
1632
1781
|
try { fs.writeSync(fd, process.pid + " " + new Date().toISOString() + "\n"); }
|
|
1633
1782
|
catch (_e) {} // diagnostics are best-effort — never fail an acquired lock over this
|
|
1783
|
+
const heartbeat = startLockHeartbeat(lockPath, staleAfterMs); // js-reclaim-lock-heartbeat
|
|
1634
1784
|
const release = () => {
|
|
1785
|
+
heartbeat.stop(); // cleared FIRST — never outlives the fd/lockPath it refreshes
|
|
1635
1786
|
try { fs.closeSync(fd); } catch (_e) {}
|
|
1636
1787
|
try { fs.unlinkSync(lockPath); } catch (_e) {}
|
|
1637
1788
|
if (createdDir) {
|
|
@@ -1734,10 +1885,59 @@ function cmdUpdate(args) {
|
|
|
1734
1885
|
log("ADD updated " + (cur || "(unstamped)") + " -> " + version +
|
|
1735
1886
|
" · managed layer reconciled (" + roll.restored + " restored · " + roll.refreshed +
|
|
1736
1887
|
" refreshed) · your project state untouched.");
|
|
1888
|
+
// crossing nudges — engine-owned follow-ups the updater must NAME, never run (python3
|
|
1889
|
+
// may be absent on this PATH; both commands are idempotent). Twin of _installer.py.
|
|
1890
|
+
if (cur !== version) {
|
|
1891
|
+
log("next: python3 .add/tooling/add.py sync-guidelines # refresh the CLAUDE.md guidance block to this version");
|
|
1892
|
+
}
|
|
1893
|
+
const tasksDir = path.join(addDir, "tasks");
|
|
1894
|
+
let legacyBoard = false;
|
|
1895
|
+
if (fs.existsSync(tasksDir)) {
|
|
1896
|
+
for (const entry of fs.readdirSync(tasksDir, { withFileTypes: true })) {
|
|
1897
|
+
if (entry.isDirectory()
|
|
1898
|
+
&& fs.existsSync(path.join(tasksDir, entry.name, "TASK.md"))
|
|
1899
|
+
&& !fs.existsSync(path.join(tasksDir, entry.name, "PLAN.md"))) {
|
|
1900
|
+
legacyBoard = true;
|
|
1901
|
+
break;
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
if (legacyBoard) {
|
|
1906
|
+
log("next: python3 .add/tooling/add.py migrate # 1.x board detected: TASK.md -> PLAN.md, live + archived");
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// js-reclaim-lock-heartbeat: a test-only entrypoint so the Python subprocess suite can drive
|
|
1911
|
+
// real multi-process contention against acquireUpdateLock/acquireProjectLock without duplicating
|
|
1912
|
+
// their acquire/release logic in the test itself. Intercepted BEFORE the `cmd`/switch dispatch
|
|
1913
|
+
// below, guarded behind an undocumented flag — never listed in --help, never reachable via any
|
|
1914
|
+
// documented public command, so it carries zero surface on the real CLI path.
|
|
1915
|
+
async function cmdInternalAcquireLock(argv) {
|
|
1916
|
+
const kind = argv[0];
|
|
1917
|
+
const targetPath = argv[1];
|
|
1918
|
+
const holdMs = Number(argv[2]);
|
|
1919
|
+
let release;
|
|
1920
|
+
if (kind === "update") {
|
|
1921
|
+
release = acquireUpdateLock(targetPath, { timeout: null }, process.env);
|
|
1922
|
+
} else if (kind === "project") {
|
|
1923
|
+
release = acquireProjectLock(targetPath, process.env);
|
|
1924
|
+
} else {
|
|
1925
|
+
fail("internal_acquire_lock_bad_kind: expected 'update' or 'project', got '" + kind + "'");
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
log("HELD " + Date.now());
|
|
1929
|
+
await sleepAsync(holdMs); // event-loop-yielding — lets the heartbeat's setInterval fire
|
|
1930
|
+
log("RELEASED " + Date.now());
|
|
1931
|
+
release();
|
|
1932
|
+
process.exit(0);
|
|
1737
1933
|
}
|
|
1738
1934
|
|
|
1739
1935
|
async function main() {
|
|
1740
1936
|
const argv = process.argv.slice(2);
|
|
1937
|
+
if (argv[0] === "--internal-acquire-lock") {
|
|
1938
|
+
await cmdInternalAcquireLock(argv.slice(1));
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1741
1941
|
const cmd = argv[0] && !argv[0].startsWith("--") ? argv.shift() : "init";
|
|
1742
1942
|
const args = parseArgs(argv);
|
|
1743
1943
|
switch (cmd) {
|
|
@@ -1753,13 +1953,13 @@ async function main() {
|
|
|
1753
1953
|
case "help":
|
|
1754
1954
|
case "--help":
|
|
1755
1955
|
log("usage: npx @pilotspace/add <init|update|prune-data> [targetDir] [--force] [--check] [--no-skill] [--global] [--yes|--non-interactive]");
|
|
1756
|
-
log(" init install the ADD skill + tooling
|
|
1757
|
-
log(" (--no-skill drops the engine
|
|
1956
|
+
log(" init install the ADD skill + tooling into a project");
|
|
1957
|
+
log(" (--no-skill drops the engine only — used by the Claude Code plugin)");
|
|
1758
1958
|
log(" (--global ALSO installs to a shared home [ADD_HOME|XDG_DATA_HOME/add|~/.add] + registers the project)");
|
|
1759
1959
|
log(" (--global-data implies --global + persists this project's user-data under <home>/data/<key>)");
|
|
1760
1960
|
log(" (--from-global-data rehydrates this project's user-data FROM the shared home on a fresh clone)");
|
|
1761
1961
|
log(" (interactive in a real terminal; --yes / --non-interactive force the plain path)");
|
|
1762
|
-
log(" update re-materialize skill/tooling
|
|
1962
|
+
log(" update re-materialize skill/tooling to this package version (preserves your state)");
|
|
1763
1963
|
log(" (--global refreshes the shared home + propagates to every registered project)");
|
|
1764
1964
|
log(" prune-data remove orphaned per-project snapshots from the shared home (dry-run; --force deletes)");
|
|
1765
1965
|
break;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pilotspace/add",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "ADD (AI-Driven Development).
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "ADD (AI-Driven Development) 2.0. The agent is the hands; ADD is the memory, judgment, and conscience — a minimal, state-tracked skill: one freeze per feature, trust from passing tests, state on disk so context rot never survives a new session.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"add": "bin/cli.js"
|
|
7
7
|
},
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
"!tooling/add_engine/__pycache__",
|
|
25
25
|
"!**/*.pyc",
|
|
26
26
|
"tooling/templates/",
|
|
27
|
-
"docs/",
|
|
28
27
|
"personas-teacher/",
|
|
29
28
|
"THIRD_PARTY_NOTICES.md",
|
|
30
29
|
"README.md",
|
|
@@ -11,3 +11,9 @@ KEEP: the agent-definition domain folders (engineering, security, design, produc
|
|
|
11
11
|
DROP: the upstream `.github/` CI, `scripts/`, other-tool `integrations/`, `CONTRIBUTING*`, `SECURITY.md`, and dotfiles.
|
|
12
12
|
|
|
13
13
|
Content is RAW + verbatim — regenerate with `python3 add-method/scripts/update_teacher.py`. Attribution: see the repo-root `THIRD_PARTY_NOTICES.md` and the retained `LICENSE` in this folder (MIT).
|
|
14
|
+
|
|
15
|
+
## Refresh-drift check (run on every refresh PR)
|
|
16
|
+
|
|
17
|
+
Distilled personas keep `source:` provenance pointing into this snapshot. In the refresh PR
|
|
18
|
+
description, list every `.add/personas/*.md` whose `source:` file(s) changed or moved in the
|
|
19
|
+
diff — those personas are candidates for re-distillation, otherwise their provenance rots silently.
|