@pilotspace/add 1.13.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,46 @@ 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
+
9
49
  ## [1.13.0] — 2026-06-28
10
50
 
11
51
  Two method milestones make ADD's loop more **self-auditing** and more **honest**:
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");
@@ -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.
@@ -16,11 +16,11 @@
16
16
 
17
17
  **Co-specification** — how a spec is made in ADD: the AI and the human **brainstorm the shape together** (diverge), the AI **drafts** it, and the human **validates with the AI's advice** (validate). The AI's decisive advice is the *lowest-confidence flag*. It replaces dictation-by-one-side — the human owns the decision, the AI owns surfacing what it does not yet know. See [03 Specify](./03-step-1-specify.md).
18
18
 
19
- **Component** — a declared part of a multi-part codebase that owns a source `root` and its own `green-bar` (suite + checks), named in `.add/components.toml` under `[component.<name>]`. A task binds to one with a `component:` header line, which adds that root to its §5 Scope and holds it to that component's green bar at verify. Declared, never inferred; a project with no components is byte-identical to a single-codebase project. See [17 Components](./17-components.md).
19
+ **Component** — a declared part of a multi-part codebase that owns a source `root` and its own `green-bar` (suite + checks), named in `.add/components.toml` under `[component.<name>]`. A task binds to one with a `component:` header line, which adds that root to its §5 Scope and holds it to that component's green bar at verify. At the gate the engine also **surfaces** the component's `verify` command (the literal suite to run, e.g. `pytest -q`) and records it — NO-EXEC, the operator runs it; the fast lane carries the same `component:` affordance. Declared, never inferred; a project with no components is byte-identical to a single-codebase project. See [17 Components](./17-components.md).
20
20
 
21
- **Cross-component contract** — the frozen, machine-checkable interface between a producer component and its consumers, declared under `[contract.<id>]` (producer + consumers). A task names its role with a `produces: <id>` or `consumes: <id>` header. On the producer's freeze the engine writes an immutable snapshot at `.add/contracts/<id>.json`; a consumer pins its hash and is flagged `contract_consumer_stale` if the producer later re-freezes a changed shape. Inside one milestone a `consumes:` task is HELD from writing its §3 until the producer's snapshot exists — the intra-milestone BE→FE ordering. See [17 Components](./17-components.md).
21
+ **Cross-component contract** — the frozen, machine-checkable interface between a producer component and its consumers, declared under `[contract.<id>]` (producer + consumers). A task names its role with a `produces: <id>` or `consumes: <id>` header. On the producer's freeze the engine writes an immutable snapshot at `.add/contracts/<id>.json`; a consumer pins its hash and is flagged `contract_consumer_stale` if the producer later re-freezes a changed shape. Inside one milestone a `consumes:` task is HELD from writing its §3 until the producer's snapshot exists — the intra-milestone BE→FE ordering. Freeze-recency: the consumer is also held `producer_contract_stale` if a *live* producer task has re-opened or drifted its §3 (the snapshot no longer matches a frozen producer); `add.py check` surfaces the softer `contract_producer_stale` and `contract_snapshot_hashless` (a snapshot with no hash to verify) as never-red warnings. See [17 Components](./17-components.md).
22
22
 
23
- **Federation (multi-repo)** — the transport that carries a frozen cross-component contract between separate repositories. A consumer repo declares `[federation.<id>]` with a `source` (and optional `pin`); `add.py federate pull <id>` validates the producer repo's published snapshot and lands a byte-for-byte copy locally, where it behaves exactly as in a monorepo. Fail-loud: an unknown id, unreadable source, invalid snapshot, or version mismatch HARD-STOPS and lands nothing. Each repo keeps its own git-native `state.json`; only the immutable snapshot crosses. See [17 Components](./17-components.md).
23
+ **Federation (multi-repo)** — the transport that carries a frozen cross-component contract between separate repositories. A consumer repo declares `[federation.<id>]` with a `source` (and optional `pin`); `add.py federate pull <id>` validates the producer repo's published snapshot and lands a byte-for-byte copy locally, where it behaves exactly as in a monorepo. Fail-loud: an unknown id, unreadable source, a `source` that escapes the repo's allowlist (`federation_source_escapes` — the path is confined to a sibling of the repo root), invalid snapshot, or version mismatch HARD-STOPS and lands nothing. Each repo keeps its own git-native `state.json`; only the immutable snapshot crosses. See [17 Components](./17-components.md).
24
24
 
25
25
  **Disposable code** — the view that code is one regenerable implementation of the artifacts, not a durable asset to be preserved.
26
26
 
@@ -150,3 +150,82 @@ A week later, telemetry shows an unexpectedly high `forbidden` rate. The `6_obse
150
150
  ---
151
151
 
152
152
  This is the whole method in one feature: four artifacts written in order, an AI build bounded by them, a verification grounded in evidence plus the one check tests miss, and a loop that turns production reality into the next specification.
153
+
154
+ ---
155
+
156
+ ## Multi-component, end to end
157
+
158
+ The example above is a single codebase with one green bar. Real slices often cross components — a backend endpoint and the frontend that calls it. ADD ships that slice *inside one milestone* using the component pillar (chapter 17). Here is the same flow, now spanning two components: a `gateway` backend that **produces** an `orders` contract, and a `web` frontend that **consumes** it.
159
+
160
+ ### Declare the components
161
+
162
+ The two parts and the boundary between them are declared in `.add/components.toml` — never inferred:
163
+
164
+ ```toml
165
+ [component.gateway]
166
+ root = "apps/gateway"
167
+ green_bar = "pytest + pyright"
168
+ verify = "pytest -q apps/gateway"
169
+
170
+ [component.web]
171
+ root = "apps/web"
172
+ green_bar = "vitest + a11y"
173
+ verify = "pnpm -C apps/web test"
174
+
175
+ [contract.orders]
176
+ producer = "gateway"
177
+ consumers = ["web"]
178
+ ```
179
+
180
+ One milestone, **list-orders slice**, holds two tasks: `orders-api` (the BE, `produces: orders`) and `orders-list` (the FE, `consumes: orders`).
181
+
182
+ ### The backend freezes first → an immutable snapshot
183
+
184
+ `orders-api` carries a `component: gateway` and a `produces: orders` header. It runs the normal flow — specify, scenarios, contract — and the human freezes its §3:
185
+
186
+ ```
187
+ GET /orders?status= -> 200 { orders: [{ id, status, total, placedAt }], nextCursor }
188
+ 400 { error: "bad_status" }
189
+ Status: FROZEN @ v1 — approved by the tech lead
190
+ ```
191
+
192
+ The moment that contract freezes and the task crosses contract→tests, the engine writes an immutable snapshot — `.add/contracts/orders.json` — recording the id, producer, version, frozen date, and a hash over the frozen §3 shape. That file *is* the published interface.
193
+
194
+ ### The frontend is held until the backend freezes
195
+
196
+ `orders-list` (`component: web`, `consumes: orders`) was started in the same milestone, but it must not commit to a shape the backend has not frozen. When it tries to advance scenarios→contract before the snapshot exists, the engine refuses:
197
+
198
+ ```
199
+ $ python3 .add/tooling/add.py advance
200
+ add: error: producer_contract_unfrozen: orders-list consumes 'orders' but no frozen producer snapshot exists yet — the FE is held until gateway freezes
201
+ ```
202
+
203
+ Once `orders.json` exists, the same `advance` succeeds: the FE writes its §3 against the frozen shape and **pins that snapshot's hash**. The slice is ordered by the contract, not by splitting BE and FE across two milestones.
204
+
205
+ If the backend later *re-opens* its §3 to change the shape, the engine holds the consumer `producer_contract_stale` rather than letting it pin a shape that is mid-change — and `add.py check` separately surfaces `contract_producer_stale` / `contract_snapshot_hashless` as never-red warnings. Freeze-recency, not just existence.
206
+
207
+ ### Each task verifies against its own green bar
208
+
209
+ At the gate, the engine holds each task to *its* component. `orders-api` must cite `pytest + pyright` in its §6 evidence; `orders-list` must cite `vitest + a11y` — cite the wrong bar (or none) and the gate refuses `component_green_bar_uncited`. The engine never runs either suite; it **surfaces** the component's `verify` command so the operator sees exactly what to run:
210
+
211
+ ```
212
+ $ python3 .add/tooling/add.py gate PASS
213
+ task 'orders-api' gate -> PASS
214
+ component: gateway · expected green-bar: pytest + pyright · verify: pytest -q apps/gateway # run this suite — the engine does not (NO-EXEC)
215
+ ```
216
+
217
+ Two tasks, one milestone, two green bars — each held to its own, each suite run by you.
218
+
219
+ ### Across repositories — federation
220
+
221
+ When `gateway` and `web` live in *separate* repos, only the snapshot transport changes. The `web` repo declares where the producer publishes:
222
+
223
+ ```toml
224
+ [federation.orders]
225
+ source = "../gateway/.add/contracts/orders.json"
226
+ pin = "v1"
227
+ ```
228
+
229
+ `add.py federate pull orders` validates the producer repo's published snapshot (valid JSON, matching id, a hash, matching version) and lands a byte-for-byte copy locally — from there the FE holds and pins exactly as in a monorepo. The pull is fail-loud: an unknown id, an unreadable source, a `source` that escapes the repo's allowlist (`federation_source_escapes`), an invalid snapshot, or a version mismatch each HARD-STOPS and lands nothing. Federation never builds the FE against a guessed, out-of-tree, or stale endpoint.
230
+
231
+ That is the component pillar in one slice: declare the parts, freeze the boundary, hold the consumer behind the producer, verify each part on its own bar, and carry the frozen contract across repos — all within the same six-step flow and its single contract-freeze approval.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotspace/add",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
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.",
5
5
  "bin": {
6
6
  "add": "bin/cli.js"
@@ -1,10 +1,8 @@
1
1
  # Components — monorepo and multi-repo slices
2
2
 
3
- Opt-in pillar for a milestone that spans **more than one green bar** — a backend
4
- and its frontend, a shared lib and two apps, services across repos. A project
5
- that declares no components is byte-identical to a single-codebase project. Reach
6
- for this only when one milestone genuinely crosses components. Full narrative:
7
- book chapter `17-components.md`.
3
+ Opt-in pillar for a milestone spanning **more than one green bar** — a BE + its
4
+ FE, a shared lib + two apps, services across repos. No components declared =
5
+ byte-identical to a single-codebase project. Full narrative: `17-components.md`.
8
6
 
9
7
  ## Declare (never inferred)
10
8
 
@@ -13,21 +11,21 @@ book chapter `17-components.md`.
13
11
  ```toml
14
12
  [component.gateway]
15
13
  root = "apps/gateway"
16
- green-bar = "pytest + pyright"
14
+ green_bar = "pytest + pyright"
17
15
  [component.dashboard]
18
16
  root = "apps/web"
19
- green-bar = "vitest + a11y"
17
+ green_bar = "vitest + a11y"
20
18
  ```
21
19
 
22
- A task binds with a `component: <name>` header line → that root joins its §5
23
- Scope, and verify holds it to that component's green-bar.
20
+ A task binds with a `component: <name>` header → that root joins its §5 Scope,
21
+ and verify holds it to that component's green-bar.
24
22
 
25
23
  ## The loop
26
24
 
27
- 1. **Per-component verify.** A bound task must CITE its component's green-bar in
28
- the §6 Build-expectations evidence, or the gate refuses
29
- `component_green_bar_uncited`. The engine never runs the suite you run the
30
- right one; the gate checks the right bar was cited.
25
+ 1. **Per-component verify.** A bound task must CITE its green-bar in §6 evidence
26
+ or the gate refuses `component_green_bar_uncited`. The engine never runs the
27
+ suite it **surfaces** the component's `verify` command (and records it in §6);
28
+ you run it (NO-EXEC). The fast lane carries the same `component:` affordance.
31
29
  2. **Freeze a cross-component contract.** Declare `[contract.<id>]`
32
30
  (producer + consumers). A task names its role `produces: <id>` / `consumes: <id>`.
33
31
  The producer's freeze (contract→tests) writes the immutable snapshot
@@ -35,20 +33,20 @@ Scope, and verify holds it to that component's green-bar.
35
33
  flags consumers `contract_consumer_stale`; a missing/malformed snapshot
36
34
  HARD-STOPS — never build against an unfrozen shape.
37
35
  3. **One milestone, full slice.** A `consumes:` task is HELD from advancing
38
- scenarios→contract (`producer_contract_unfrozen`) until the producer's
39
- snapshot exists. The FE stays downstream of the frozen BE endpoint, in one
40
- milestone.
36
+ scenarios→contract (`producer_contract_unfrozen`) until the producer's snapshot
37
+ exists and `producer_contract_stale` if a live producer re-opened/drifted its
38
+ §3 (freeze-recency). The FE stays downstream of the frozen BE, in one milestone.
41
39
  4. **Across repos — federate.** A consumer repo declares `[federation.<id>]`
42
40
  (`source` + optional `pin`); `add.py federate pull <id>` validates and lands a
43
- byte-for-byte copy of the producer repo's published snapshot locally, where it
44
- behaves as in a monorepo. Fail-loud: unknown id / unreadable source / invalid
41
+ byte-for-byte copy of the producer's snapshot locally. Fail-loud:
42
+ unknown id / unreadable source / a `source` escaping the repo's allowlist
43
+ (`federation_source_escapes`, confined to a repo-root sibling) / invalid
45
44
  snapshot / version mismatch each HARD-STOPS and lands nothing.
46
45
 
47
46
  ## Hold the line
48
47
 
49
48
  - **Declared, not inferred** — no scanning `apps/*`.
50
- - **No central server / no shared mutable state** — federation copies an
51
- immutable snapshot; each repo keeps its own git-native `state.json`.
52
- - **No new approval** — these are engine-enforced gates on the existing six-step
53
- flow, not extra human checkpoints. Ownership/autonomy per component is the
54
- identity story (`streams.md`, governance), layered on this graph.
49
+ - **No central server / no shared mutable state** — federation copies an immutable
50
+ snapshot; each repo keeps its own `state.json`.
51
+ - **No new approval** — engine-enforced gates on the six-step flow, not extra human
52
+ checkpoints. Per-component ownership/autonomy is the identity story (`streams.md`).
package/tooling/add.py CHANGED
@@ -228,11 +228,18 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
228
228
  new = text
229
229
  for pat, repl in rules:
230
230
  new = re.sub(pat, repl, new, count=1)
231
- # component-aware-add (per-component-verify): record WHICH green-bar a bound task gated
232
- # against, right after the Outcome line. Unbound / no green_bar -> no line (byte-identical).
231
+ # component-aware-add (per-component-verify) + component-registry-fill: record WHICH green-bar
232
+ # the bound task gated against AND the component's verify COMMAND, right after the Outcome line.
233
+ # Unbound / neither declared -> no line (byte-identical). green-bar-only output is unchanged.
233
234
  _bar = _task_green_bar(root, slug)
234
- if _bar:
235
- _line = f"component: {_task_component(root, slug)} · expected green-bar: {_bar}"
235
+ _vfy = _task_verify(root, slug)
236
+ if _bar or _vfy:
237
+ _parts = [f"component: {_task_component(root, slug)}"]
238
+ if _bar:
239
+ _parts.append(f"expected green-bar: {_bar}")
240
+ if _vfy:
241
+ _parts.append(f"verify: {_vfy}")
242
+ _line = " · ".join(_parts) # deterministic per task -> idempotent on re-stamp
236
243
  if _line not in new:
237
244
  new = re.sub(r"(?m)^(Outcome:.*$)", lambda m: m.group(1) + "\n" + _line, new, count=1)
238
245
  if new != text: # no-op = no write (mtime stable)
@@ -835,6 +842,10 @@ def cmd_phase(args: argparse.Namespace) -> None:
835
842
  # loop sets phase=build directly (never via cmd_phase) and so stays exempt.
836
843
  if args.phase == "build":
837
844
  _build_entry(root, state, slug, skip_freeze=getattr(args, "skip_freeze", False))
845
+ # cross-component-recency: the `phase contract` override runs the SAME consumer HOLD `advance`
846
+ # runs (existence + recency), so it is not a backdoor around producer_contract_unfrozen/_stale.
847
+ if args.phase == "contract":
848
+ _consumer_contract_hold(root, state, slug)
838
849
  state["tasks"][slug]["phase"] = args.phase
839
850
  state["tasks"][slug]["updated"] = _now()
840
851
  save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
@@ -863,12 +874,10 @@ def cmd_advance(args: argparse.Namespace) -> None:
863
874
  # the HARD-STOP precedes the phase bump, so the task stays at `scenarios`. Undeclared id / no
864
875
  # `consumes:` header -> no hold (byte-identical; a typo'd id is a cmd_check registry finding).
865
876
  if nxt == "contract":
866
- _cid = _task_consumes(root, slug)
867
- _cmap = _contracts(root)
868
- if _cid and _cid in _cmap and not _contract_snapshot(root, _cid).exists():
869
- _die(f"producer_contract_unfrozen: the producer '{_cmap[_cid].get('producer', '?')}' of "
870
- f"contract '{_cid}' must freeze its contract before you write §3 — wait for "
871
- f".add/contracts/{_cid}.json")
877
+ # existence + RECENCY (cross-component-recency): a missing snapshot HARD-STOPs
878
+ # producer_contract_unfrozen; a present-but-stale leftover (live producer drifted/unfroze)
879
+ # HARD-STOPs producer_contract_stale. Shared with cmd_phase so the override is no backdoor.
880
+ _consumer_contract_hold(root, state, slug)
872
881
  # flag-first freeze guard (task unflagged-freeze): a FROZEN §3 may not cross
873
882
  # into build without a WELL-FORMED lowest-confidence flag. On pass, stamp the
874
883
  # verified marker so `audit` enforces the flag on THIS record only (open/new
@@ -1067,6 +1076,9 @@ def cmd_gate(args: argparse.Namespace) -> None:
1067
1076
  _gbar = _task_green_bar(root, slug) # per-component-verify: surface the bound bar
1068
1077
  if _gbar:
1069
1078
  print(f"component: {_task_component(root, slug)} · expected green-bar: {_gbar}")
1079
+ _vfy = _task_verify(root, slug) # component-registry-fill: surface the verify command
1080
+ if _vfy:
1081
+ print(f"verify: {_vfy} # run this suite — the engine does not (NO-EXEC)")
1070
1082
  # the engine-sourced next step (next-footer-engine): a completing gate hands off to the
1071
1083
  # state arm; HARD-STOP routes to "resolve HARD-STOP …" — converging the old bespoke line.
1072
1084
  print(_next_footer(root, state))
@@ -2179,12 +2191,25 @@ def _missing_captures(root: Path) -> list[str]:
2179
2191
  if not any((cap_dir / f"{n}.{ext}").is_file() for ext in _CAPTURE_EXTS)]
2180
2192
 
2181
2193
 
2194
+ def _federation_source_confined(root: Path, source: str) -> bool:
2195
+ """True iff a federation manifest `source` resolves INSIDE the sibling-repo allowlist — the
2196
+ workspace dir (root.parent.parent) that holds this project and its sibling checkouts. A legit
2197
+ cross-repo source is one level up + down (`../<repo>/…`); an absolute path or a `../../` escape
2198
+ resolves OUTSIDE and returns False. PURE + TOTAL — never raises (every error, incl. an embedded
2199
+ NUL, → False, fail-closed), so cmd_federate can gate on it BEFORE any read (federation-harden)."""
2200
+ try:
2201
+ allow = root.parent.parent.resolve()
2202
+ return _confined(root.parent / source, allow)
2203
+ except (OSError, ValueError):
2204
+ return False
2205
+
2206
+
2182
2207
  def cmd_federate(args: argparse.Namespace) -> None:
2183
2208
  """Multi-repo federation: pull a producer repo's published, immutable contract snapshot into
2184
2209
  this repo. Mono vs multi-repo differ ONLY in snapshot-transport — this lands the byte-copy at
2185
2210
  the SAME local `.add/contracts/<id>.json` the monorepo path (tasks 3/4) already reads, so a
2186
- `consumes: <id>` task then holds/pins identically. Designed-for-failure: unknown / missing /
2187
- invalid / version-mismatched sources HARD-STOP and land NOTHING (never build blind)."""
2211
+ `consumes: <id>` task then holds/pins identically. Designed-for-failure: unknown / escaping /
2212
+ missing / invalid / version-mismatched sources HARD-STOP and land NOTHING (never build blind)."""
2188
2213
  root = find_root()
2189
2214
  if root is None:
2190
2215
  _die("no_project")
@@ -2193,6 +2218,13 @@ def cmd_federate(args: argparse.Namespace) -> None:
2193
2218
  if fid not in fed:
2194
2219
  _die(f"federation_unknown: no [federation.{fid}] in components.toml — declare the producer "
2195
2220
  f"repo's published snapshot source before pulling")
2221
+ # federation-harden: confine the source to the sibling-repo allowlist BEFORE any read — an
2222
+ # absolute path or a `../../` traversal must HARD-STOP and land nothing (never read a path
2223
+ # that escapes the project's sibling repos). Checked after the id lookup, before read_bytes.
2224
+ if not _federation_source_confined(root, fed[fid]["source"]):
2225
+ _die(f"federation_source_escapes: the source '{fed[fid]['source']}' for '{fid}' resolves "
2226
+ f"outside the sibling-repo allowlist (the workspace dir) — refusing to read a path "
2227
+ f"that escapes the project's sibling repos")
2196
2228
  source = (root.parent / fed[fid]["source"])
2197
2229
  try:
2198
2230
  raw = source.read_bytes() # bytes — the landed snapshot must be a byte-for-byte copy
@@ -2214,6 +2246,47 @@ def cmd_federate(args: argparse.Namespace) -> None:
2214
2246
  print(f"federated '{fid}' {snap.get('version', '?')} {snap['hash']} from {fed[fid]['source']}")
2215
2247
 
2216
2248
 
2249
+ def cmd_components(args: argparse.Namespace) -> None:
2250
+ """Read-only: print + validate the component registry (.add/components.toml).
2251
+
2252
+ Opt-in — with no registry this is a friendly single-component no-op (exit 0). Prints
2253
+ the parsed components/contracts/federation, then the existing RED integrity findings
2254
+ (_component_findings + _contract_findings), then the new schema-lint WARNs
2255
+ (_component_schema_findings); exits 1 IFF a RED finding exists (a typo/WARN never fails).
2256
+ NO-EXEC — `verify` is shown as data, never run. Reads no docs/ chapter."""
2257
+ root = find_root()
2258
+ if root is None:
2259
+ _die("no_project")
2260
+ if not (root / "components.toml").exists():
2261
+ print("single-component project (no components.toml) — nothing to validate")
2262
+ return
2263
+ comps, cons, feds = _components(root), _contracts(root), _federation(root)
2264
+ for name in sorted(comps):
2265
+ c = comps[name]
2266
+ print(f"component {name} root={c['root']} verify={c.get('verify') or '-'} "
2267
+ f"green_bar={c.get('green_bar') or '-'} language={c.get('language') or '-'}")
2268
+ for cid in sorted(cons):
2269
+ c = cons[cid]
2270
+ print(f"contract {cid} producer={c['producer']} consumers={c['consumers']}")
2271
+ for fid in sorted(feds):
2272
+ f = feds[fid]
2273
+ print(f"federation {fid} source={f['source']} pin={f.get('pin') or '-'}")
2274
+ reds = sorted(_component_findings(root) + _contract_findings(root))
2275
+ warns = sorted(_component_schema_findings(root))
2276
+ for code, detail in reds:
2277
+ print(f"ERROR {code}: {detail}")
2278
+ for code, detail in warns:
2279
+ print(f"WARN {code}: {detail}")
2280
+ head = f"components: {len(comps)} · contracts: {len(cons)} · federation: {len(feds)}"
2281
+ if reds or warns:
2282
+ seg = ([f"{len(reds)} error(s)"] if reds else []) + ([f"{len(warns)} warning(s)"] if warns else [])
2283
+ print(f"{head} — {', '.join(seg)}")
2284
+ else:
2285
+ print(f"{head} — valid")
2286
+ if reds:
2287
+ raise SystemExit(1)
2288
+
2289
+
2217
2290
  def cmd_check(args: argparse.Namespace) -> None:
2218
2291
  """Read-only integrity check of the .add project. Exit 1 if anything fails."""
2219
2292
  as_json = getattr(args, "json", False)
@@ -2290,6 +2363,28 @@ def cmd_check(args: argparse.Namespace) -> None:
2290
2363
  warnings.append((f"task '{slug}'", f"contract_consumer_stale — pinned contract "
2291
2364
  f"'{_pin.get('id')}' changed shape since pin; re-pin (re-cross contract→tests) "
2292
2365
  "after reviewing the producer's new frozen shape"))
2366
+ # cross-component-recency: a consumer whose landed snapshot's LIVE producer drifted/unfroze
2367
+ # since the snapshot — surfaced EARLY (never red) before the consumer re-enters §3, the
2368
+ # check twin of the producer_contract_stale advance HARD-STOP. Degrade-safe.
2369
+ _ccons = _task_consumes(root, slug)
2370
+ if _ccons and _ccons in _contracts(root):
2371
+ _csnap = _contract_snapshot(root, _ccons)
2372
+ if _csnap.exists():
2373
+ try:
2374
+ _chash = json.loads(_csnap.read_text(encoding="utf-8")).get("hash")
2375
+ except (OSError, ValueError, AttributeError):
2376
+ _chash = None
2377
+ if _chash is None:
2378
+ # cross-component-recency R1: a present-but-hash-less snapshot degrades the
2379
+ # recency check to existence-only (frozen behavior) — SURFACE the blind spot
2380
+ # (never red) so a hand-tampered/hash-stripped snapshot is not silently trusted.
2381
+ warnings.append((f"task '{slug}'", f"contract_snapshot_hashless — the landed "
2382
+ f"snapshot for '{_ccons}' carries no hash; recency cannot be "
2383
+ "verified (re-publish the producer contract / re-cross contract→tests)"))
2384
+ elif _producer_snapshot_stale(root, _ccons, _chash):
2385
+ warnings.append((f"task '{slug}'", f"contract_producer_stale — the live producer "
2386
+ f"of '{_ccons}' changed or re-opened its §3 since the landed "
2387
+ "snapshot; re-cross the producer contract→tests, then re-enter"))
2293
2388
  for dep in t.get("depends_on") or []:
2294
2389
  checks.append((dep in tasks or dep in archived_slugs,
2295
2390
  f"task '{slug}' dep '{dep}' resolves", "unknown task"))
@@ -2417,11 +2512,23 @@ def cmd_check(args: argparse.Namespace) -> None:
2417
2512
  # is a BROKEN JOIN — surface it EARLY as a WARN (never red alone; `federate pull` is where it
2418
2513
  # HARD-STOPs). Silent when no federation is declared (opt-in / byte-identical).
2419
2514
  for _fid, _fspec in _federation(root).items():
2420
- if not (root.parent / _fspec["source"]).is_file():
2515
+ if not _federation_source_confined(root, _fspec["source"]):
2516
+ # federation-harden: an out-of-allowlist source surfaced EARLY as a never-red WARN
2517
+ # (escape takes precedence over unreadable — a real /etc/passwd passes is_file()).
2518
+ warnings.append((f"federation '{_fid}'",
2519
+ f"federation_source_escapes — '{_fspec['source']}' resolves outside the "
2520
+ f"sibling-repo allowlist; `federate pull {_fid}` will HARD-STOP"))
2521
+ elif not (root.parent / _fspec["source"]).is_file():
2421
2522
  warnings.append((f"federation '{_fid}'",
2422
2523
  f"federation_source_unreadable — the producer snapshot at "
2423
2524
  f"'{_fspec['source']}' is missing/unreadable; `federate pull {_fid}` "
2424
2525
  "will hard-stop until the producer repo publishes it"))
2526
+ # components-validator: the schema-lint (typo'd/unknown key · wrong-type value · unknown
2527
+ # table) is a never-red WARN — measure-not-block, forward-compat-safe. Rides `warnings`,
2528
+ # NEVER `checks`/`failed`. Silent when no components.toml. `add.py components` is the
2529
+ # richer surface; this catches the same typos EARLY in CI without failing the build.
2530
+ for _scode, _sdetail in _component_schema_findings(root):
2531
+ warnings.append((_scode, _sdetail))
2425
2532
 
2426
2533
  # UDD foundation (udd-check-lint): lint a project's named set under .add/design/ —
2427
2534
  # composes the token + catalog/tree validators + the cross-file prop-token resolution.
@@ -3572,6 +3679,16 @@ def _task_green_bar(root: Path, slug: str) -> str | None:
3572
3679
  return (_components(root).get(comp) or {}).get("green_bar") or None
3573
3680
 
3574
3681
 
3682
+ def _task_verify(root: Path, slug: str) -> str | None:
3683
+ """The `verify` COMMAND of the task's bound component (component-registry-fill) — the literal
3684
+ suite the operator runs at the gate. Twin of _task_green_bar: unbound / "?" / no verify
3685
+ declared all yield None. PURE. SURFACED as data only — the engine NEVER executes it (NO-EXEC)."""
3686
+ comp = _task_component(root, slug)
3687
+ if not comp or comp == "?":
3688
+ return None
3689
+ return (_components(root).get(comp) or {}).get("verify") or None
3690
+
3691
+
3575
3692
  def _component_findings(root: Path) -> list[tuple[str, str]]:
3576
3693
  """The loud gate surface for the registry — the codes a degrade-safe read passes
3577
3694
  over silently. Consumed by cmd_check (the scope_violation surface). [] when clean."""
@@ -3624,6 +3741,64 @@ def _task_consumes(root: Path, slug: str) -> str | None:
3624
3741
  return m.group(1).strip() if m else None
3625
3742
 
3626
3743
 
3744
+ def _producer_snapshot_stale(root: Path, cid: str, snap_hash: str | None) -> bool:
3745
+ """True IFF a LIVE producer task backs `cid` (some task under root/tasks/ whose header carries
3746
+ `produces: cid`) AND that producer's current §3 is NOT frozen, OR its frozen body-hash differs
3747
+ from `snap_hash` (the landed snapshot's hash) — i.e. the producer re-opened/changed its contract
3748
+ since the snapshot was written (cross-component-recency). No live producer task (archived in a
3749
+ prior milestone, or a federation/external snapshot) -> False = existence-only, recency is the
3750
+ producer repo's job via the federation version pin. snap_hash None / unreadable §3 -> False (not
3751
+ confirmable here). PURE + TOTAL — never raises (a read error is a non-confirmation, not a block)."""
3752
+ if snap_hash is None:
3753
+ return False
3754
+ tasks_dir = root / "tasks"
3755
+ if not tasks_dir.is_dir():
3756
+ return False
3757
+ try:
3758
+ producers = sorted(td.name for td in tasks_dir.iterdir() if td.is_dir())
3759
+ except OSError:
3760
+ return False
3761
+ for pslug in producers:
3762
+ if _task_produces(root, pslug) != cid:
3763
+ continue
3764
+ raw3 = _raw_phase_bodies(root, pslug).get(3, "")
3765
+ if "FROZEN @" not in raw3:
3766
+ return True # live producer exists but its §3 is not frozen
3767
+ return _contract_body_hash(raw3) != snap_hash # frozen but drifted from the landed snapshot
3768
+ return False # no live producer backs cid -> existence-only
3769
+
3770
+
3771
+ def _consumer_contract_hold(root: Path, state: dict, slug: str) -> None:
3772
+ """The cross-component consumer HOLD at the §3 boundary, shared by cmd_advance (nxt=="contract")
3773
+ and cmd_phase (phase=="contract") so the admin override is not a backdoor (mirrors the
3774
+ phase build -> _build_entry precedent). A `consumes: <cid>` task targeting a DECLARED contract:
3775
+ a MISSING snapshot HARD-STOPs `producer_contract_unfrozen` (producer hasn't frozen); a PRESENT
3776
+ snapshot whose live producer has drifted/unfrozen HARD-STOPs `producer_contract_stale`
3777
+ (cross-component-recency — a stale leftover must not admit a consumer). No consumes / undeclared
3778
+ id -> return (byte-identical; a typo'd id is a cmd_check finding). `state` is accepted for call-site
3779
+ symmetry with _build_entry (unused here — the hold reads the task + contract files). Validate-
3780
+ then-write: every _die precedes the caller's phase bump, so a refused task does not move."""
3781
+ cid = _task_consumes(root, slug)
3782
+ if not cid:
3783
+ return
3784
+ cmap = _contracts(root)
3785
+ if cid not in cmap:
3786
+ return
3787
+ snap = _contract_snapshot(root, cid)
3788
+ if not snap.exists():
3789
+ _die(f"producer_contract_unfrozen: the producer '{cmap[cid].get('producer', '?')}' of "
3790
+ f"contract '{cid}' must freeze its contract before you write §3 — wait for "
3791
+ f".add/contracts/{cid}.json")
3792
+ try:
3793
+ snap_hash = json.loads(snap.read_text(encoding="utf-8")).get("hash")
3794
+ except (OSError, ValueError, AttributeError):
3795
+ snap_hash = None
3796
+ if _producer_snapshot_stale(root, cid, snap_hash):
3797
+ _die(f"producer_contract_stale: the live producer of contract '{cid}' changed or re-opened "
3798
+ f"its §3 since the landed .add/contracts/{cid}.json — re-cross the producer "
3799
+ "contract->tests to refresh the snapshot, then re-enter (never build against a stale shape)")
3800
+
3801
+
3627
3802
  def _contract_body_hash(raw3: str) -> str:
3628
3803
  """md5 of the §3 contract SHAPE — the first ```fenced``` block, whitespace-normalized. The
3629
3804
  version stamp + freeze flags are excluded (fallback strips Status:/flag/change-request lines)
@@ -3644,6 +3819,67 @@ def _contract_findings(root: Path) -> list[tuple[str, str]]:
3644
3819
  return findings
3645
3820
 
3646
3821
 
3822
+ # ── components.toml schema-lint (components-validator) ─────────────────────────────────
3823
+ # The SOFT typo surface: the keys/tables a DEGRADE-SAFE read silently drops. All three
3824
+ # codes are WARN-severity (measure-not-block) — forward-compat-safe (an older engine
3825
+ # reading a newer file flags, never fails). Surfaced at `check` (as warnings) AND by
3826
+ # `add.py components`. PURE · DEGRADE-SAFE · NO-EXEC: reads only .add/components.toml,
3827
+ # never raises, never executes `verify`. A parse break is _component_findings' RED job.
3828
+ _SCHEMA_KNOWN_KEYS: dict[str, tuple[str, ...]] = {
3829
+ "component": ("root", "verify", "green_bar", "language"),
3830
+ "contract": ("producer", "consumers"),
3831
+ "federation": ("source", "pin"),
3832
+ }
3833
+ _SCHEMA_KEY_TYPES: dict[str, dict[str, type]] = {
3834
+ # `root` is intentionally absent — a missing/non-str root is already RED `components_malformed`
3835
+ "component": {"verify": str, "green_bar": str, "language": str},
3836
+ "contract": {"producer": str, "consumers": list},
3837
+ "federation": {"source": str, "pin": str},
3838
+ }
3839
+ _SCHEMA_TYPENAME = {str: "a string", list: "a list"}
3840
+
3841
+
3842
+ def _component_schema_findings(root: Path) -> list[tuple[str, str]]:
3843
+ """Schema-lint the components.toml registry for typos a degrade-safe read drops:
3844
+ an unknown/misspelled key, a wrong-type value on a known key, or an unrecognized
3845
+ top-level table. Returns [(code, detail)] (deterministic file order); [] when the
3846
+ file is absent / opted-out / unparseable (no double-report with the RED surface).
3847
+ All codes WARN: component_unknown_key · component_type_mismatch · component_unknown_table."""
3848
+ if tomllib is None:
3849
+ return []
3850
+ try:
3851
+ data = tomllib.loads((root / "components.toml").read_bytes().decode("utf-8"))
3852
+ except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
3853
+ return [] # a parse break is _component_findings' RED job
3854
+ if not isinstance(data, dict):
3855
+ return []
3856
+ out: list[tuple[str, str]] = []
3857
+ for top in data: # 1. unrecognized top-level tables/keys
3858
+ if top not in _SCHEMA_KNOWN_KEYS:
3859
+ out.append(("component_unknown_table",
3860
+ f"top-level [{top}] is not a known table "
3861
+ f"(expected one of: {', '.join(_SCHEMA_KNOWN_KEYS)})"))
3862
+ for table, known in _SCHEMA_KNOWN_KEYS.items(): # 2/3. per-entry keys + value types
3863
+ entries = data.get(table)
3864
+ if not isinstance(entries, dict):
3865
+ continue
3866
+ types = _SCHEMA_KEY_TYPES[table]
3867
+ for name, spec in entries.items():
3868
+ if not isinstance(spec, dict):
3869
+ continue # a non-table entry is the RED surface's job
3870
+ for key, val in spec.items():
3871
+ if key not in known:
3872
+ out.append(("component_unknown_key",
3873
+ f"[{table}.{name}] has unknown key {key!r} "
3874
+ f"(known: {', '.join(known)})"))
3875
+ elif key in types and not isinstance(val, types[key]):
3876
+ out.append(("component_type_mismatch",
3877
+ f"[{table}.{name}].{key} should be "
3878
+ f"{_SCHEMA_TYPENAME.get(types[key], types[key].__name__)}, "
3879
+ f"got {type(val).__name__}"))
3880
+ return out
3881
+
3882
+
3647
3883
  def _declared_scope(root: Path, slug: str) -> list[str] | None:
3648
3884
  """Resolve the §5 'Scope (may touch):' declaration to project-root-relative
3649
3885
  strings (directory tokens keep a trailing '/'). The frozen scope-decl-template
@@ -5998,6 +6234,11 @@ def build_parser() -> argparse.ArgumentParser:
5998
6234
  pck.add_argument("--json", action="store_true", help="machine-readable JSON output")
5999
6235
  pck.set_defaults(func=cmd_check)
6000
6236
 
6237
+ pcomp = sub.add_parser("components",
6238
+ help="read-only: print + validate the component registry "
6239
+ "(.add/components.toml) — RED integrity errors + schema-lint typo warnings")
6240
+ pcomp.set_defaults(func=cmd_components)
6241
+
6001
6242
  pfed = sub.add_parser("federate", help="multi-repo: pull a producer repo's published, immutable "
6002
6243
  "contract snapshot into this repo (fail-loud)")
6003
6244
  pfedsub = pfed.add_subparsers(dest="action", required=True)
@@ -1,7 +1,7 @@
1
1
  # TASK: {{title}}
2
2
 
3
3
  slug: {{slug}} · created: {{date}} · stage: {{stage}}
4
- autonomy: {{autonomy}}
4
+ autonomy: {{autonomy}} <!-- Multi-component repo? add a `component: <name>` line (declared in .add/components.toml) to bind this fast task to a component — its root joins §5 Scope and its green-bar/verify surface at the gate. Omit for single-component projects. -->
5
5
  phase: ground <!-- fast lane: ground -> specify -> contract -> tests -> build -> verify -> observe -> done -->
6
6
  fast: true <!-- the fast lane: a small task, collapsed flow + minimal template. Omit --fast for full rigor. -->
7
7