@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 +40 -0
- package/bin/cli.js +203 -15
- package/docs/17-components.md +23 -5
- package/docs/appendix-c-glossary.md +3 -3
- package/docs/appendix-d-worked-example.md +79 -0
- package/package.json +1 -1
- package/skill/add/components.md +21 -23
- package/tooling/add.py +254 -13
- package/tooling/templates/TASK.fast.md.tmpl +1 -1
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,
|
|
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
|
-
|
|
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
|
-
|
|
946
|
-
|
|
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(
|
|
950
|
-
kept.push(
|
|
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
|
-
(
|
|
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 ·
|
|
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");
|
package/docs/17-components.md
CHANGED
|
@@ -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
|
-
|
|
27
|
+
green_bar = "pytest + pyright"
|
|
28
28
|
|
|
29
29
|
[component.dashboard]
|
|
30
30
|
root = "apps/web"
|
|
31
|
-
|
|
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,
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
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"
|
package/skill/add/components.md
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
# Components — monorepo and multi-repo slices
|
|
2
2
|
|
|
3
|
-
Opt-in pillar for a milestone
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
14
|
+
green_bar = "pytest + pyright"
|
|
17
15
|
[component.dashboard]
|
|
18
16
|
root = "apps/web"
|
|
19
|
-
|
|
17
|
+
green_bar = "vitest + a11y"
|
|
20
18
|
```
|
|
21
19
|
|
|
22
|
-
A task binds with a `component: <name>` header
|
|
23
|
-
|
|
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
|
|
28
|
-
the
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
52
|
-
- **No new approval** —
|
|
53
|
-
|
|
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
|
|
232
|
-
# against
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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 /
|
|
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
|
|
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
|
|