@pilotspace/add 1.12.0 → 1.14.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 CHANGED
@@ -6,6 +6,96 @@ All notable changes to the ADD method (`@pilotspace/add` on npm,
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.14.0] — 2026-06-29
10
+
11
+ Two milestones round out lanes that shipped partially in earlier releases: the
12
+ **global-home / installer** lane gets its missing inverse + safety, and the
13
+ **component** pillar closes its remaining gaps and hardens its cross-repo edges.
14
+ Installer- and engine-pin-neutral — the ADD engine is byte-identical (ENGINE_MD5
15
+ unchanged); all changes live in the installer twins and the component validator.
16
+ Backward-compatible throughout.
17
+
18
+ ### Added (installer-polish — complete the global lane)
19
+ - **Restore the global home.** `init --from-global-data` (and an `init` that detects a
20
+ matching `<home>/data/<key>`) rehydrates a project's user-data from the global home on
21
+ a fresh clone — the non-destructive inverse of the one-way backup. Fill-gaps by default;
22
+ `--force` writes a `<name>.bak` sidecar before overwriting.
23
+ - **`prune-data` orphan cleanup.** A new `prune-data` command removes home snapshots with
24
+ no live registry owner — dry-run by default, `--force` to delete. Both installer twins
25
+ (pip + npm) carry the behavior, byte-for-byte.
26
+ - **`update --global` made safe.** An O_EXCL home lock (`<home>/.update.lock`) serializes
27
+ concurrent runs **cross-twin** (a pip-held lock blocks an npm run and vice-versa), and
28
+ every registered path is validated before any write — a relative/traversal entry aborts
29
+ the whole run loud (`unsafe_registry_path`), a directory without `.add/` is dropped.
30
+ - **Reconcile roll-up.** Every reconcile now reports a file-level `N restored · M refreshed`
31
+ summary, so a partially-gutted-but-present managed tree's heal is finally visible (it
32
+ healed silently before). Pure observation — copy semantics are unchanged.
33
+
34
+ ### Added (component-polish — close the pillar gaps + harden the edges)
35
+ - **`add.py components` reader + validator.** A `components.toml` schema-lint surfaces
36
+ `component_unknown_key` / `component_type_mismatch` / `component_unknown_table` — all
37
+ measure-not-block warnings at `check`.
38
+ - **Federation hardening.** `federate pull` path-confines the manifest `source` to a
39
+ sibling-repo allowlist with a fail-closed HARD-STOP (`federation_source_escapes`) before
40
+ any read; a stale leftover contract snapshot that no longer admits a consumer is surfaced
41
+ (`producer_contract_stale`, never red).
42
+ - **Registry fill + a worked example.** The component registry round-trips completely, and
43
+ a full worked example threads the component flow end-to-end in the book.
44
+
45
+ ### Changed
46
+ - Five version sources bump in lockstep to **1.14.0** (`package.json`, `package-lock.json`
47
+ ×2, `pyproject.toml`, `.claude-plugin/plugin.json`, `add_method.__version__`).
48
+
49
+ ## [1.13.0] — 2026-06-28
50
+
51
+ Two method milestones make ADD's loop more **self-auditing** and more **honest**:
52
+ the observe step now harvests a decision record automatically, and every flow gate
53
+ is either mechanically enforced or plainly disclosed where it is not. Plus the npm
54
+ release pipeline moves to tokenless **Trusted Publishing**. Backward-compatible
55
+ throughout; nothing retro-changes an existing task or milestone record.
56
+
57
+ ### Added (adr-at-observe — a decision record, harvested not hand-written)
58
+ - **`### Decisions (ADR)` harvested at the gate.** On `add.py gate PASS`, the engine
59
+ harvests a §7 Architecture-Decision-Record block from §1/§3/§5/§6 — one
60
+ actor-tagged line per decision (`[AI] specify · [human] freeze · [AI] build ·
61
+ [human] verify`), refilled only while the placeholder stands (never hand-edited).
62
+ - **`add.py audit` checks the ADR at done.** A done task whose §7 ADR block is unfilled
63
+ is surfaced — presence-only, never a semantic judgment of the decisions themselves.
64
+ - **Strategy write-back.** The §6 GATE RECORD is stamped with the git actor + date, and
65
+ the §5 "Strategy actually used" line is harvested into the ADR as the `[AI] build` row.
66
+
67
+ ### Added (flow-honesty — every gate engine-true or honestly disclosed)
68
+ - **Two structural holes became real, forceable gates.** A **universal freeze gate**
69
+ (`contract_not_frozen` fires for every task at tests→build, with a recorded
70
+ `--skip-freeze` escape) and a **delta-drain release floor**
71
+ (`release_build_in_flight`, `--force`-able but loud) — the two places the method
72
+ promised enforcement and now delivers it.
73
+ - **Four honor-system edges became plain disclosures + measure-not-block lints.**
74
+ `add.py audit` now surfaces `shallow_deep_check`, `risk_unset`, and
75
+ `refute_unrecorded` (presence-only, never blocking); the guides + book state plainly
76
+ that a *missed* security finding is invisible to the engine (a human spot-audit is
77
+ the only backstop under `auto`); reject-code names read honestly
78
+ (`release_tests_red`→`release_build_in_flight`).
79
+ - **The earned-green refute-read is now a recorded verdict.** Under `auto`, the §6
80
+ `### Refute-read verdict` block records `EARNED | NOT-EARNED`; the engine measures it
81
+ is filled but never spawns the read — the resolver still does the judging.
82
+ - **Stale guidance synced to the shipped engine.** The 5-build "scope gate deferred"
83
+ note is gone (the scope gate enforces today); the auto-PASS precondition list is now
84
+ identical across `run.md`, `6-verify.md`, and the book.
85
+
86
+ ### Added (loose tasks)
87
+ - **`/add --todo` flag** (`skill-todo-flag`) — capture/list/close backlog todos.
88
+ - **§5 Strategy fed into spawns** (`build-strategy-solutions` · `streams-strategy-pull`)
89
+ — the planned build order + known-problem fixes flow into subagent prompts.
90
+ - **`<strategy>` softened** (`strategy-soft-not-hard`) — §5 is the *preferred* plan; the
91
+ builder may self-improve during build and reports the strategy actually used for audit.
92
+
93
+ ### Changed (release infrastructure)
94
+ - **npm Trusted Publishing (OIDC).** `publish.yml` drops the stored npm token (no more
95
+ ~90-day rotation), publishes via OIDC (`id-token: write`, Node 24 + npm ≥ 11.5.1),
96
+ and generates provenance automatically (`--provenance` no longer needed). A guard test
97
+ locks the tokenless shape. PyPI already used OIDC.
98
+
9
99
  ## [1.12.0] — 2026-06-26
10
100
 
11
101
  Multi-milestone polish release — the intake/roadmap front grows a real **queue**,
package/bin/cli.js CHANGED
@@ -39,7 +39,8 @@ function parseArgs(argv) {
39
39
  // defaults the stage and infers the name from the folder, so the manual-init
40
40
  // hint only echoes flags the user actually chose (shortest true command).
41
41
  const args = { _: [], force: false, check: false, noSkill: false, stage: null, name: null,
42
- yes: false, nonInteractive: false, global: false, globalData: false, ruleFile: false };
42
+ yes: false, nonInteractive: false, global: false, globalData: false,
43
+ fromGlobalData: false, ruleFile: false };
43
44
  for (let i = 0; i < argv.length; i++) {
44
45
  const a = argv[i];
45
46
  if (a === "--force") args.force = true;
@@ -50,6 +51,9 @@ function parseArgs(argv) {
50
51
  // --global-data: (implies --global) ALSO persist this project's user-data under
51
52
  // <home>/data/<key> keyed by path (opt-in, one-way snapshot).
52
53
  else if (a === "--global-data") args.globalData = true;
54
+ // --from-global-data: the INVERSE — rehydrate this project's user-data FROM the shared
55
+ // home on a fresh clone (non-destructive fill-gaps; --force overwrites with a .bak).
56
+ else if (a === "--from-global-data") args.fromGlobalData = true;
53
57
  // --yes / --non-interactive: skip all prompts, take defaults — the explicit
54
58
  // non-interactive selector the interactive() gate honors (CI/pipes do this too).
55
59
  else if (a === "--yes" || a === "-y") args.yes = true;
@@ -694,12 +698,23 @@ async function cmdInit(args) {
694
698
  }
695
699
  }
696
700
  if (args.globalData) args.global = true; // --global-data implies --global (need a home)
701
+ // OPT-IN restore: the home MUST already exist — no_global_home is a HARD fail, checked FAST
702
+ // here so nothing lands in the target on a missing home (the restore itself runs after the drop).
703
+ if (args.fromGlobalData) {
704
+ const home = resolveGlobalHome(process.env);
705
+ if (!fs.existsSync(path.join(home, STAMP_FILE))) {
706
+ fail("no global ADD install at " + home + " (.add-version not found) — nothing to restore " +
707
+ "from; run `init --global-data` on a source checkout first");
708
+ }
709
+ }
697
710
  // OPT-IN global home, BEFORE the per-project drop (fail-closed if the home is unwritable
698
711
  // or its registry is corrupt — the package + the self-contained default stay usable).
699
712
  if (args.global) installGlobal(args, chosenTarget);
700
713
  dropFiles(args, chosenTarget, profile, intent);
701
714
  // OPT-IN data persist, AFTER the drop (one-way snapshot of existing user-data).
702
715
  if (args.globalData) installGlobalData(chosenTarget);
716
+ // OPT-IN restore (consume), AFTER the drop: rehydrate user-data from the home into this clone.
717
+ if (args.fromGlobalData) installGlobalDataRestore(chosenTarget, args.force);
703
718
  }
704
719
 
705
720
  // --- update: re-materialize the managed layer without a re-install -----------
@@ -712,6 +727,7 @@ const MANAGED = [
712
727
  ["docs", [".add", "docs"], false],
713
728
  ];
714
729
  const STAMP_FILE = ".add-version";
730
+ const LOCK_FILE = ".update.lock"; // the `update --global` home lock (never user-data)
715
731
 
716
732
  function pkgVersion() {
717
733
  try { return require(path.join(PKG_ROOT, "package.json")).version; }
@@ -732,8 +748,30 @@ function writeStamp(addDir, version, channel) {
732
748
  );
733
749
  }
734
750
 
751
+ // The set of FILE paths (recursive leaves) under root, RELATIVE to root. ∅ if absent.
752
+ // Directories are not counted — only files (the "manifest" the roll-up measures).
753
+ function treeFiles(root) {
754
+ const out = new Set();
755
+ if (!fs.existsSync(root)) return out;
756
+ const walk = (dir) => {
757
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
758
+ const full = path.join(dir, entry.name);
759
+ if (entry.isDirectory()) walk(full);
760
+ else out.add(path.relative(root, full));
761
+ }
762
+ };
763
+ walk(root);
764
+ return out;
765
+ }
766
+
767
+ // Returns a file-level roll-up of the heal: { restored, refreshed } where `restored` = a
768
+ // file in the FINAL tree whose relative path was ABSENT before the wipe (fresh or
769
+ // partially-gutted trees heal these), `refreshed` = a final file that was PRESENT before
770
+ // (re-materialized). Orphans (present before, gone after) are swept, counted as neither.
771
+ // Pure observation — copy semantics unchanged. Mirror of _installer.py:_clean_replace.
735
772
  function cleanReplaceTree(src, dest, stripTests) {
736
773
  if (!fs.existsSync(src)) fail("missing packaged source: " + src);
774
+ const before = treeFiles(dest); // snapshot BEFORE the wipe, or it's always ∅
737
775
  fs.mkdirSync(path.dirname(dest), { recursive: true });
738
776
  if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
739
777
  fs.cpSync(src, dest, { recursive: true });
@@ -743,6 +781,9 @@ function cleanReplaceTree(src, dest, stripTests) {
743
781
  if (/^test_.*\.py$/.test(entry)) fs.rmSync(path.join(dest, entry), { force: true });
744
782
  }
745
783
  }
784
+ let restored = 0, refreshed = 0;
785
+ for (const f of treeFiles(dest)) (before.has(f) ? refreshed++ : restored++);
786
+ return { restored: restored, refreshed: refreshed };
746
787
  }
747
788
 
748
789
  const TREE_LABEL = { "skill/add": "skill", "tooling": "tooling", "docs": "docs" };
@@ -771,8 +812,11 @@ function reconcile(args, target, srcRoot) {
771
812
  }
772
813
  }
773
814
  const status = managedStatus(target);
815
+ let restored = 0, refreshed = 0;
774
816
  for (const [sub, destParts, stripTests] of trees) {
775
- cleanReplaceTree(path.join(srcRoot, sub), path.join(target, ...destParts), stripTests);
817
+ const roll = cleanReplaceTree(path.join(srcRoot, sub), path.join(target, ...destParts), stripTests);
818
+ restored += roll.restored;
819
+ refreshed += roll.refreshed;
776
820
  const dest = destParts.join("/");
777
821
  if (status[sub] === "missing") {
778
822
  log(" ✓ restored " + TREE_LABEL[sub].padEnd(8) + "-> " + dest + " (was missing)");
@@ -780,7 +824,8 @@ function reconcile(args, target, srcRoot) {
780
824
  log(" ✓ refreshed " + TREE_LABEL[sub].padEnd(8) + "-> " + dest);
781
825
  }
782
826
  }
783
- return status;
827
+ log(" → " + restored + " restored · " + refreshed + " refreshed");
828
+ return { restored: restored, refreshed: refreshed, trees: status };
784
829
  }
785
830
 
786
831
  // --- global home: an OPT-IN shared install (engine+book+skill) updated for all projects ----
@@ -851,7 +896,7 @@ function reconcileGlobal(home, claudeDir, noSkill) {
851
896
  // --- global DATA: an OPT-IN per-project user-data snapshot under <home>/data/<key> ----------
852
897
  // Strictly additive; copies ONLY user-data (managed trees + transient excluded), clean-replaced,
853
898
  // one-way (project->home). Mirror of _installer.py (identical key + include/exclude rule).
854
- const DATA_EXCLUDE = ["tooling", "docs", ".update-cache", STAMP_FILE]; // managed trees + meta
899
+ const DATA_EXCLUDE = ["tooling", "docs", ".update-cache", STAMP_FILE, LOCK_FILE]; // managed trees + meta + lock
855
900
 
856
901
  // data_key twin: <sanitized-basename>-<sha1(abspath_utf8)[:12]>. Pure · total · separator-free.
857
902
  function dataKey(projectAbspath) {
@@ -902,6 +947,95 @@ function installGlobalData(chosenTarget) {
902
947
  else log(" (no project data to persist yet — run /add to create one, then re-run --global-data)");
903
948
  }
904
949
 
950
+ function isSymlink(p) {
951
+ try { return fs.lstatSync(p).isSymbolicLink(); } catch (_e) { return false; }
952
+ }
953
+
954
+ // Restore USER-DATA from <home>/data/<key> into <project>/.add — the NON-DESTRUCTIVE inverse of
955
+ // persistData. FILL-GAPS by default (write only ABSENT entries); force overwrites a present entry,
956
+ // writing a <name>.bak FIRST. Copies only isUserData entries; DEREFERENCES symlinks to content
957
+ // (cpSync dereference). true if >=1 restored, false if nothing to restore. Throws on an unwritable
958
+ // dest -> restore_failed. Mirror of _installer.py:_restore_data (identical key + fill-gaps rule).
959
+ function restoreData(home, projectAbspath, force) {
960
+ const src = path.join(home, "data", dataKey(projectAbspath));
961
+ if (!fs.existsSync(src)) return false;
962
+ const entries = fs.readdirSync(src).filter(isUserData).sort();
963
+ if (entries.length === 0) return false;
964
+ const addDir = path.join(projectAbspath, ".add");
965
+ fs.mkdirSync(addDir, { recursive: true });
966
+ let restored = false;
967
+ for (const e of entries) {
968
+ const dest = path.join(addDir, e);
969
+ if (fs.existsSync(dest) || isSymlink(dest)) {
970
+ if (!force) continue; // fill-gaps: never clobber a present entry
971
+ const bak = dest + ".bak";
972
+ if (fs.existsSync(bak) || isSymlink(bak)) fs.rmSync(bak, { recursive: true, force: true });
973
+ fs.renameSync(dest, bak); // back up the original BEFORE replacing
974
+ }
975
+ fs.cpSync(path.join(src, e), dest, { recursive: true, dereference: true }); // deref symlinks
976
+ restored = true;
977
+ }
978
+ return restored;
979
+ }
980
+
981
+ // init --from-global-data: rehydrate user-data after the per-project drop. Resolves the SAME
982
+ // realpath the snapshot key uses. The home is verified BEFORE the drop (no_global_home fails fast,
983
+ // in cmdInit). An unwritable dest -> restore_failed; a missing snapshot is an honest skip.
984
+ function installGlobalDataRestore(chosenTarget, force) {
985
+ const home = resolveGlobalHome(process.env);
986
+ let resolved = chosenTarget;
987
+ try { resolved = fs.realpathSync(chosenTarget); } catch (_e) { /* fall back to the abspath */ }
988
+ const snap = path.join(home, "data", dataKey(resolved));
989
+ let restored;
990
+ try { restored = restoreData(home, resolved, force); }
991
+ catch (e) {
992
+ fail("restore_failed: cannot write restored data into " + path.join(resolved, ".add") +
993
+ " — " + (e && e.message ? e.message : e));
994
+ }
995
+ if (restored) log(" ✓ restored data <- " + snap);
996
+ else log(" (no snapshot for this project at " + snap + " — nothing restored)");
997
+ }
998
+
999
+ // prune-data: reclaim ORPHANED snapshots under <home>/data. An orphan is a <home>/data/<key>
1000
+ // whose key is owned by NO LIVE registry entry (LIVE = a registered path that still EXISTS on
1001
+ // disk) — so unregistered AND registered-but-vanished are BOTH orphans (the explicit reclaim;
1002
+ // DIVERGES from update --global's keep-vanished). Reads the registry FIRST (corrupt throws,
1003
+ // before any removal). Returns {orphans, removed}. Mirror of _installer.py:_prune_data.
1004
+ function pruneData(home, force) {
1005
+ const reg = readRegistry(home); // corrupt -> throw (LOUD, zero removal)
1006
+ const live = new Set(reg.filter((p) => fs.existsSync(p)).map(dataKey));
1007
+ const dataDir = path.join(home, "data");
1008
+ if (!fs.existsSync(dataDir)) return { orphans: [], removed: [] };
1009
+ const orphans = fs.readdirSync(dataDir).filter((name) => {
1010
+ try { return fs.statSync(path.join(dataDir, name)).isDirectory() && !live.has(name); }
1011
+ catch (_e) { return false; }
1012
+ }).sort();
1013
+ const removed = [];
1014
+ if (force) {
1015
+ for (const key of orphans) {
1016
+ fs.rmSync(path.join(dataDir, key), { recursive: true, force: true });
1017
+ removed.push(key);
1018
+ }
1019
+ }
1020
+ return { orphans: orphans, removed: removed };
1021
+ }
1022
+
1023
+ // prune-data command: dry-run lists orphans (removes nothing); --force deletes. no_global_home /
1024
+ // registry_corrupt = fail-closed (LOUD, nothing removed). Mirror of pip _installer.prune_data.
1025
+ function cmdPruneData(args) {
1026
+ const home = resolveGlobalHome(process.env);
1027
+ if (!fs.existsSync(path.join(home, STAMP_FILE))) {
1028
+ fail("no global ADD install at " + home + " (.add-version not found) — nothing to prune");
1029
+ }
1030
+ let result;
1031
+ try { result = pruneData(home, args.force); }
1032
+ catch (_e) { fail("global registry " + registryPath(home) + " is corrupt — fix or delete it; not pruning"); }
1033
+ if (result.orphans.length === 0) { log(" no orphaned snapshots — nothing to prune"); return; }
1034
+ if (args.force) { log(" ✓ " + result.removed.length + " removed"); return; }
1035
+ for (const key of result.orphans) log(" orphan: " + key);
1036
+ log(" " + result.orphans.length + " orphan(s); re-run with --force to remove");
1037
+ }
1038
+
905
1039
  // init --global: install the managed layer ONCE to the shared home + register this project,
906
1040
  // fail-closed BEFORE the per-project drop. Returns the resolved target for the normal drop.
907
1041
  function installGlobal(args, chosenTarget) {
@@ -924,34 +1058,82 @@ function installGlobal(args, chosenTarget) {
924
1058
 
925
1059
  // update --global: refresh the home mirror + skill, then propagate to every registered+existing
926
1060
  // project via reconcile(.., home); prune vanished projects (warn) + rewrite the registry atomically.
1061
+ // True iff path.normalize(p) is an EXISTING ADD project (a dir containing .add/). The is-.add/
1062
+ // backstop: a managed-file reconcile NEVER lands in a dir without a .add/ marker. Absoluteness is
1063
+ // the SEPARATE LOUD gate in cmdUpdateGlobal (a relative path is the traversal vector). pip twin:
1064
+ // _installer.py:_valid_registry_path.
1065
+ function validRegistryPath(p) {
1066
+ const np = path.normalize(String(p));
1067
+ try { return fs.statSync(np).isDirectory() && fs.statSync(path.join(np, ".add")).isDirectory(); }
1068
+ catch (_e) { return false; }
1069
+ }
1070
+
1071
+ // Serialize `update --global` with an EXCLUSIVE lockfile (O_CREAT|O_EXCL via the "wx" flag).
1072
+ // Already held -> update_in_progress (fail-fast). Released on process exit (normal completion OR
1073
+ // fail()'s process.exit), so it never outlives the run; a hard crash may leave a stale lock to
1074
+ // remove by hand. pip twin: _installer.py:_update_lock (the SAME O_EXCL lockfile, cross-compatible).
1075
+ function acquireUpdateLock(home) {
1076
+ fs.mkdirSync(home, { recursive: true });
1077
+ const lockPath = path.join(home, LOCK_FILE);
1078
+ let fd;
1079
+ try { fd = fs.openSync(lockPath, "wx"); }
1080
+ catch (e) {
1081
+ if (e && e.code === "EEXIST") {
1082
+ fail("update_in_progress: another `update --global` is already running — retry shortly " +
1083
+ "(remove " + lockPath + " if it is stale)");
1084
+ }
1085
+ throw e;
1086
+ }
1087
+ const release = () => {
1088
+ try { fs.closeSync(fd); } catch (_e) {}
1089
+ try { fs.unlinkSync(lockPath); } catch (_e) {}
1090
+ };
1091
+ process.on("exit", release); // covers normal completion AND fail()'s process.exit
1092
+ return release;
1093
+ }
1094
+
927
1095
  function cmdUpdateGlobal(args) {
928
1096
  const home = resolveGlobalHome(process.env);
929
1097
  const claudeDir = claudeSkillsDir(process.env);
930
1098
  if (!fs.existsSync(path.join(home, STAMP_FILE))) {
931
- fail("no global ADD install at " + home + " (.add-version not found) — run `init --global` first");
1099
+ fail("no_global_home: no global ADD install at " + home + " (.add-version not found) — run `init --global` first");
932
1100
  }
1101
+ acquireUpdateLock(home); // exclusive; EEXIST -> update_in_progress; released on process exit
933
1102
  // Read the registry BEFORE refreshing the home — a corrupt registry fails closed with ZERO
934
1103
  // writes (never a silent empty-list no-op), leaving the file for the user to fix or delete.
935
1104
  let reg;
936
1105
  try { reg = readRegistry(home); }
937
- catch (_e) { fail("global registry " + registryPath(home) + " is corrupt — fix or delete it; not propagating"); }
1106
+ catch (_e) { fail("registry_corrupt: global registry " + registryPath(home) + " is corrupt — fix or delete it; not propagating"); }
1107
+ // PRE-SCAN (security): a NON-ABSOLUTE entry is the traversal vector -> abort with zero mutations.
1108
+ for (const p of reg) {
1109
+ if (!path.isAbsolute(p)) {
1110
+ fail("unsafe_registry_path: registered project '" + p + "' is not an absolute path — fix or remove it in " + registryPath(home) + "; not propagating");
1111
+ }
1112
+ }
938
1113
  try { reconcileGlobal(home, claudeDir, args.noSkill); }
939
1114
  catch (e) { fail("cannot write global home " + home + " — " + (e && e.message ? e.message : e)); }
940
1115
  const version = pkgVersion();
941
1116
  writeStamp(home, version, "global");
942
1117
  const kept = [];
943
- let pruned = 0;
1118
+ let pruned = 0, dropped = 0;
944
1119
  for (const p of reg) {
945
- if (!fs.existsSync(p)) { log(" ⚠ registered project " + p + " not found — pruning"); pruned++; continue; }
946
- reconcile(args, p, home); // standard MANAGED map, sourced from the home mirror
1120
+ const np = path.normalize(p); // absolute (pre-scan) -> lexically normalized
1121
+ if (!fs.existsSync(np)) { log(" ⚠ registered project " + np + " not found — pruning"); pruned++; continue; }
1122
+ if (!validRegistryPath(np)) { // exists but no .add/ -> NEVER reconcile managed files into it
1123
+ log(" ⚠ registered path " + np + " is not an ADD project (no .add/) — dropping"); dropped++; continue;
1124
+ }
1125
+ reconcile(args, np, home); // standard MANAGED map, sourced from the home mirror
947
1126
  // re-persist an opted-in project (one that already has a snapshot); a vanished
948
1127
  // project's snapshot is KEPT above (the backup outlives the dir).
949
- if (fs.existsSync(path.join(home, "data", dataKey(p)))) persistData(home, p);
950
- kept.push(p);
1128
+ if (fs.existsSync(path.join(home, "data", dataKey(np)))) persistData(home, np);
1129
+ kept.push(np); // store the NORMALIZED path (heals a bent legit entry)
951
1130
  }
952
1131
  writeRegistry(home, kept);
1132
+ const bits = [];
1133
+ if (pruned) bits.push(pruned + " pruned");
1134
+ if (dropped) bits.push(dropped + " dropped");
953
1135
  log("ADD " + version + " · global home + " + kept.length + " project(s) reconciled" +
954
- (pruned ? " (" + pruned + " pruned)" : "") + ".");
1136
+ (bits.length ? " (" + bits.join(", ") + ")" : "") + ".");
955
1137
  }
956
1138
 
957
1139
  function cmdUpdate(args) {
@@ -984,12 +1166,13 @@ function cmdUpdate(args) {
984
1166
  if (fs.existsSync(stateFile)) {
985
1167
  fs.copyFileSync(stateFile, path.join(addDir, "pre-update-state.bak.json"));
986
1168
  }
987
- reconcile(args, target);
1169
+ const roll = reconcile(args, target);
988
1170
  seedSoulMd(target); // pip parity: re-seed a missing user-owned SOUL.md (never clobber)
989
1171
  seedGitignore(target); // pip parity: seed/append-if-absent the engine-transient ignore lines
990
1172
  writeStamp(addDir, version);
991
1173
  log("ADD updated " + (cur || "(unstamped)") + " -> " + version +
992
- " · managed layer reconciled · your project state untouched.");
1174
+ " · managed layer reconciled (" + roll.restored + " restored · " + roll.refreshed +
1175
+ " refreshed) · your project state untouched.");
993
1176
  }
994
1177
 
995
1178
  async function main() {
@@ -1003,16 +1186,21 @@ async function main() {
1003
1186
  case "update":
1004
1187
  cmdUpdate(args);
1005
1188
  break;
1189
+ case "prune-data":
1190
+ cmdPruneData(args);
1191
+ break;
1006
1192
  case "help":
1007
1193
  case "--help":
1008
- log("usage: npx @pilotspace/add <init|update> [targetDir] [--force] [--check] [--no-skill] [--global] [--yes|--non-interactive]");
1194
+ log("usage: npx @pilotspace/add <init|update|prune-data> [targetDir] [--force] [--check] [--no-skill] [--global] [--yes|--non-interactive]");
1009
1195
  log(" init install the ADD skill + tooling + book into a project");
1010
1196
  log(" (--no-skill drops the engine + book only — used by the Claude Code plugin)");
1011
1197
  log(" (--global ALSO installs to a shared home [ADD_HOME|XDG_DATA_HOME/add|~/.add] + registers the project)");
1012
1198
  log(" (--global-data implies --global + persists this project's user-data under <home>/data/<key>)");
1199
+ log(" (--from-global-data rehydrates this project's user-data FROM the shared home on a fresh clone)");
1013
1200
  log(" (interactive in a real terminal; --yes / --non-interactive force the plain path)");
1014
1201
  log(" update re-materialize skill/tooling/docs to this package version (preserves your state)");
1015
1202
  log(" (--global refreshes the shared home + propagates to every registered project)");
1203
+ log(" prune-data remove orphaned per-project snapshots from the shared home (dry-run; --force deletes)");
1016
1204
  break;
1017
1205
  default:
1018
1206
  fail("unknown command '" + cmd + "'. Try: npx @pilotspace/add init");
@@ -3,7 +3,7 @@
3
3
  [← 02 The flow](./02-the-flow.md) · [Contents](./README.md) · Next: [04 Step 2 Scenarios →](./04-step-2-scenarios.md)
4
4
 
5
5
  > **Purpose:** state, in plain language, what the feature must do and what it must reject, with no ambiguity left for the AI to resolve by guessing.
6
- > **Produces:** `SPEC.md` for the feature.
6
+ > **Produces:** the **§1 SPECIFY** block of the task's `TASK.md` (one file per task — what this chapter is the *why* behind; historically a separate `SPEC.md`).
7
7
  > **How it works — co-specification:** AI and human **brainstorm the shape together**; the AI drafts; the **human validates, with the AI's advice.** The decisive advice is a *lowest-confidence flag* — the AI names the one or two things most likely to be wrong, so the human's attention lands where it matters. The human owns the decision; the AI owns surfacing what it does not yet know.
8
8
 
9
9
  ---
@@ -3,7 +3,7 @@
3
3
  [← 03 Step 1 Specify](./03-step-1-specify.md) · [Contents](./README.md) · Next: [05 Step 3 Contract →](./05-step-3-contract.md)
4
4
 
5
5
  > **Purpose:** rewrite each rule from the spec as a concrete, pass-or-fail scenario.
6
- > **Produces:** `features/<name>.feature`.
6
+ > **Produces:** the **§2 SCENARIOS** block of the task's `TASK.md` (one file per task — what this chapter is the *why* behind; historically a separate `features/<name>.feature`).
7
7
  > **Person's job:** decide what "correct" looks like in concrete situations. **AI's job:** draft the scenarios.
8
8
 
9
9
  > **Part of the specification bundle (v7).** In the default flow these scenarios are drafted by the AI alongside the spec, contract, and failing tests as **one bundle**, approved by a person **once** (the one approval), at the contract freeze — not signed off step by step. This chapter is how to get the scenarios *right*; [05 Contract](./05-step-3-contract.md) is where the bundle is frozen. See [11 Governance](./11-governance.md).
@@ -18,7 +18,7 @@ This needs care, because it is easy to misread. "Not by inspection" does not mea
18
18
 
19
19
  Verify can be resolved two ways, set per task by the `autonomy:` header (see [governance](./11-governance.md) and the autonomy level):
20
20
 
21
- - **Auto (the default).** When `autonomy: auto`, the run resolves the gate on **evidence** rather than waiting for a person — but only when *all* of these hold: every test green, coverage not decreased, no test weakened and no contract edited, the convergence loops dry, and **no residue** (security, concurrency, or architecture). It records `PASS` as *auto-resolved*, naming the run as the accountable owner — an explicit pass, not a skip. This is principle 7: a gate may be resolved by evidence when that evidence is sufficient and the result is logged.
21
+ - **Auto (the default).** When `autonomy: auto`, the run resolves the gate on **evidence** rather than waiting for a person — but only when *all* of these hold — the **same canonical list run.md owns**: every test green, coverage not decreased, no test weakened and no contract edited, the convergence loops dry, the completeness-critic clean, the deep check filled, the earned-green refute-read verdict recorded (the §6 `### Refute-read verdict` field, under auto), and **no residue** (security, concurrency, or architecture). It records `PASS` as *auto-resolved*, naming the run as the accountable owner — an explicit pass, not a skip. This is principle 7: a gate may be resolved by evidence when that evidence is sufficient and the result is logged.
22
22
  - **Human.** When `autonomy: conservative`, or whenever the run finds residue it cannot judge, the gate stops for a person; the two parts below are theirs.
23
23
 
24
24
  **Security is always a `HARD-STOP` and is never auto-passed, at any autonomy level.** The two parts that follow — confirm the evidence, then check the non-functional risks — are what *either* resolver works through; the only question is whether a person or the recorded run signs the outcome.
@@ -40,7 +40,7 @@ Automated tests are excellent at behavior on defined inputs and poor at a few sp
40
40
 
41
41
  - **Concurrency and timing.** Is the operation correct when two of them happen at once? Tests usually run serially and miss races.
42
42
  - ▶ *Example: the balance update must be one atomic transaction. Confirm that two simultaneous transfers from the same account cannot both pass the balance check and overdraw it.* This is the single most important check for this feature, and it is the reason the build prompt named atomicity explicitly.
43
- - **Security.** Are there exposed secrets, injection openings, or unexpected dependencies? AI-generated code is known to hardcode secrets and to pull in packages by plausible-but-wrong names.
43
+ - **Security.** Are there exposed secrets, injection openings, or unexpected dependencies? AI-generated code is known to hardcode secrets and to pull in packages by plausible-but-wrong names. A security finding is always a `HARD-STOP`, never a waiver — and it escalates only if it is *written down*: `add.py audit`'s `unescalated_security_note` check fires on a **marked** note that slipped through as an auto-gate PASS, but a finding you **never marked** is invisible to the engine, escalated to no one. That is the honest limit of the auto-gate: under `auto`, a human **spot-audit** (reading the diff) is the only backstop for a *missed* security finding — the same backstop named for an unrecorded earned-green refute-read.
44
44
  - **Architecture conformance.** Does the change respect the layering and dependency rules in `CONVENTIONS.md`? Speed with no architectural check produces a fast-growing tangle that becomes unmaintainable within months.
45
45
 
46
46
  ## Part three — the deep check (do not skim)
@@ -59,6 +59,8 @@ Passing tests say the code satisfies the cases you wrote down. They do not say i
59
59
 
60
60
  A green suite proves the tests pass — not that the build EARNED them. Three judgment cheats pass the unchanged suite without earning it: src overfit to the test fixtures (special-cased to the literal inputs, not the general behavior §1 asked for), vacuous asserts (tautological — green even against an empty implementation), and real logic stubbed away (the function returns a constant the tests happen to accept). These cheats are invisible to the mechanical tamper tripwire, which only sees edited files. Score them with an adversarial refute-read: an independent reviewer — a subagent under `autonomy: auto` is recommended, the engine never spawns one — prompted to argue the green was NOT earned from outside the build context. This is the verify-gate, whole-suite specialization of run.md's adversarial verify (see run.md), not a new discipline. A confirmed earned-green failure is HARD-STOP-class: never auto-passed, never RISK-ACCEPTED — but a first cheat is a chance to redo: a confirmed cheat (mechanical tamper or a reported earned-green failure) enters the bounded self-heal loop — it returns to build for an honest redo, and only after the loop's cap does it HARD-STOP to the human (the loop lives in run.md).
61
61
 
62
+ Under `autonomy: auto` there is no human at the gate to confirm the refute-read happened, so the verdict is **recorded** — a `### Refute-read verdict` field in §6 (`Verdict: EARNED | NOT-EARNED`, plus who read it and what they adversarially probed). This closes an honesty gap the auto-gate would otherwise leave open: the method *says* an auto-PASS rests on an earned-green check, but nothing made that check visible. Now `add.py audit` surfaces a verify-or-later task whose verdict block is still a placeholder as `refute_unrecorded` — a **measure, not a gate**: it never blocks the run (a hard gate on this universal completion path would break far too much to justify it, and the engine cannot read whether a self-graded `EARNED` is *true* anyway — only whether it was written). The honest backstop is the same one named for a missed security finding: a human **spot-audit** reading the diff. A `NOT-EARNED` verdict is not a dead end — it routes through `add.py heal` for an honest redo, never an auto-PASS. The discipline this records is simple: under auto, you are the accountable owner of the gate, so you leave the trace of the check you owe.
63
+
62
64
  ## Recording the outcome
63
65
 
64
66
  Every verification ends with exactly one recorded outcome, with an accountable owner — never a silent pass:
@@ -33,6 +33,10 @@ Every defect, surprise, or new need is written up as a change to the specificati
33
33
 
34
34
  This is also where the AI returns to a useful role: summarizing telemetry, clustering errors into themes, and drafting the proposed spec delta for a person to review. But the production decisions — what to roll back, what to prioritize — remain human.
35
35
 
36
+ ## The decision record (ADR)
37
+
38
+ Observe is also where the loop's **decisions** become durable. At the gate the engine harvests §7's **Decisions (ADR)** block from the stamps the task already carries — the §1 framing it chose (and the alternatives it rejected), the §3 contract a person froze, the §5 strategy it actually used, and the §6 gate outcome — tagging each line `[human]` or `[AI]`. It is *harvested, not authored*: the engine writes no decision of its own, only what the task already recorded, so the trail can never drift from what actually happened. The result is one consolidated, auditable record of who decided what — and `add.py audit` flags a done task whose block never harvested (`adr_record_missing`), so the record cannot silently go missing.
39
+
36
40
  ## Lessons learned and the retrospective consolidation
37
41
 
38
42
  A spec delta feeds the *next feature*. But a loop also teaches the **method itself** — that the domain model missed a boundary, that a whole class of scenario was never tested, that a build convention helped or hurt. AIDD captures those as **lessons learned**: a single tagged learning, written in the Observe step, marking which of the five competencies it sharpens.
@@ -23,7 +23,7 @@ The **per-scope default is auto-with-evidence behind a one-approval decision poi
23
23
 
24
24
  The engine expresses this per task as an explicit three-rung level — `autonomy: manual | conservative | auto`, an ordered ladder `manual < conservative < auto` declared in the `TASK.md` header and reviewed at the freeze. `auto` is auto-with-evidence behind the one approval (the seeded default); `conservative` is the deliberate lowering that keeps a person at the verify gate; `manual` is the strict floor where the human owns the gate and nothing auto-resolves. A high-risk or method-defining scope refuses an unguarded `auto` (`unguarded_high_risk_auto`) — it must be lowered to `conservative` or `manual`. The prose here and that engine token are one rule: prose ≡ enforcement.
25
25
 
26
- **Autonomy is earned by goal-clarity — the auto-ready goal.** The autonomy level decides *who* resolves Verify; an **auto-ready goal** decides whether a self-verifying run is even *meaningful*. A milestone goal is *auto-ready* when **every exit criterion cites a verifier** — `(verify: <test | command | metric>)` — so the engine can check the result against the goal without human judgment. `add.py check` raises a `goal_not_auto_ready` WARN (never red, the active milestone only) while the goal has criteria not all cited, and `status` surfaces a `goal-ready:` line every session, so the goal-clarity gap stays visible. The WARN *measures*, it never blocks: it changes neither the freeze gate nor the autonomy level — clarifying the goal is the prerequisite that *earns* trust, not a new gate (a zero-criteria goal reads not-auto-ready and is milestone-shaping's nudge, not this one's). The lint raises the floor — a citation slot per criterion — but cannot prove the citation is honest: a human can still write `(verify: it works)`, and closing that is a person's judgment, not the engine's.
26
+ **Autonomy is earned by goal-clarity — the auto-ready goal.** The autonomy level decides *who* resolves Verify; an **auto-ready goal** decides whether a self-verifying run is even *meaningful*. A milestone goal is *auto-ready* when **every exit criterion cites a verifier** — `(verify: <test | command | metric>)` — so the engine can check the result against the goal without human judgment. `add.py check` raises a `goal_not_auto_ready` WARN (never red, the active milestone only) while the goal has criteria not all cited, and `status` surfaces a `goal-ready:` line every session, so the goal-clarity gap stays visible. The WARN *measures citation presence*, it never blocks: it changes neither the freeze gate nor the autonomy level — clarifying the goal is the prerequisite for a meaningful self-verifying run, not a new gate (a zero-criteria goal reads not-auto-ready and is milestone-shaping's nudge, not this one's). The lint raises the floor — a citation slot per criterion — but cannot prove the citation is honest: a human can still write `(verify: it works)`, and closing that is a person's judgment, not the engine's.
27
27
 
28
28
  ## The gate-fail protocol and the three reports
29
29
 
@@ -86,7 +86,7 @@ refusing with a non-zero exit and leaving every file byte-unchanged on any of fo
86
86
 
87
87
  - `release_security_open` — an open security `HARD-STOP` exists. This is the non-negotiable one: a
88
88
  security finding is never shipped. Resolve it first, as a change request back to Specify.
89
- - `release_tests_red` — the suite is not green. A release ships on evidence, not on a plausible diff.
89
+ - `release_build_in_flight` — a build is in flight: a task sits in build/verify with no recorded green gate. A release ships on evidence, not on a plausible diff.
90
90
  - `release_no_closed_milestone` — nothing new since the last release. The cut would be a no-op; do not
91
91
  bump a version to mark time.
92
92
  - `release_undisclosed_waiver` — a `RISK-ACCEPTED` waiver rides into the release but is missing from the
@@ -24,11 +24,11 @@ guess. You name them in `.add/components.toml`:
24
24
  ```toml
25
25
  [component.gateway]
26
26
  root = "apps/gateway"
27
- green-bar = "pytest + pyright"
27
+ green_bar = "pytest + pyright"
28
28
 
29
29
  [component.dashboard]
30
30
  root = "apps/web"
31
- green-bar = "vitest + a11y"
31
+ green_bar = "vitest + a11y"
32
32
  ```
33
33
 
34
34
  Each component owns a **root** (the source subtree it governs) and a
@@ -49,6 +49,13 @@ the right suite for the bound component; the gate checks the **right bar was
49
49
  cited** in the evidence. Two tasks, one milestone, two green bars — each held to
50
50
  its own.
51
51
 
52
+ At the gate the engine also **surfaces** the bound component's `verify` command —
53
+ the literal suite to run (e.g. `pytest -q`) — beside the expected green-bar, and
54
+ records it in the §6 gate record so the ledger shows which suite backed the gate.
55
+ It prints *what* to run; it never runs it (NO-EXEC). In the fast lane the same
56
+ `component:` affordance is available, so a small task in a monorepo can bind a
57
+ component and get its bar and `verify` surfaced too.
58
+
52
59
  ## Freeze a contract between components
53
60
 
54
61
  When one component produces an interface another consumes, that boundary needs a
@@ -83,6 +90,15 @@ frontend proceeds and pins it. The slice is **ordered by the frozen contract**,
83
90
  all inside one milestone — the FE stays downstream of the BE endpoint, not split
84
91
  into a later milestone.
85
92
 
93
+ The hold checks more than existence. Even once a snapshot exists, if a live
94
+ producer task has *re-opened or drifted* its §3 — the snapshot no longer matches
95
+ a frozen producer — the consumer is held `producer_contract_stale` rather than
96
+ pinning a shape that is mid-change (the freeze-recency guard). Outside the hold,
97
+ `add.py check` surfaces the softer `contract_producer_stale` (a live producer
98
+ drifted past a pinned consumer) and `contract_snapshot_hashless` (a snapshot
99
+ carrying no hash to verify against) as **never-red warnings** — measured and
100
+ reported, never blocking.
101
+
86
102
  ## Across repositories: federation
87
103
 
88
104
  Components in separate repositories work the same way; only the
@@ -99,9 +115,11 @@ pin = "v1" # optional — the version this repo expects
99
115
  matching id, a hash, and — if `pin` is set — a matching version), and lands a
100
116
  **byte-for-byte copy** at the local `.add/contracts/gateway-api.json`. From
101
117
  there, the consuming repo's task holds and pins exactly as in a monorepo. The
102
- pull is **fail-loud by design**: an unknown id, an unreadable source, an invalid
103
- snapshot, or a version mismatch each HARD-STOPS and lands nothing federation
104
- never builds an FE against a guessed or stale endpoint. The producer's snapshot
118
+ pull is **fail-loud by design**: an unknown id, an unreadable source, a `source`
119
+ that **escapes the consumer repo's allowlist** (`federation_source_escapes`the
120
+ path is confined to a sibling of the repo root, so a `../../etc`-style traversal
121
+ lands nothing), an invalid snapshot, or a version mismatch each HARD-STOPS — federation
122
+ never builds an FE against a guessed, out-of-tree, or stale endpoint. The producer's snapshot
105
123
  is the published artifact; "publishing" is committing that file in the producer
106
124
  repo. Each repo keeps its own git-native `state.json`; federation transports only
107
125
  the immutable frozen shape, never shared mutable state.