@pilotspace/add 1.16.1 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,89 @@ All notable changes to the ADD method (`@pilotspace/add` on npm,
4
4
  `pilotspace-add` on PyPI) are documented here. The format follows
5
5
  [Keep a Changelog](https://keepachangelog.com/); versions follow semver.
6
6
 
7
+ ## [1.17.0] — 2026-07-06
8
+
9
+ Minor: four milestones — **method-ergonomics** (every recurring gate rule becomes
10
+ a form the engine presents at that moment, cutting per-task ceremony without
11
+ moving the safety floor), **persona-domain-fit** (a new milestone or task
12
+ whose domain no existing persona covers gets a concrete draft-one nudge),
13
+ **dynamic-personas** (a drafted persona carries `flow:` routing to its agent
14
+ surface, at teacher-grade depth and load cost), and **self-improving-loop**
15
+ (the observe→deltas→fold→compact loop surfaces its own accumulation instead of
16
+ rotting silently) — plus twelve loose tasks. New engine verbs are additive; no
17
+ gate weakened, nothing removed or renamed.
18
+
19
+ ### Added
20
+ - **`add.py gate --explain [slug]`** — a read-only dry-run of the verify gate:
21
+ prints phase · autonomy · risk · sensitivity · advisor lines and one
22
+ `path: AUTO | HUMAN | RELAX | REFUSED (reason)` verdict, always ending with
23
+ the security floor (a security finding is always HARD-STOP). Writes nothing.
24
+ - **`add.py advance --to <phase>`** — fast-forwards a drafted bundle's
25
+ bookkeeping crossings in one call, stopping hard at `tests` (the freeze gate,
26
+ tamper tripwire and scope snapshot are never skipped).
27
+ - **`add.py re-cross`** — records a human-approved post-freeze re-cross
28
+ (`--by` required): re-runs the full tests→build gate stack to legally
29
+ re-snapshot after an approved test addition, never bypassing the freeze.
30
+ - **`add.py worktree-prep <slug>`** — mechanizes the spawn-isolation recipe:
31
+ cuts a git worktree at HEAD, materializes the gitignored `.add/tooling` +
32
+ `.add/docs` a tracked-only checkout lacks, and echoes the fork base for the
33
+ WAVE.md ledger. Workspace-only; state.json is never written.
34
+ - **Verify-record rollup** — `add.py audit` folds its four §6 shape lints into
35
+ one `verify_record_incomplete` line per task.
36
+ - **Delta verbs reach archived tasks** — `drop-delta` / `carry-delta` /
37
+ `reopen-delta` now operate on a light-archived task's on-disk TASK.md
38
+ (explicit slug only, `(archived — on-disk record)` marker, state untouched).
39
+ - **Batched intake + batched gates** — intake.md and report-template.md
40
+ document one report + one confirm for N same-gate items (per-item
41
+ lowest-confidence flags; any item holdable by name). Presentation only.
42
+ - **Domain test forms** — 4-tests.md states that a test is any
43
+ machine-checkable assertion (metric threshold · reconciliation query ·
44
+ plan-diff · rendered-screen diff), red-first holding for each.
45
+ - **Persona domain-fit nudge** — a milestone/task whose domain no existing
46
+ persona covers is nudged toward drafting a fitting one; TASK templates now
47
+ require a named persona and carry a domain-strategy hint.
48
+ - **Fast-lane ground anchor** — the `--fast` template's §0 gains the
49
+ `Ground SHA:` drift anchor, so a fast task can clear the stale-line-ref WARN.
50
+ - **Persona `flow:` routing** — a persona's `flow:` frontmatter (design | build
51
+ | advisor) is now read, not just written: the 4 flow-routed roster agents,
52
+ `design.md`'s evidence checklist, and `advisor.md`'s spawn block all select
53
+ a persona flow-first; add-persona drafts the current schema (`flow:` /
54
+ `source:` / `## Abilities`) and returns `flow` in its verdict.
55
+ - **Persona load performance** — every seeded persona gains `## Abilities`
56
+ (orient-command-led) and `## Anti-patterns`; selection is frontmatter-first
57
+ (name · vibe · flow, then one body — not the whole roster); add-persona
58
+ routes the teacher library by division directory, never its catalog README.
59
+ - **Fold grows the current persona schema** — `add.py fold` routes a
60
+ `persona:<slug> · anti-pattern|ability` lesson into those sections too (was
61
+ limited to `critical-rule`/`success-metric`), so the persona learning loop
62
+ can grow the sections that shape agent behavior.
63
+ - **Loop-surfacing status cues** — `add.py status` names the carried
64
+ spec-delta backlog and the un-compacted foundation tail (last-rolled vs.
65
+ current `foundation-version`) once either passes a noticeable size;
66
+ `release-report` lists the carried total. Additive; a clean project's
67
+ output is unchanged.
68
+ - **`skill/add/self-improve.md`** — one map of how ADD improves itself: the
69
+ four self-improving artifacts (foundation · personas · `SOUL.md` · next
70
+ scope), routed across the 5 domains, fed by all 8 steps.
71
+
72
+ ### Changed
73
+ - **Sequential+auto is the default run mode** — parallel streams stay a
74
+ deliberate, persisted opt-in.
75
+ - **Leaner TASK.md template** — instructional comment bloat trimmed 16%
76
+ (comments −27%); every machine-parsed marker untouched.
77
+ - **Leaner guides** — run.md's specification-bundle section is a pointer at
78
+ its one home (`phases/3-contract.md`); the ⚠ flag grammar now lives in
79
+ exactly one guide.
80
+ - **Waiver field census is case-insensitive** — `audit` recognizes
81
+ `Owner:/Ticket:/Expires:` as written by the signed records; the
82
+ missing-field refusal is unchanged.
83
+
84
+ ### Fixed
85
+ - The CI-observed reclaim-ticket race in `_update_lock` (+ its JS/npm
86
+ heartbeat twin), orphaned reclaim-ticket sweep, the prune-data/update-global
87
+ lock race, the ADR harvester's multi-line field capture, and the
88
+ strip-scaffold backtick-comment over-strip.
89
+
7
90
  ## [1.16.1] — 2026-07-04
8
91
 
9
92
  Patch: two loose, additive persona-loop improvements found while dogfooding
@@ -8,7 +8,7 @@ color: cyan
8
8
  You are the **advisor** in ADD's phase-agent roster — not a phase-worker but a cross-cutting, consultative service (modeled on Claude Code's advisor tool) that any of the other agents, or the orchestrator, consults when a decision is genuinely medium-hard: an ambiguous interpretation, a risky shape, a change-of-approach, a "which of these is right" the caller cannot cheaply resolve alone. Given the situation and its context you return ADVICE — a recommendation, the tradeoffs weighed, the risks and edge-cases, and a confidence self-score. You **advise; you never decide**: you run nothing, record nothing, edit nothing, and you never lower a gate. You own no ADD phase (like add-persona), and you never perform add-verify's earned-green refute-read — that adversarial check stays with the verifier; you advise on the decision, you do not sign the gate.
9
9
 
10
10
  ## Become the persona (do this FIRST — before acting on any task-specific instructions in your prompt)
11
- Load the fit `.add/personas/<slug>.md` for whatever domain the decision sits in and BECOME it — a senior-engineer / architect / domain-analyst stance matched to the question; its `## Critical Rules` are your constraints, its `## Success Metrics` sharpen the recommendation. Even when the caller hands you a different return shape than `## Return` below, keep the in-character judgment — a self-contained prompt says WHAT to weigh, never whether to weigh it in-character. No persona seeded or matched? Use a generic senior engineer, correctness over speed — the generic body never blocks the advice.
11
+ Load the fit `.add/personas/<slug>.md` for whatever domain the decision sits in and BECOME it — select a `flow: advisor` persona first (the frontmatter routing field — choose from frontmatter alone: name · vibe · flow, then read the body of the one you become), then a senior-engineer / architect / domain-analyst stance matched to the question; its `## Critical Rules` are your constraints, its `## Success Metrics` sharpen the recommendation. Even when the caller hands you a different return shape than `## Return` below, keep the in-character judgment — a self-contained prompt says WHAT to weigh, never whether to weigh it in-character. No persona seeded or matched? Use a generic senior engineer, correctness over speed — the generic body never blocks the advice.
12
12
 
13
13
  ## What you own (consultative advice — a cross-cutting service, not an ADD phase)
14
14
  - **Read the situation** — the diff, the real code, and the task/plan files the caller points you at; confirm you understand the decision before weighing in. An advisor who misread the question gives confident, wrong advice.
@@ -8,7 +8,7 @@ color: green
8
8
  You are the **build** specialist in ADD's phase-agent roster — a test author and builder who drives red to green the honest way. You cover two phases: tests (turn each scenario plus the frozen contract into one executable test, confirm the suite fails for the right reason) and build (implement until every test passes, without changing a test or the frozen contract).
9
9
 
10
10
  ## Become the persona
11
- Load the fit `.add/personas/<slug>.md` and BECOME it — prefer a build-engineer / test-author stance; its `## Critical Rules` are your constraints, its `## Success Metrics` are your done-bar. No persona seeded or matched? Use a generic build engineer, correctness over speed — the generic body never blocks.
11
+ Load the fit `.add/personas/<slug>.md` and BECOME it — select a `flow: build` persona first (the frontmatter routing field — choose from frontmatter alone: name · vibe · flow, then read the body of the one you become; archetype stance is the tie-break: build-engineer / test-author); its `## Critical Rules` are your constraints, its `## Success Metrics` are your done-bar. No persona seeded or matched? Use a generic build engineer, correctness over speed — the generic body never blocks.
12
12
 
13
13
  ## What you own (tests → build)
14
14
  - **Tests** — one executable test per scenario, asserting behavior not internals; contract-conformance tests for every shape and error code the frozen contract names; side-effect assertions on rejection paths; confirm the suite is RED for the right reason (missing implementation, not a broken harness) before build opens; record a coverage target.
@@ -8,7 +8,7 @@ color: blue
8
8
  You are the **design** specialist in ADD's phase-agent roster — an interface architect and domain analyst who fixes direction before any code is written. You cover five phases in one continuous span — setup, ground, specify, scenarios, contract — carrying a task (or, for a fresh project, the whole foundation) from a blank page to a frozen, testable shape. Below the freeze, code is disposable; above it, the shape does not move — and that freeze is never yours to declare.
9
9
 
10
10
  ## Become the persona
11
- Load the fit `.add/personas/<slug>.md` and BECOME it — prefer a domain-analyst / interface-architect stance; its `## Critical Rules` are your constraints, its `## Success Metrics` are your done-bar. No persona seeded or matched? Use a generic domain-analyst/architect, correctness over speed — the generic body never blocks.
11
+ Load the fit `.add/personas/<slug>.md` and BECOME it — select a `flow: design` persona first (the frontmatter routing field — choose from frontmatter alone: name · vibe · flow, then read the body of the one you become; archetype stance is the tie-break: domain-analyst / interface-architect); its `## Critical Rules` are your constraints, its `## Success Metrics` are your done-bar. No persona seeded or matched? Use a generic domain-analyst/architect, correctness over speed — the generic body never blocks.
12
12
 
13
13
  ## What you own (the design span: setup → ground → specify → scenarios → contract)
14
14
  - **Setup** (fresh project only) — point ADD at the repo and draft the whole foundation yourself: brownfield is mapped silently from code, greenfield runs the short 4-lens interview (Domain · Spec · Users · Decisions), lowest-confidence-first. Seed `.add/personas/` from PROJECT.md plus the vendored `.add/personas-teacher/` library, never clobbering an existing file.
@@ -8,12 +8,12 @@ color: purple
8
8
  You are the **persona** specialist in ADD's phase-agent roster — not a phase-worker like the other three, but a cross-cutting SERVICE the design, build, and verify agents (or the orchestrator) consult when they need to know which domain stance to adopt for a piece of work. You read PROJECT.md and the vendored `.add/personas-teacher/` library directly to judge fit.
9
9
 
10
10
  ## Become the persona
11
- There is no persona to become for persona-selection itself — read the existing roster under `.add/personas/`, `PROJECT.md` (domain), and the vendored `.add/personas-teacher/` library (read off-build; never fetched) to judge which existing persona fits, or what a new one needs.
11
+ There is no persona to become for persona-selection itself — read the existing roster under `.add/personas/`, `PROJECT.md` (domain), and the vendored `.add/personas-teacher/` library (read off-build; never fetched) to judge which existing persona fits, or what a new one needs. Route into the teacher by its division directory names (the domain index — `engineering/`, `testing/`, `security/`, `design/`, `product/`, …); read only the 1–2 matched teacher files, never the catalog README.
12
12
 
13
13
  ## What you own (persona selection/drafting — a cross-cutting service, not an ADD phase)
14
14
  - Given a piece of work's domain, select the best-fit EXISTING `.add/personas/<slug>.md` if one matches.
15
- - If none fits, draft a NEW persona file conforming to the schema: frontmatter `name`/`vibe`; sections `## Identity` / `## Critical Rules` / `## Default Requirement` / `## Success Metrics` — sourced from PROJECT.md plus the vendored teacher library, never invented from nothing.
16
- - Return the chosen or drafted slug plus a one-line rationale for the calling agent to load and become.
15
+ - If none fits, draft a NEW persona file conforming to the CURRENT schema (`templates/personas/_template.md.tmpl`): frontmatter `name`/`vibe` (required) plus `flow:` (which apply-surface loads it — design | build | advisor) and `source:` (the teacher file(s) it distills); sections `## Identity` / `## Critical Rules` / `## Default Requirement` / `## Success Metrics` plus `## Abilities` — sourced from PROJECT.md plus the vendored teacher library, never invented from nothing. Dynamic never means shallow: a drafted persona carries teacher-grade depth or it isn't worth drafting.
16
+ - Return the chosen or drafted slug, its flow, and a one-line rationale for the calling agent to load and become.
17
17
  - Never overwrite an existing `.add/personas/<slug>.md` — a new draft always gets a new file, even when an existing one is partial or outdated.
18
18
 
19
19
  ## Boundary (the irreducible floor)
@@ -26,6 +26,8 @@ Self-score with the confidence.md six dimensions (Completeness · Clarity · Pra
26
26
 
27
27
  ## Return (disclose progress)
28
28
  End with a structured verdict the calling agent or orchestrator parses:
29
- `{ phase: persona, slug, drafted: true|false, rationale, confidence: {per-dimension 0–1}, open_questions }`.
29
+ `{ phase: persona, slug, flow, drafted: true|false, rationale, confidence: {per-dimension 0–1}, open_questions }`.
30
+
31
+ Personas GROW after drafting: recommend the calling agent tag behavioral lessons `· persona:<slug> · <critical-rule|success-metric|anti-pattern|ability>` at observe — `add.py fold` routes each into that section, never clobbering.
30
32
 
31
33
  Method depth: the AIDD book in `.add/docs/` — `0-setup.md`'s persona-seeding convention (no single phase chapter owns cross-cutting persona work).
@@ -8,7 +8,7 @@ color: red
8
8
  You are the **verify** specialist in ADD's phase-agent roster — a verifier who trusts evidence, not a plausible diff, then a reliability analyst who feeds what shipped back into the next loop. You cover two phases: verify (confirm evidence, check what tests miss, refute the green, record one outcome) and observe (release deliberately, watch reality, draft the next spec delta).
9
9
 
10
10
  ## Become the persona (do this FIRST — before acting on any task-specific instructions in your prompt)
11
- Load the fit `.add/personas/<slug>.md` and BECOME it — prefer a Code-Reviewer / security-gatekeeper stance for verify (`tdd-verifier` is this project's closest fit when nothing more specific matches), a reliability-analyst stance for observe; its `## Critical Rules` are your constraints, its `## Success Metrics` are your done-bar. Tag findings with its severity convention (🔴 blocker · 🟡 concern · 💭 note) even when the caller's prompt never mentions personas or severity markers, and even when the caller hands you a different return shape than `## Return` below — a detailed, self-contained task prompt describes WHAT to check, never whether to check it in-character; if its requested format has no slot for `persona`, add one. No persona seeded or matched? Use a generic reliability/security engineer, correctness over speed — the generic body never blocks.
11
+ Load the fit `.add/personas/<slug>.md` and BECOME it — select a `flow: advisor` persona first (the frontmatter routing field — choose from frontmatter alone: name · vibe · flow, then read the body of the one you become; the verify refute-read rides the advisor surface), then by archetype: a Code-Reviewer / security-gatekeeper stance for verify (`tdd-verifier` is this project's closest fit when nothing more specific matches), a reliability-analyst stance for observe; its `## Critical Rules` are your constraints, its `## Success Metrics` are your done-bar. Tag findings with its severity convention (🔴 blocker · 🟡 concern · 💭 note) even when the caller's prompt never mentions personas or severity markers, and even when the caller hands you a different return shape than `## Return` below — a detailed, self-contained task prompt describes WHAT to check, never whether to check it in-character; if its requested format has no slot for `persona`, add one. No persona seeded or matched? Use a generic reliability/security engineer, correctness over speed — the generic body never blocks.
12
12
 
13
13
  ## What you own (verify → observe)
14
14
  - **Before build** — fill the Build expectations block (observable outcomes derived from the scenarios and the frozen contract); confirm each against real evidence at the gate, not just a green test.
@@ -31,4 +31,6 @@ Treat any Strategy the builder used as their PREFERRED path, not a hard rule you
31
31
  End with a structured verdict the orchestrator parses:
32
32
  `{ phase: verify|observe, persona, result, evidence, residue, outcome, deltas, confidence: {per-dimension 0–1}, open_questions }`.
33
33
 
34
+ When observe surfaces a lesson about HOW an agent should behave (a suspect instinct, a checkable skill), recommend tagging it `· persona:<slug> · anti-pattern|ability` so `add.py fold` grows that persona instead of the shared conventions pile.
35
+
34
36
  Method depth: the AIDD book in `.add/docs/` — `08-step-6-verify.md` · `09-the-loop.md`.
package/bin/cli.js CHANGED
@@ -796,6 +796,37 @@ function sleepSync(ms) {
796
796
  Atomics.wait(ia, 0, 0, ms);
797
797
  }
798
798
 
799
+ // An async, event-loop-YIELDING sleep (unlike sleepSync's Atomics.wait, which blocks the very
800
+ // thread a setInterval-based heartbeat needs to fire on) — js-reclaim-lock-heartbeat.
801
+ function sleepAsync(ms) {
802
+ return new Promise((resolve) => setTimeout(resolve, ms));
803
+ }
804
+
805
+ // js-reclaim-lock-heartbeat: the JS analog of the Python `_lock_heartbeat` fix
806
+ // (reclaim-ticket-race) — refreshes a held lock file's own mtime on a `.unref()`'d
807
+ // `setInterval` for as long as it is held, so a live-but-slow holder is never misjudged
808
+ // stale by a sibling racer purely because wall-clock age crosses `staleAfterMs`. `.unref()`
809
+ // means the timer never keeps the process alive on its own; `stop()` clears it explicitly on
810
+ // release (belt AND suspenders, mirrors the interval's own `max(50ms, min(staleAfterMs/4,
811
+ // 5000ms))` formula the frozen contract cites). A crash simply stops the interval firing — the
812
+ // file still ages out and self-heals via the existing reclaim-ticket mechanism, unchanged.
813
+ //
814
+ // This is a probabilistic mitigation, not a mathematical guarantee: a callback fires only
815
+ // between synchronous JS turns, so whole-event-loop starvation (a long synchronous operation
816
+ // blocking the process) can defeat it exactly as whole-process scheduling starvation once
817
+ // defeated the Python fix's own real OS thread on real CI (see TASK.md's least-sure flag) —
818
+ // accepted given production defaults (600s/120s) make that starvation window vanishingly
819
+ // unlikely in practice.
820
+ function startLockHeartbeat(lockPath, staleAfterMs) {
821
+ const intervalMs = Math.max(50, Math.min(staleAfterMs / 4, 5000));
822
+ const timer = setInterval(() => {
823
+ const now = new Date();
824
+ try { fs.utimesSync(lockPath, now, now); } catch (_e) {} // released/reclaimed — best-effort
825
+ }, intervalMs);
826
+ timer.unref();
827
+ return { stop: () => clearInterval(timer) };
828
+ }
829
+
799
830
  function pkgVersion() {
800
831
  try { return require(path.join(PKG_ROOT, "package.json")).version; }
801
832
  catch (_e) { return "0.0.0"; }
@@ -1249,17 +1280,44 @@ function installGlobalDataRestore(chosenTarget, force) {
1249
1280
  else log(" (no snapshot for this project at " + snap + " — nothing restored)");
1250
1281
  }
1251
1282
 
1283
+ // Every "<lockFileName>.reclaim-*" directly under dir aged past staleAfterSeconds — a LEAKED
1284
+ // per-generation reclaim ticket (its own holder crashed between winning it and its own
1285
+ // best-effort cleanup; a live, currently-in-flight ticket is never this old). Returns full
1286
+ // paths, sorted; [] if dir does not exist. Mirror of _installer.py:_aged_reclaim_tickets.
1287
+ function agedReclaimTickets(dir, lockFileName, staleAfterSeconds, nowMs) {
1288
+ if (!fs.existsSync(dir)) return [];
1289
+ const prefix = lockFileName + ".reclaim-";
1290
+ return fs.readdirSync(dir).filter((name) => name.startsWith(prefix)).map((name) => path.join(dir, name))
1291
+ .filter((p) => {
1292
+ try { return (nowMs - fs.statSync(p).mtimeMs) / 1000 > staleAfterSeconds; }
1293
+ catch (_e) { return false; } // vanished mid-sweep — nothing to report
1294
+ }).sort();
1295
+ }
1296
+
1252
1297
  // prune-data: reclaim ORPHANED snapshots under <home>/data. An orphan is a <home>/data/<key>
1253
1298
  // whose key is owned by NO LIVE registry entry (LIVE = a registered path that still EXISTS on
1254
1299
  // disk) — so unregistered AND registered-but-vanished are BOTH orphans (the explicit reclaim;
1255
1300
  // DIVERGES from update --global's keep-vanished). Reads the registry FIRST (corrupt throws,
1256
- // before any removal). Returns {orphans, removed}. Mirror of _installer.py:_prune_data.
1301
+ // before any removal).
1302
+ //
1303
+ // sweep-orphan-reclaim-tickets: ALSO sweeps LEAKED per-generation reclaim tickets — a
1304
+ // "<LOCK_FILE>.reclaim-*" under home (home-scope) and a "<PROJECT_LOCK_FILE>.reclaim-*" under
1305
+ // every LIVE registered project's own .add/ (project-scope) — each aged past its OWN kind's
1306
+ // existing staleness constant (LOCK_TICKET_STALE_SECONDS / PROJECT_LOCK_TICKET_STALE_SECONDS,
1307
+ // reused verbatim; no new threshold). Reuses the registry already read for the data-orphan
1308
+ // sweep — no new read.
1309
+ //
1310
+ // Returns {orphans, removed, ticketOrphans, ticketsRemoved} — extends the prior {orphans,
1311
+ // removed} object non-breakingly (existing dot-access callers are unaffected). ticketOrphans/
1312
+ // ticketsRemoved are full paths; ticketsRemoved == [] on a dry-run, == ticketOrphans under
1313
+ // force (each unlinked best-effort, matching the existing reclaim code's own swallow-errors
1314
+ // convention). Mirror of _installer.py:_prune_data.
1257
1315
  function pruneData(home, force) {
1258
1316
  const reg = readRegistry(home); // corrupt -> throw (LOUD, zero removal)
1259
- const live = new Set(reg.filter((p) => fs.existsSync(p)).map(dataKey));
1317
+ const livePaths = reg.filter((p) => fs.existsSync(p));
1318
+ const live = new Set(livePaths.map(dataKey));
1260
1319
  const dataDir = path.join(home, "data");
1261
- if (!fs.existsSync(dataDir)) return { orphans: [], removed: [] };
1262
- const orphans = fs.readdirSync(dataDir).filter((name) => {
1320
+ const orphans = !fs.existsSync(dataDir) ? [] : fs.readdirSync(dataDir).filter((name) => {
1263
1321
  try { return fs.statSync(path.join(dataDir, name)).isDirectory() && !live.has(name); }
1264
1322
  catch (_e) { return false; }
1265
1323
  }).sort();
@@ -1270,23 +1328,67 @@ function pruneData(home, force) {
1270
1328
  removed.push(key);
1271
1329
  }
1272
1330
  }
1273
- return { orphans: orphans, removed: removed };
1331
+
1332
+ const now = Date.now();
1333
+ let ticketCandidates = agedReclaimTickets(home, LOCK_FILE, LOCK_TICKET_STALE_SECONDS, now)
1334
+ .map((p) => ({ path: p, staleAfter: LOCK_TICKET_STALE_SECONDS }));
1335
+ for (const project of livePaths) {
1336
+ ticketCandidates = ticketCandidates.concat(
1337
+ agedReclaimTickets(path.join(project, ".add"), PROJECT_LOCK_FILE, PROJECT_LOCK_TICKET_STALE_SECONDS, now)
1338
+ .map((p) => ({ path: p, staleAfter: PROJECT_LOCK_TICKET_STALE_SECONDS }))
1339
+ );
1340
+ }
1341
+ const ticketOrphans = ticketCandidates.map((c) => c.path);
1342
+ const ticketsRemoved = [];
1343
+ if (force) {
1344
+ for (const { path: ticket, staleAfter } of ticketCandidates) {
1345
+ try {
1346
+ if ((Date.now() - fs.statSync(ticket).mtimeMs) / 1000 <= staleAfter) continue; // no longer stale at unlink time — §5 safety rule
1347
+ fs.unlinkSync(ticket);
1348
+ ticketsRemoved.push(ticket);
1349
+ } catch (_e) { /* already gone — harmless, matches reclaim's own convention */ }
1350
+ }
1351
+ }
1352
+
1353
+ return { orphans: orphans, removed: removed, ticketOrphans: ticketOrphans, ticketsRemoved: ticketsRemoved };
1274
1354
  }
1275
1355
 
1276
1356
  // prune-data command: dry-run lists orphans (removes nothing); --force deletes. no_global_home /
1277
1357
  // registry_corrupt = fail-closed (LOUD, nothing removed). Mirror of pip _installer.prune_data.
1358
+ //
1359
+ // prune-data-update-lock: the registry-read + orphan-computation + removal critical section now
1360
+ // holds the SAME home lock (acquireUpdateLock) `update --global` already holds during its own
1361
+ // reconcile (which refreshes an existing project's <home>/data/<key> snapshot) — so the two can
1362
+ // never interleave. Reuses the existing, proven primitive verbatim (fail-fast, no poll — the
1363
+ // primitive's own fail() call handles a contended lock, no extra catch needed here).
1364
+ //
1365
+ // sweep-orphan-reclaim-tickets: ALSO reports (and, with --force, removes) leaked reclaim
1366
+ // tickets found by pruneData — see its own comment; a separate, additive count from the
1367
+ // data-orphan sweep above.
1278
1368
  function cmdPruneData(args) {
1279
1369
  const home = resolveGlobalHome(process.env);
1280
1370
  if (!fs.existsSync(path.join(home, STAMP_FILE))) {
1281
1371
  fail("no global ADD install at " + home + " (.add-version not found) — nothing to prune");
1282
1372
  }
1373
+ acquireUpdateLock(home, { timeout: null }, process.env);
1283
1374
  let result;
1284
1375
  try { result = pruneData(home, args.force); }
1285
1376
  catch (_e) { fail("global registry " + registryPath(home) + " is corrupt — fix or delete it; not pruning"); }
1286
- if (result.orphans.length === 0) { log(" no orphaned snapshots — nothing to prune"); return; }
1287
- if (args.force) { log(" " + result.removed.length + " removed"); return; }
1377
+ if (result.orphans.length === 0 && result.ticketOrphans.length === 0) {
1378
+ log(" no orphaned snapshots nothing to prune");
1379
+ return;
1380
+ }
1381
+ if (args.force) {
1382
+ if (result.orphans.length > 0) log(" ✓ " + result.removed.length + " removed");
1383
+ if (result.ticketOrphans.length > 0) log(" ✓ " + result.ticketsRemoved.length + " reclaim ticket(s) removed");
1384
+ return;
1385
+ }
1288
1386
  for (const key of result.orphans) log(" orphan: " + key);
1289
- log(" " + result.orphans.length + " orphan(s); re-run with --force to remove");
1387
+ if (result.orphans.length > 0) log(" " + result.orphans.length + " orphan(s); re-run with --force to remove");
1388
+ for (const ticket of result.ticketOrphans) log(" ticket orphan: " + ticket);
1389
+ if (result.ticketOrphans.length > 0) {
1390
+ log(" " + result.ticketOrphans.length + " reclaim ticket orphan(s); re-run with --force to remove");
1391
+ }
1290
1392
  }
1291
1393
 
1292
1394
  // init --global: install the managed layer ONCE to the shared home + register this project,
@@ -1477,7 +1579,9 @@ function acquireUpdateLock(home, { timeout = null } = {}, env = process.env) {
1477
1579
  }
1478
1580
  try { fs.writeSync(fd, process.pid + " " + new Date().toISOString() + "\n"); }
1479
1581
  catch (_e) {} // diagnostics are best-effort — never fail an acquired lock over this
1582
+ const heartbeat = startLockHeartbeat(lockPath, staleAfterMs); // js-reclaim-lock-heartbeat
1480
1583
  const release = () => {
1584
+ heartbeat.stop(); // cleared FIRST — never outlives the fd/lockPath it refreshes
1481
1585
  try { fs.closeSync(fd); } catch (_e) {}
1482
1586
  try { fs.unlinkSync(lockPath); } catch (_e) {}
1483
1587
  };
@@ -1631,7 +1735,9 @@ function acquireProjectLock(addDir, env = process.env) {
1631
1735
  }
1632
1736
  try { fs.writeSync(fd, process.pid + " " + new Date().toISOString() + "\n"); }
1633
1737
  catch (_e) {} // diagnostics are best-effort — never fail an acquired lock over this
1738
+ const heartbeat = startLockHeartbeat(lockPath, staleAfterMs); // js-reclaim-lock-heartbeat
1634
1739
  const release = () => {
1740
+ heartbeat.stop(); // cleared FIRST — never outlives the fd/lockPath it refreshes
1635
1741
  try { fs.closeSync(fd); } catch (_e) {}
1636
1742
  try { fs.unlinkSync(lockPath); } catch (_e) {}
1637
1743
  if (createdDir) {
@@ -1736,8 +1842,37 @@ function cmdUpdate(args) {
1736
1842
  " refreshed) · your project state untouched.");
1737
1843
  }
1738
1844
 
1845
+ // js-reclaim-lock-heartbeat: a test-only entrypoint so the Python subprocess suite can drive
1846
+ // real multi-process contention against acquireUpdateLock/acquireProjectLock without duplicating
1847
+ // their acquire/release logic in the test itself. Intercepted BEFORE the `cmd`/switch dispatch
1848
+ // below, guarded behind an undocumented flag — never listed in --help, never reachable via any
1849
+ // documented public command, so it carries zero surface on the real CLI path.
1850
+ async function cmdInternalAcquireLock(argv) {
1851
+ const kind = argv[0];
1852
+ const targetPath = argv[1];
1853
+ const holdMs = Number(argv[2]);
1854
+ let release;
1855
+ if (kind === "update") {
1856
+ release = acquireUpdateLock(targetPath, { timeout: null }, process.env);
1857
+ } else if (kind === "project") {
1858
+ release = acquireProjectLock(targetPath, process.env);
1859
+ } else {
1860
+ fail("internal_acquire_lock_bad_kind: expected 'update' or 'project', got '" + kind + "'");
1861
+ return;
1862
+ }
1863
+ log("HELD " + Date.now());
1864
+ await sleepAsync(holdMs); // event-loop-yielding — lets the heartbeat's setInterval fire
1865
+ log("RELEASED " + Date.now());
1866
+ release();
1867
+ process.exit(0);
1868
+ }
1869
+
1739
1870
  async function main() {
1740
1871
  const argv = process.argv.slice(2);
1872
+ if (argv[0] === "--internal-acquire-lock") {
1873
+ await cmdInternalAcquireLock(argv.slice(1));
1874
+ return;
1875
+ }
1741
1876
  const cmd = argv[0] && !argv[0].startsWith("--") ? argv.shift() : "init";
1742
1877
  const args = parseArgs(argv);
1743
1878
  switch (cmd) {
@@ -113,6 +113,6 @@ The default is one task at a time. But when a milestone holds several tasks whos
113
113
 
114
114
  **Design for failure (required).** Lease each task to its worker with a timeout — if a worker dies, release the claim back to READY rather than trusting partial work. A worker that hits a stop-and-escalate blocks only its own task; siblings keep running. And if several workers fail in one wave, trip a circuit-breaker and fall back to sequential — repeated failure means the scope was wrong, not the parallelism.
115
115
 
116
- **The hard boundary.** The orchestrator owns every shared write — `state.json`, `MILESTONE.md`, and each `add.py advance`/`gate` call (always with the explicit task slug). A worker owns only its own task directory and is isolated in a git worktree, so concurrent builds cannot collide. Merge is **serial**: bring worktrees back one at a time and run an **integration Verify** for the concurrency and architecture conflicts that two-green-in-isolation tasks can still produce — automation never auto-passes that step.
116
+ **The hard boundary.** The orchestrator owns every shared write — `state.json`, `MILESTONE.md`, and each `add.py advance`/`gate` call (always with the explicit task slug). A worker owns only its own task directory and is isolated in a git worktree — ADD's default for any agent-spawned step, not only a wave — so concurrent builds cannot collide. Merge is **serial**: bring worktrees back one at a time and run an **integration Verify** for the concurrency and architecture conflicts that two-green-in-isolation tasks can still produce — automation never auto-passes that step.
117
117
 
118
118
  The full, agent-agnostic worker contract (the prompt a worker runs) and the per-runner spawn adapter live in the skill's `streams.md`; this section is the *why* and the safety frame, not the operational recipe.
@@ -35,11 +35,11 @@ task actually applies one.
35
35
 
36
36
  Personas are **living documents**: they improve through the same loop the foundation uses. In a
37
37
  task's **observe** phase the AI emits a **persona delta** — a one-line, tagged proposal to add or
38
- sharpen a critical-rule or success-metric, written `open` with evidence (just like a lesson learned).
38
+ sharpen a critical-rule, success-metric, anti-pattern, or ability, written `open` with evidence (just like a lesson learned).
39
39
  At a retrospective the human **consolidates** confirmed deltas into the persona file (the same
40
40
  `fold` step the foundation uses), bumping it forward. The consolidation is judgment-free
41
41
  transcription: the engine routes a confirmed delta into the named
42
- persona's `## Critical Rules` or `## Success Metrics` section and **never clobbers** existing content.
42
+ persona's `## Critical Rules`, `## Success Metrics`, `## Anti-patterns`, or `## Abilities` section and **never clobbers** existing content.
43
43
  So a persona gets *more* accurate every milestone instead of drifting.
44
44
 
45
45
  ## Apply — three surfaces
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotspace/add",
3
- "version": "1.16.1",
3
+ "version": "1.17.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"
@@ -34,7 +34,8 @@ surrounding decisions. Return a verdict; do not record state.
34
34
  </objective>
35
35
 
36
36
  <persona>
37
- SELECT the best-fit project persona for this piece and load `.add/personas/{{PERSONA_SLUG}}.md`
37
+ SELECT the persona by frontmatter flow: match first, then domain; read ONE body
38
+ and load `.add/personas/{{PERSONA_SLUG}}.md` —
38
39
  Identity→your stance · Critical Rules→constraints · Success Metrics→done-bar (streams.md's worker
39
40
  contract). No match → a {{DOMAIN}} engineer, correctness over speed; never blocks.
40
41
  Work step by step, following the plan:
@@ -16,7 +16,7 @@ Each delta begins on its own **tag line**; the learning may wrap:
16
16
  - `<status>` — `open` | `folded` | `rejected`. A **newly emitted delta is `open`**.
17
17
  - `<learning>` — the insight; the tag line comes **first**, `(evidence: …)` **closes** the delta.
18
18
  - `(evidence: …)` — **required**, non-empty: a failing scenario, a production signal, a review note. No evidence → it is an opinion, not a delta.
19
- - **persona target (optional)** — a competency lesson MAY add `· persona:<slug> · <critical-rule|success-metric>` in brackets, e.g. `- [UDD · open · persona:ui-designer · success-metric] 4.5:1 contrast (evidence: audit)`. At `add.py fold` it lands in `.add/personas/<slug>.md` under that section (newest-first, never clobbering) instead of `PROJECT.md` (`fold.md`).
19
+ - **persona target (optional)** — a competency lesson MAY add `· persona:<slug> · <critical-rule|success-metric|anti-pattern|ability>` in brackets, e.g. `- [UDD · open · persona:ui-designer · success-metric] 4.5:1 contrast (evidence: audit)`. At `add.py fold` it lands in `.add/personas/<slug>.md` under that section (newest-first, never clobbering) instead of `PROJECT.md` (`fold.md`).
20
20
 
21
21
  A long learning may wrap — `add.py check` joins continuation lines into **one** delta:
22
22
 
@@ -57,8 +57,8 @@ partial per catalog component), with realistic **mock** data.
57
57
  feature's `TASK.md`** — so the approved screen is traceable from the task. The HTML mock is the
58
58
  *visible evidence*; the json-render tree is the *machine-checkable* record.
59
59
 
60
- **Persona evidence checklist.** Before design-confirm, match the screen to the seeded UI personas
61
- (`.add/personas/*` covering visual design and UX research) and render their `## Success Metrics`
60
+ **Persona evidence checklist.** Before design-confirm, load the `flow: design` personas
61
+ (`.add/personas/*` frontmatter, else description-match) and render their `## Success Metrics`
62
62
  as a confirmable **checklist** beside the captured image — **both dimensions**: **UI-Designer**
63
63
  (visual + WCAG-AA **accessibility**) and **UX-Researcher** (methodology-first,
64
64
  **validated by user evidence, not assumed**). Each item traces to a success-metric
package/skill/add/fold.md CHANGED
@@ -6,7 +6,7 @@ This **closes the loop**. `deltas.md` lets a task EMIT `open` lessons in OBSERVE
6
6
 
7
7
  ## When to consolidate
8
8
 
9
- At **milestone close**, or **on demand** when open lessons pile up. One `add.py fold` = ONE session: one `foundation-version` bump, every resolved lesson stamped with it.
9
+ At **milestone close**, or on demand when opens pile up. One `add.py fold` = ONE session: one `foundation-version` bump, every resolved lesson stamped with it.
10
10
 
11
11
  ## The ritual
12
12
 
@@ -25,11 +25,11 @@ At **milestone close**, or **on demand** when open lessons pile up. One `add.py
25
25
  | competency | consolidates into | how |
26
26
  |------------|-----------|-----|
27
27
  | `DDD` | `PROJECT.md` §Domain | transcribed bullet at the top (newest-first) |
28
- | `SDD` | `PROJECT.md` §Spec | transcribed bullet at the top |
29
- | `UDD` | `PROJECT.md` §Users | transcribed bullet at the top |
30
- | `TDD` | `CONVENTIONS.md` §Method learnings | transcribed bullet |
31
- | `ADD` | `CONVENTIONS.md` §Method learnings | transcribed bullet |
32
- | `persona:<slug>` | `.add/personas/<slug>.md` §Critical Rules / §Success Metrics | dated bullet at top; schema stays conformant |
28
+ | `SDD` | `PROJECT.md` §Spec | same |
29
+ | `UDD` | `PROJECT.md` §Users | same |
30
+ | `TDD` | `CONVENTIONS.md` §Method learnings | same |
31
+ | `ADD` | `CONVENTIONS.md` §Method learnings | same |
32
+ | `persona:<slug>` | `.add/personas/<slug>.md` §Critical Rules / §Success Metrics / §Anti-patterns / §Abilities | dated bullet at top; schema stays conformant |
33
33
 
34
34
  A `persona:<slug> · <hint>` lesson routes into that persona doc, not a foundation file; it is still flipped `folded` and still bumps the version once (`deltas.md`).
35
35
 
@@ -45,7 +45,7 @@ A `persona:<slug> · <hint>` lesson routes into that persona doc, not a foundati
45
45
  - `missing_route_section` — a lesson routes to a foundation section that does not exist. Add the section header, then re-run. Nothing is written.
46
46
  - `no_foundation_version` — `PROJECT.md` carries no parseable `foundation-version:` marker to bump.
47
47
  - `missing_persona_target` — a `persona:<slug>` lesson with no `.add/personas/<slug>.md`. Fail-closed: nothing written, no bump. Seed the persona first.
48
- - `persona_section_unroutable` — the section hint is not `critical-rule` or `success-metric`. Nothing is written.
48
+ - `persona_section_unroutable` — the section hint is not one of `critical-rule | success-metric | anti-pattern | ability`. Nothing is written.
49
49
  - `persona_clobber_forbidden` — INVARIANT: a persona consolidation prepends only; it never drops existing content or breaks the schema.
50
50
  </reject_codes>
51
51
 
@@ -31,8 +31,13 @@ fits the active milestone → `task`.
31
31
 
32
32
  **One-task gap rule.** If the request is ONE task but does NOT fit the active milestone's stated
33
33
  scope, do not force it into `sub-milestone` (which requires "too big for one task"): create a new
34
- micro-milestone to house it (`new-milestone` + `new-task`) — that gives the task ledger attribution
35
- and clear exit criteria without inflating scope.
34
+ micro-milestone to house it (`new-milestone` + `new-task`) — ledger attribution + clear
35
+ exit criteria without inflating scope.
36
+
37
+ **Batched intake.** N same-bucket items arriving together (one directive; a drafted
38
+ milestone's task list) classify as ONE proposal: one report listing every item, one
39
+ human confirm covering the batch — never N sequential asks. Mixed buckets stay
40
+ `split_required`.
36
41
 
37
42
  ## What you emit (the proposal)
38
43
 
@@ -59,25 +64,25 @@ goal/body, the new TASK.md, or a note in the affected TASK.md — never in state
59
64
 
60
65
  ## Roadmap — a request that is several milestones
61
66
 
62
- Some requests decompose into **N>1 milestones of the same line** — a roadmap, not one milestone.
67
+ Some requests decompose into **N>1 milestones of the same line** — a roadmap.
63
68
  Don't create only the first and lose the rest. Instead:
64
69
 
65
70
  1. **Propose** the roadmap — the ordered milestone list, each with a one-line goal.
66
- 2. **Confirm** — the human confirms the roadmap before anything is created. Never auto-create N
67
- milestones unprompted the intake floor (`ask_human`) still holds.
71
+ 2. **Confirm** — the human confirms before anything is created; never auto-create N
72
+ milestones unprompted (the `ask_human` floor holds).
68
73
  3. **Create** all N on confirm — the first with `add.py new-milestone <slug>` (active), the rest
69
74
  with `add.py new-milestone --queued <slug>` (status `queued`, not focused).
70
- 4. **Promote** one at a time as each is started, `add.py activate <slug>` flips it queued→active.
71
- You work one active milestone at a time; the queue is the agreed backlog, surfaced at resume.
75
+ 4. **Promote** — `add.py activate <slug>` flips each queued→active as started; one active
76
+ milestone at a time, the queue is the agreed backlog surfaced at resume.
72
77
 
73
- This is NOT `split_required`: that code is for a request spanning **different buckets** (propose the
74
- smallest correctly-sized set). A roadmap is several milestones of the **same line**, created queued.
78
+ NOT `split_required` (that is for a request spanning **different buckets**); a roadmap is
79
+ several milestones of the **same line**, created queued.
75
80
 
76
81
  ## Worked examples (from this project's own history)
77
82
 
78
83
  | request | bucket | rationale |
79
84
  |---------|--------|-----------|
80
- | give ADD a hosted web dashboard | new-major | a new product theme no active milestone's goal covers → a fresh major line (v5) |
81
- | add the build corridor + tests-red-before-build | sub-milestone | a slice of the live v4 "self-driving" theme, too big for one task → v4-2 |
85
+ | give ADD a hosted web dashboard | new-major | a new product theme → a fresh major line (v5) |
86
+ | add the build corridor + tests-red-before-build | sub-milestone | a slice of the live v4 theme, too big for one task → v4-2 |
82
87
  | expose owner/stop as --json | task | fits the active v4-1 (intake interface) scope → one task |
83
- | guide --json phase/gate should be nullable | change-request | changes the FROZEN machine-state-json contract → reopen its CONTRACT, do not make a new milestone |
88
+ | guide --json phase/gate should be nullable | change-request | changes a FROZEN contract → reopen its CONTRACT, never a new milestone |
package/skill/add/loop.md CHANGED
@@ -1,9 +1,8 @@
1
1
  # The dynamic loop — open deltas and extras become the next tasks
2
2
 
3
- A milestone is not done when its tasks are done it is done when its **GOAL** is met.
4
- This guide drives a milestone toward that goal: turn what each task leaves behind (open lessons,
5
- and work discovered but out of scope) into the next tasks, and keep going until the exit criteria
6
- are all met.
3
+ A milestone is done when its **GOAL** is met, not when its tasks are.
4
+ This guide drives toward that goal: turn what each task leaves behind (open lessons, and
5
+ work discovered but out of scope) into the next tasks until the exit criteria are all met.
7
6
 
8
7
  You **gather and propose**; the **human confirms**; the existing `add.py new-task`
9
8
  creates each one. The engine never decides what the next task is — that is judgment.
@@ -24,15 +23,15 @@ both refuse a milestone that is not done. The one gate is enough — there is no
24
23
 
25
24
  ## The loop
26
25
 
27
- When every task is done but the goal is not, `add.py status` shows
26
+ Every task done but not the goal? `add.py status` shows
28
27
  `goal not met (m/n exit criteria)`. That is the cue:
29
28
 
30
29
  1. **Gather** the carried inventory:
31
- - open lessons — `add.py deltas` (the §7 OBSERVE deltas still `open`);
30
+ - open lessons — `add.py deltas` (§7 deltas still `open`) + the deferred pile — `add.py deltas --carried`;
32
31
  - the planned-but-unscaffolded tasks — the plan-vs-state line in `add.py status`;
33
32
  - any reopened task — one a deepened verify returned to the flow (see below).
34
33
  2. **Propose** the next tasks: for each carried item worth doing now, draft a one-line task
35
- (slug + title + why) and show the human. Group the trivial ones; do not propose noise.
34
+ (slug + title + why) and show the human. Group trivial ones; no noise.
36
35
  3. **Confirm** — the human accepts, edits, or declines each. No task is created without this.
37
36
  4. **Create** each accepted task — `add.py new-task <slug> --title "..."` — and run it through
38
37
  the normal flow (specify → … → verify).
@@ -61,16 +61,16 @@ Capture each surfaced decision as an **ADR** in `PROJECT.md` **Key Decisions** a
61
61
  Draft the full bundle **§1–§4** incl. the **§4 red suite** (`phases/4-tests.md`); the lock approves it whole. **Leave §3 `Status: DRAFT`** — the lock is its approval. You MAY `advance` pre-lock, but the engine **refuses build** until you `lock` (`setup_unlocked`). Sequence: **bundle (§1–§4, tests RED) → lock → build** — the red suite must FAIL before build.
62
62
  4. **Write `.add/SETUP-REVIEW.md`** per `setup-review.md`: every drafted decision, **lowest-confidence-first**, tagged `guessed` | `evidence-grounded`.
63
63
 
64
- ## Run mode — how the build will be driven (propose parallel + auto; confirm to keep)
64
+ ## Run mode — how the build will be driven (propose sequential + auto; confirm to keep)
65
65
 
66
66
  Before the lock, surface the **run mode** — autonomy + streams (`run.md` · `streams.md`):
67
67
 
68
68
  | Run mode | Human gates | Concurrency |
69
69
  |----------|-------------|-------------|
70
+ | **sequential · auto** *(default)* | contract freeze **only** — Verify auto-PASSes on evidence | one task at a time |
70
71
  | **sequential · manual/conservative** | contract freeze **and** every Verify | one task; safest |
71
- | **parallel · auto** *(default)* | contract freeze **only** — Verify auto-PASSes on evidence | `add.py waves` overlaps independent builds behind frozen contracts |
72
72
 
73
- **Propose `parallel + auto`; confirm-to-keep** (or downgrade: `add.py autonomy set conservative --project` + `add.py streams set sequential --project`). Record in **`PROJECT.md` Key Decisions**.
73
+ **Propose `sequential + auto`; confirm-to-keep** (raise the gate: `add.py autonomy set conservative --project`; opt into parallel streaming for a milestone with independent tasks — `add.py streams set parallel --project`, then `add.py waves` schedules the fan-out). Record in **`PROJECT.md` Key Decisions**.
74
74
 
75
75
  Floor: **one human approval per contract**.
76
76