@pilotspace/add 1.12.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/bin/cli.js +203 -15
- package/docs/03-step-1-specify.md +1 -1
- package/docs/04-step-2-scenarios.md +1 -1
- package/docs/08-step-6-verify.md +4 -2
- package/docs/09-the-loop.md +4 -0
- package/docs/11-governance.md +1 -1
- package/docs/16-releasing.md +1 -1
- package/docs/17-components.md +23 -5
- package/docs/appendix-c-glossary.md +6 -4
- package/docs/appendix-d-worked-example.md +79 -0
- package/package.json +1 -1
- package/skill/add/SKILL.md +5 -1
- package/skill/add/advisor.md +8 -1
- package/skill/add/components.md +21 -23
- package/skill/add/phases/5-build.md +1 -1
- package/skill/add/phases/6-verify.md +13 -14
- package/skill/add/phases/7-observe.md +11 -10
- package/skill/add/release.md +1 -1
- package/skill/add/run.md +20 -20
- package/skill/add/scope.md +1 -1
- package/skill/add/streams.md +7 -0
- package/tooling/add.py +689 -93
- package/tooling/add_engine/constants.py +1 -1
- package/tooling/add_engine/release.py +3 -3
- package/tooling/templates/TASK.fast.md.tmpl +3 -1
- package/tooling/templates/TASK.md.tmpl +15 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,96 @@ All notable changes to the ADD method (`@pilotspace/add` on npm,
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.14.0] — 2026-06-29
|
|
10
|
+
|
|
11
|
+
Two milestones round out lanes that shipped partially in earlier releases: the
|
|
12
|
+
**global-home / installer** lane gets its missing inverse + safety, and the
|
|
13
|
+
**component** pillar closes its remaining gaps and hardens its cross-repo edges.
|
|
14
|
+
Installer- and engine-pin-neutral — the ADD engine is byte-identical (ENGINE_MD5
|
|
15
|
+
unchanged); all changes live in the installer twins and the component validator.
|
|
16
|
+
Backward-compatible throughout.
|
|
17
|
+
|
|
18
|
+
### Added (installer-polish — complete the global lane)
|
|
19
|
+
- **Restore the global home.** `init --from-global-data` (and an `init` that detects a
|
|
20
|
+
matching `<home>/data/<key>`) rehydrates a project's user-data from the global home on
|
|
21
|
+
a fresh clone — the non-destructive inverse of the one-way backup. Fill-gaps by default;
|
|
22
|
+
`--force` writes a `<name>.bak` sidecar before overwriting.
|
|
23
|
+
- **`prune-data` orphan cleanup.** A new `prune-data` command removes home snapshots with
|
|
24
|
+
no live registry owner — dry-run by default, `--force` to delete. Both installer twins
|
|
25
|
+
(pip + npm) carry the behavior, byte-for-byte.
|
|
26
|
+
- **`update --global` made safe.** An O_EXCL home lock (`<home>/.update.lock`) serializes
|
|
27
|
+
concurrent runs **cross-twin** (a pip-held lock blocks an npm run and vice-versa), and
|
|
28
|
+
every registered path is validated before any write — a relative/traversal entry aborts
|
|
29
|
+
the whole run loud (`unsafe_registry_path`), a directory without `.add/` is dropped.
|
|
30
|
+
- **Reconcile roll-up.** Every reconcile now reports a file-level `N restored · M refreshed`
|
|
31
|
+
summary, so a partially-gutted-but-present managed tree's heal is finally visible (it
|
|
32
|
+
healed silently before). Pure observation — copy semantics are unchanged.
|
|
33
|
+
|
|
34
|
+
### Added (component-polish — close the pillar gaps + harden the edges)
|
|
35
|
+
- **`add.py components` reader + validator.** A `components.toml` schema-lint surfaces
|
|
36
|
+
`component_unknown_key` / `component_type_mismatch` / `component_unknown_table` — all
|
|
37
|
+
measure-not-block warnings at `check`.
|
|
38
|
+
- **Federation hardening.** `federate pull` path-confines the manifest `source` to a
|
|
39
|
+
sibling-repo allowlist with a fail-closed HARD-STOP (`federation_source_escapes`) before
|
|
40
|
+
any read; a stale leftover contract snapshot that no longer admits a consumer is surfaced
|
|
41
|
+
(`producer_contract_stale`, never red).
|
|
42
|
+
- **Registry fill + a worked example.** The component registry round-trips completely, and
|
|
43
|
+
a full worked example threads the component flow end-to-end in the book.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
- Five version sources bump in lockstep to **1.14.0** (`package.json`, `package-lock.json`
|
|
47
|
+
×2, `pyproject.toml`, `.claude-plugin/plugin.json`, `add_method.__version__`).
|
|
48
|
+
|
|
49
|
+
## [1.13.0] — 2026-06-28
|
|
50
|
+
|
|
51
|
+
Two method milestones make ADD's loop more **self-auditing** and more **honest**:
|
|
52
|
+
the observe step now harvests a decision record automatically, and every flow gate
|
|
53
|
+
is either mechanically enforced or plainly disclosed where it is not. Plus the npm
|
|
54
|
+
release pipeline moves to tokenless **Trusted Publishing**. Backward-compatible
|
|
55
|
+
throughout; nothing retro-changes an existing task or milestone record.
|
|
56
|
+
|
|
57
|
+
### Added (adr-at-observe — a decision record, harvested not hand-written)
|
|
58
|
+
- **`### Decisions (ADR)` harvested at the gate.** On `add.py gate PASS`, the engine
|
|
59
|
+
harvests a §7 Architecture-Decision-Record block from §1/§3/§5/§6 — one
|
|
60
|
+
actor-tagged line per decision (`[AI] specify · [human] freeze · [AI] build ·
|
|
61
|
+
[human] verify`), refilled only while the placeholder stands (never hand-edited).
|
|
62
|
+
- **`add.py audit` checks the ADR at done.** A done task whose §7 ADR block is unfilled
|
|
63
|
+
is surfaced — presence-only, never a semantic judgment of the decisions themselves.
|
|
64
|
+
- **Strategy write-back.** The §6 GATE RECORD is stamped with the git actor + date, and
|
|
65
|
+
the §5 "Strategy actually used" line is harvested into the ADR as the `[AI] build` row.
|
|
66
|
+
|
|
67
|
+
### Added (flow-honesty — every gate engine-true or honestly disclosed)
|
|
68
|
+
- **Two structural holes became real, forceable gates.** A **universal freeze gate**
|
|
69
|
+
(`contract_not_frozen` fires for every task at tests→build, with a recorded
|
|
70
|
+
`--skip-freeze` escape) and a **delta-drain release floor**
|
|
71
|
+
(`release_build_in_flight`, `--force`-able but loud) — the two places the method
|
|
72
|
+
promised enforcement and now delivers it.
|
|
73
|
+
- **Four honor-system edges became plain disclosures + measure-not-block lints.**
|
|
74
|
+
`add.py audit` now surfaces `shallow_deep_check`, `risk_unset`, and
|
|
75
|
+
`refute_unrecorded` (presence-only, never blocking); the guides + book state plainly
|
|
76
|
+
that a *missed* security finding is invisible to the engine (a human spot-audit is
|
|
77
|
+
the only backstop under `auto`); reject-code names read honestly
|
|
78
|
+
(`release_tests_red`→`release_build_in_flight`).
|
|
79
|
+
- **The earned-green refute-read is now a recorded verdict.** Under `auto`, the §6
|
|
80
|
+
`### Refute-read verdict` block records `EARNED | NOT-EARNED`; the engine measures it
|
|
81
|
+
is filled but never spawns the read — the resolver still does the judging.
|
|
82
|
+
- **Stale guidance synced to the shipped engine.** The 5-build "scope gate deferred"
|
|
83
|
+
note is gone (the scope gate enforces today); the auto-PASS precondition list is now
|
|
84
|
+
identical across `run.md`, `6-verify.md`, and the book.
|
|
85
|
+
|
|
86
|
+
### Added (loose tasks)
|
|
87
|
+
- **`/add --todo` flag** (`skill-todo-flag`) — capture/list/close backlog todos.
|
|
88
|
+
- **§5 Strategy fed into spawns** (`build-strategy-solutions` · `streams-strategy-pull`)
|
|
89
|
+
— the planned build order + known-problem fixes flow into subagent prompts.
|
|
90
|
+
- **`<strategy>` softened** (`strategy-soft-not-hard`) — §5 is the *preferred* plan; the
|
|
91
|
+
builder may self-improve during build and reports the strategy actually used for audit.
|
|
92
|
+
|
|
93
|
+
### Changed (release infrastructure)
|
|
94
|
+
- **npm Trusted Publishing (OIDC).** `publish.yml` drops the stored npm token (no more
|
|
95
|
+
~90-day rotation), publishes via OIDC (`id-token: write`, Node 24 + npm ≥ 11.5.1),
|
|
96
|
+
and generates provenance automatically (`--provenance` no longer needed). A guard test
|
|
97
|
+
locks the tokenless shape. PyPI already used OIDC.
|
|
98
|
+
|
|
9
99
|
## [1.12.0] — 2026-06-26
|
|
10
100
|
|
|
11
101
|
Multi-milestone polish release — the intake/roadmap front grows a real **queue**,
|
package/bin/cli.js
CHANGED
|
@@ -39,7 +39,8 @@ function parseArgs(argv) {
|
|
|
39
39
|
// defaults the stage and infers the name from the folder, so the manual-init
|
|
40
40
|
// hint only echoes flags the user actually chose (shortest true command).
|
|
41
41
|
const args = { _: [], force: false, check: false, noSkill: false, stage: null, name: null,
|
|
42
|
-
yes: false, nonInteractive: false, global: false, globalData: false,
|
|
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");
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[← 02 The flow](./02-the-flow.md) · [Contents](./README.md) · Next: [04 Step 2 Scenarios →](./04-step-2-scenarios.md)
|
|
4
4
|
|
|
5
5
|
> **Purpose:** state, in plain language, what the feature must do and what it must reject, with no ambiguity left for the AI to resolve by guessing.
|
|
6
|
-
> **Produces:** `
|
|
6
|
+
> **Produces:** the **§1 SPECIFY** block of the task's `TASK.md` (one file per task — what this chapter is the *why* behind; historically a separate `SPEC.md`).
|
|
7
7
|
> **How it works — co-specification:** AI and human **brainstorm the shape together**; the AI drafts; the **human validates, with the AI's advice.** The decisive advice is a *lowest-confidence flag* — the AI names the one or two things most likely to be wrong, so the human's attention lands where it matters. The human owns the decision; the AI owns surfacing what it does not yet know.
|
|
8
8
|
|
|
9
9
|
---
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[← 03 Step 1 Specify](./03-step-1-specify.md) · [Contents](./README.md) · Next: [05 Step 3 Contract →](./05-step-3-contract.md)
|
|
4
4
|
|
|
5
5
|
> **Purpose:** rewrite each rule from the spec as a concrete, pass-or-fail scenario.
|
|
6
|
-
> **Produces:** `features/<name>.feature
|
|
6
|
+
> **Produces:** the **§2 SCENARIOS** block of the task's `TASK.md` (one file per task — what this chapter is the *why* behind; historically a separate `features/<name>.feature`).
|
|
7
7
|
> **Person's job:** decide what "correct" looks like in concrete situations. **AI's job:** draft the scenarios.
|
|
8
8
|
|
|
9
9
|
> **Part of the specification bundle (v7).** In the default flow these scenarios are drafted by the AI alongside the spec, contract, and failing tests as **one bundle**, approved by a person **once** (the one approval), at the contract freeze — not signed off step by step. This chapter is how to get the scenarios *right*; [05 Contract](./05-step-3-contract.md) is where the bundle is frozen. See [11 Governance](./11-governance.md).
|
package/docs/08-step-6-verify.md
CHANGED
|
@@ -18,7 +18,7 @@ This needs care, because it is easy to misread. "Not by inspection" does not mea
|
|
|
18
18
|
|
|
19
19
|
Verify can be resolved two ways, set per task by the `autonomy:` header (see [governance](./11-governance.md) and the autonomy level):
|
|
20
20
|
|
|
21
|
-
- **Auto (the default).** When `autonomy: auto`, the run resolves the gate on **evidence** rather than waiting for a person — but only when *all* of these hold
|
|
21
|
+
- **Auto (the default).** When `autonomy: auto`, the run resolves the gate on **evidence** rather than waiting for a person — but only when *all* of these hold — the **same canonical list run.md owns**: every test green, coverage not decreased, no test weakened and no contract edited, the convergence loops dry, the completeness-critic clean, the deep check filled, the earned-green refute-read verdict recorded (the §6 `### Refute-read verdict` field, under auto), and **no residue** (security, concurrency, or architecture). It records `PASS` as *auto-resolved*, naming the run as the accountable owner — an explicit pass, not a skip. This is principle 7: a gate may be resolved by evidence when that evidence is sufficient and the result is logged.
|
|
22
22
|
- **Human.** When `autonomy: conservative`, or whenever the run finds residue it cannot judge, the gate stops for a person; the two parts below are theirs.
|
|
23
23
|
|
|
24
24
|
**Security is always a `HARD-STOP` and is never auto-passed, at any autonomy level.** The two parts that follow — confirm the evidence, then check the non-functional risks — are what *either* resolver works through; the only question is whether a person or the recorded run signs the outcome.
|
|
@@ -40,7 +40,7 @@ Automated tests are excellent at behavior on defined inputs and poor at a few sp
|
|
|
40
40
|
|
|
41
41
|
- **Concurrency and timing.** Is the operation correct when two of them happen at once? Tests usually run serially and miss races.
|
|
42
42
|
- ▶ *Example: the balance update must be one atomic transaction. Confirm that two simultaneous transfers from the same account cannot both pass the balance check and overdraw it.* This is the single most important check for this feature, and it is the reason the build prompt named atomicity explicitly.
|
|
43
|
-
- **Security.** Are there exposed secrets, injection openings, or unexpected dependencies? AI-generated code is known to hardcode secrets and to pull in packages by plausible-but-wrong names.
|
|
43
|
+
- **Security.** Are there exposed secrets, injection openings, or unexpected dependencies? AI-generated code is known to hardcode secrets and to pull in packages by plausible-but-wrong names. A security finding is always a `HARD-STOP`, never a waiver — and it escalates only if it is *written down*: `add.py audit`'s `unescalated_security_note` check fires on a **marked** note that slipped through as an auto-gate PASS, but a finding you **never marked** is invisible to the engine, escalated to no one. That is the honest limit of the auto-gate: under `auto`, a human **spot-audit** (reading the diff) is the only backstop for a *missed* security finding — the same backstop named for an unrecorded earned-green refute-read.
|
|
44
44
|
- **Architecture conformance.** Does the change respect the layering and dependency rules in `CONVENTIONS.md`? Speed with no architectural check produces a fast-growing tangle that becomes unmaintainable within months.
|
|
45
45
|
|
|
46
46
|
## Part three — the deep check (do not skim)
|
|
@@ -59,6 +59,8 @@ Passing tests say the code satisfies the cases you wrote down. They do not say i
|
|
|
59
59
|
|
|
60
60
|
A green suite proves the tests pass — not that the build EARNED them. Three judgment cheats pass the unchanged suite without earning it: src overfit to the test fixtures (special-cased to the literal inputs, not the general behavior §1 asked for), vacuous asserts (tautological — green even against an empty implementation), and real logic stubbed away (the function returns a constant the tests happen to accept). These cheats are invisible to the mechanical tamper tripwire, which only sees edited files. Score them with an adversarial refute-read: an independent reviewer — a subagent under `autonomy: auto` is recommended, the engine never spawns one — prompted to argue the green was NOT earned from outside the build context. This is the verify-gate, whole-suite specialization of run.md's adversarial verify (see run.md), not a new discipline. A confirmed earned-green failure is HARD-STOP-class: never auto-passed, never RISK-ACCEPTED — but a first cheat is a chance to redo: a confirmed cheat (mechanical tamper or a reported earned-green failure) enters the bounded self-heal loop — it returns to build for an honest redo, and only after the loop's cap does it HARD-STOP to the human (the loop lives in run.md).
|
|
61
61
|
|
|
62
|
+
Under `autonomy: auto` there is no human at the gate to confirm the refute-read happened, so the verdict is **recorded** — a `### Refute-read verdict` field in §6 (`Verdict: EARNED | NOT-EARNED`, plus who read it and what they adversarially probed). This closes an honesty gap the auto-gate would otherwise leave open: the method *says* an auto-PASS rests on an earned-green check, but nothing made that check visible. Now `add.py audit` surfaces a verify-or-later task whose verdict block is still a placeholder as `refute_unrecorded` — a **measure, not a gate**: it never blocks the run (a hard gate on this universal completion path would break far too much to justify it, and the engine cannot read whether a self-graded `EARNED` is *true* anyway — only whether it was written). The honest backstop is the same one named for a missed security finding: a human **spot-audit** reading the diff. A `NOT-EARNED` verdict is not a dead end — it routes through `add.py heal` for an honest redo, never an auto-PASS. The discipline this records is simple: under auto, you are the accountable owner of the gate, so you leave the trace of the check you owe.
|
|
63
|
+
|
|
62
64
|
## Recording the outcome
|
|
63
65
|
|
|
64
66
|
Every verification ends with exactly one recorded outcome, with an accountable owner — never a silent pass:
|
package/docs/09-the-loop.md
CHANGED
|
@@ -33,6 +33,10 @@ Every defect, surprise, or new need is written up as a change to the specificati
|
|
|
33
33
|
|
|
34
34
|
This is also where the AI returns to a useful role: summarizing telemetry, clustering errors into themes, and drafting the proposed spec delta for a person to review. But the production decisions — what to roll back, what to prioritize — remain human.
|
|
35
35
|
|
|
36
|
+
## The decision record (ADR)
|
|
37
|
+
|
|
38
|
+
Observe is also where the loop's **decisions** become durable. At the gate the engine harvests §7's **Decisions (ADR)** block from the stamps the task already carries — the §1 framing it chose (and the alternatives it rejected), the §3 contract a person froze, the §5 strategy it actually used, and the §6 gate outcome — tagging each line `[human]` or `[AI]`. It is *harvested, not authored*: the engine writes no decision of its own, only what the task already recorded, so the trail can never drift from what actually happened. The result is one consolidated, auditable record of who decided what — and `add.py audit` flags a done task whose block never harvested (`adr_record_missing`), so the record cannot silently go missing.
|
|
39
|
+
|
|
36
40
|
## Lessons learned and the retrospective consolidation
|
|
37
41
|
|
|
38
42
|
A spec delta feeds the *next feature*. But a loop also teaches the **method itself** — that the domain model missed a boundary, that a whole class of scenario was never tested, that a build convention helped or hurt. AIDD captures those as **lessons learned**: a single tagged learning, written in the Observe step, marking which of the five competencies it sharpens.
|
package/docs/11-governance.md
CHANGED
|
@@ -23,7 +23,7 @@ The **per-scope default is auto-with-evidence behind a one-approval decision poi
|
|
|
23
23
|
|
|
24
24
|
The engine expresses this per task as an explicit three-rung level — `autonomy: manual | conservative | auto`, an ordered ladder `manual < conservative < auto` declared in the `TASK.md` header and reviewed at the freeze. `auto` is auto-with-evidence behind the one approval (the seeded default); `conservative` is the deliberate lowering that keeps a person at the verify gate; `manual` is the strict floor where the human owns the gate and nothing auto-resolves. A high-risk or method-defining scope refuses an unguarded `auto` (`unguarded_high_risk_auto`) — it must be lowered to `conservative` or `manual`. The prose here and that engine token are one rule: prose ≡ enforcement.
|
|
25
25
|
|
|
26
|
-
**Autonomy is earned by goal-clarity — the auto-ready goal.** The autonomy level decides *who* resolves Verify; an **auto-ready goal** decides whether a self-verifying run is even *meaningful*. A milestone goal is *auto-ready* when **every exit criterion cites a verifier** — `(verify: <test | command | metric>)` — so the engine can check the result against the goal without human judgment. `add.py check` raises a `goal_not_auto_ready` WARN (never red, the active milestone only) while the goal has criteria not all cited, and `status` surfaces a `goal-ready:` line every session, so the goal-clarity gap stays visible. The WARN *measures*, it never blocks: it changes neither the freeze gate nor the autonomy level — clarifying the goal is the prerequisite
|
|
26
|
+
**Autonomy is earned by goal-clarity — the auto-ready goal.** The autonomy level decides *who* resolves Verify; an **auto-ready goal** decides whether a self-verifying run is even *meaningful*. A milestone goal is *auto-ready* when **every exit criterion cites a verifier** — `(verify: <test | command | metric>)` — so the engine can check the result against the goal without human judgment. `add.py check` raises a `goal_not_auto_ready` WARN (never red, the active milestone only) while the goal has criteria not all cited, and `status` surfaces a `goal-ready:` line every session, so the goal-clarity gap stays visible. The WARN *measures citation presence*, it never blocks: it changes neither the freeze gate nor the autonomy level — clarifying the goal is the prerequisite for a meaningful self-verifying run, not a new gate (a zero-criteria goal reads not-auto-ready and is milestone-shaping's nudge, not this one's). The lint raises the floor — a citation slot per criterion — but cannot prove the citation is honest: a human can still write `(verify: it works)`, and closing that is a person's judgment, not the engine's.
|
|
27
27
|
|
|
28
28
|
## The gate-fail protocol and the three reports
|
|
29
29
|
|
package/docs/16-releasing.md
CHANGED
|
@@ -86,7 +86,7 @@ refusing with a non-zero exit and leaving every file byte-unchanged on any of fo
|
|
|
86
86
|
|
|
87
87
|
- `release_security_open` — an open security `HARD-STOP` exists. This is the non-negotiable one: a
|
|
88
88
|
security finding is never shipped. Resolve it first, as a change request back to Specify.
|
|
89
|
-
- `
|
|
89
|
+
- `release_build_in_flight` — a build is in flight: a task sits in build/verify with no recorded green gate. A release ships on evidence, not on a plausible diff.
|
|
90
90
|
- `release_no_closed_milestone` — nothing new since the last release. The cut would be a no-op; do not
|
|
91
91
|
bump a version to mark time.
|
|
92
92
|
- `release_undisclosed_waiver` — a `RISK-ACCEPTED` waiver rides into the release but is missing from the
|
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.
|