@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.
Files changed (113) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/GETTING-STARTED.md +20 -64
  3. package/README.md +128 -56
  4. package/agents/add.md +68 -0
  5. package/bin/cli.js +229 -29
  6. package/package.json +2 -3
  7. package/personas-teacher/VENDOR.md +6 -0
  8. package/skill/add/SKILL.md +100 -134
  9. package/skill/add/adopt.md +28 -0
  10. package/skill/add/beyond.md +36 -0
  11. package/skill/add/deltas.md +25 -2
  12. package/skill/add/design.md +56 -74
  13. package/skill/add/{report-template.md → gate-udd.md} +28 -37
  14. package/skill/add/intake.md +65 -29
  15. package/skill/add/loop.md +35 -23
  16. package/skill/add/phases/build.md +78 -0
  17. package/skill/add/phases/direction.md +280 -0
  18. package/skill/add/phases/verify.md +160 -0
  19. package/skill/add/run.md +13 -30
  20. package/skill/add/terms.md +18 -0
  21. package/tooling/add.py +2520 -2933
  22. package/tooling/add_engine/autonomy.py +2 -37
  23. package/tooling/add_engine/components.py +4 -87
  24. package/tooling/add_engine/constants.py +153 -87
  25. package/tooling/add_engine/guidelines.py +9 -11
  26. package/tooling/add_engine/io_state.py +82 -2
  27. package/tooling/add_engine/predicates.py +90 -2
  28. package/tooling/add_engine/search.py +6 -6
  29. package/tooling/add_engine/taskdoc.py +15 -10
  30. package/tooling/templates/CONVENTIONS.md.tmpl +1 -1
  31. package/tooling/templates/GLOSSARY.md.tmpl +10 -4
  32. package/tooling/templates/MILESTONE.md.tmpl +20 -16
  33. package/tooling/templates/PLAN.md.tmpl +128 -0
  34. package/tooling/templates/PROJECT.md.tmpl +6 -1
  35. package/tooling/templates/PROMPT.persona.md.tmpl +17 -21
  36. package/tooling/templates/personas/_template.md.tmpl +35 -29
  37. package/tooling/templates/personas/build-engineer.md.tmpl +45 -0
  38. package/tooling/templates/personas/data-steward.md.tmpl +45 -0
  39. package/tooling/templates/personas/evidence-verifier.md.tmpl +45 -0
  40. package/tooling/templates/personas/platform-engineer.md.tmpl +45 -0
  41. package/tooling/templates/personas/product-lead.md.tmpl +51 -0
  42. package/tooling/templates/personas/quality-auditor.md.tmpl +47 -0
  43. package/tooling/templates/personas/release-manager.md.tmpl +52 -0
  44. package/tooling/templates/personas/security-gatekeeper.md.tmpl +45 -0
  45. package/tooling/templates/personas/software-architect.md.tmpl +45 -0
  46. package/tooling/templates/personas/stream-orchestrator.md.tmpl +44 -0
  47. package/tooling/templates/personas/technical-writer.md.tmpl +49 -0
  48. package/tooling/templates/personas/ux-experience-lead.md.tmpl +48 -0
  49. package/tooling/templates/specs/SPEC.md.tmpl +18 -0
  50. package/tooling/templates/udd-wireframe.md +1 -1
  51. package/agents/add-advisor.md +0 -31
  52. package/agents/add-build.md +0 -29
  53. package/agents/add-design.md +0 -32
  54. package/agents/add-persona.md +0 -31
  55. package/agents/add-verify.md +0 -34
  56. package/docs/00-introduction.md +0 -46
  57. package/docs/01-principles.md +0 -71
  58. package/docs/02-the-flow.md +0 -113
  59. package/docs/03-step-1-specify.md +0 -123
  60. package/docs/04-step-2-scenarios.md +0 -90
  61. package/docs/05-step-3-contract.md +0 -80
  62. package/docs/06-step-4-tests.md +0 -85
  63. package/docs/07-step-5-build.md +0 -82
  64. package/docs/08-step-6-verify.md +0 -105
  65. package/docs/09-the-loop.md +0 -75
  66. package/docs/10-setup-and-stages.md +0 -118
  67. package/docs/11-governance.md +0 -91
  68. package/docs/12-roles.md +0 -99
  69. package/docs/13-adoption.md +0 -67
  70. package/docs/14-foundation.md +0 -142
  71. package/docs/15-foundations-and-lineage.md +0 -106
  72. package/docs/16-releasing.md +0 -182
  73. package/docs/17-components.md +0 -143
  74. package/docs/18-personas.md +0 -80
  75. package/docs/README.md +0 -77
  76. package/docs/add-competencies.png +0 -0
  77. package/docs/add-flow.png +0 -0
  78. package/docs/add-foundation.png +0 -0
  79. package/docs/add-hierarchy.png +0 -0
  80. package/docs/add-milestone-task-lifecycle.png +0 -0
  81. package/docs/add-task-growth-wheel.png +0 -0
  82. package/docs/appendix-a-templates.md +0 -88
  83. package/docs/appendix-b-prompts.md +0 -154
  84. package/docs/appendix-c-glossary.md +0 -176
  85. package/docs/appendix-d-worked-example.md +0 -231
  86. package/docs/appendix-e-checklists.md +0 -92
  87. package/docs/appendix-f-requirements-matrix.md +0 -171
  88. package/docs/appendix-g-references.md +0 -106
  89. package/skill/add/advisor.md +0 -80
  90. package/skill/add/compact-foundation.md +0 -40
  91. package/skill/add/components.md +0 -52
  92. package/skill/add/confidence.md +0 -32
  93. package/skill/add/fold.md +0 -63
  94. package/skill/add/graduate.md +0 -39
  95. package/skill/add/phases/0-ground.md +0 -64
  96. package/skill/add/phases/0-setup.md +0 -103
  97. package/skill/add/phases/1-specify.md +0 -61
  98. package/skill/add/phases/2-scenarios.md +0 -48
  99. package/skill/add/phases/3-contract.md +0 -60
  100. package/skill/add/phases/4-tests.md +0 -59
  101. package/skill/add/phases/5-build.md +0 -62
  102. package/skill/add/phases/6-verify.md +0 -69
  103. package/skill/add/phases/7-observe.md +0 -47
  104. package/skill/add/phases/fast-lane.md +0 -31
  105. package/skill/add/release.md +0 -61
  106. package/skill/add/scope.md +0 -72
  107. package/skill/add/sensitivity.md +0 -51
  108. package/skill/add/setup-review.md +0 -48
  109. package/skill/add/soul.md +0 -58
  110. package/skill/add/streams.md +0 -268
  111. package/tooling/add_engine/release.py +0 -86
  112. package/tooling/templates/TASK.fast.md.tmpl +0 -77
  113. 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 + book into a target project:
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 + book ONLY, not the skill. The Claude Code plugin
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/ + .add/docs/ into the project without a duplicate .claude/skills/add.
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 + book are installed.\n" +
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). Fail-soft:
414
- // any draw error is swallowed so a banner can never abort the install. No color is emitted
415
- // (default accent: none); the glyphs / tagline / accent are a SWAPPABLE content slot.
416
- const BRAND_LOOP = ["Specify", "Scenarios", "Contract", "Tests", "Build", "Verify", "Observe"];
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 + book here?" });
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 + book are" : "The `add` skill + tooling are") +
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", "docs": "docs", "personas-teacher": "personas" };
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 = cleanReplaceTree(path.join(srcRoot, sub), path.join(target, ...destParts), stripTests);
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+book+skill) updated for all projects ----
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 + docs at the SAME relative paths
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). Returns {orphans, removed}. Mirror of _installer.py:_prune_data.
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 live = new Set(reg.filter((p) => fs.existsSync(p)).map(dataKey));
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
- if (!fs.existsSync(dataDir)) return { orphans: [], removed: [] };
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
- return { orphans: orphans, removed: removed };
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) { log(" no orphaned snapshots — nothing to prune"); return; }
1287
- if (args.force) { log(" " + result.removed.length + " removed"); return; }
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 + book into a project");
1757
- log(" (--no-skill drops the engine + book only — used by the Claude Code plugin)");
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/docs to this package version (preserves your state)");
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": "1.16.1",
4
- "description": "ADD (AI-Driven Development). One skill. Eight steps. Five disciplines. Every feature ships through the loop — a minimal, state-tracked Claude Code skill that ships the AIDD book as its trust layer.",
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.