@pilotspace/add 1.9.0 → 1.10.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,70 @@ 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
+ ## [Unreleased]
8
+
9
+ ## [1.10.0] — 2026-06-25
10
+
11
+ Component-aware major — ADD now models every codebase as a **graph of components**,
12
+ each owning a source root, its own green bar, and the contracts it produces or
13
+ consumes, so **one milestone can ship a vertical slice across components** in a
14
+ monorepo or across repos. Bundles the closed `component-aware-add` major plus the
15
+ `docs-site` (the book as a live MkDocs site) and `loop-steering` milestones, and the
16
+ ccsk `--rule-file` mode. Every new gate is **opt-in or grandfathered** — a project
17
+ that declares no components is byte-identical to 1.9.0.
18
+
19
+ ### Added
20
+ - **Component registry (`component-aware-add`)** — declare parts of a multi-part
21
+ codebase in `.add/components.toml` under `[component.<name>]` (a `root` + a
22
+ `green-bar`). A task binds to one with a `component:` header line, which anchors its
23
+ §5 Scope to that component's root. Declared, never inferred; zero components ⇒
24
+ byte-identical to today.
25
+ - **Per-component verify** — a bound task is held to ITS component's green-bar at the
26
+ verify gate (the cite-gate refuses `component_green_bar_uncited`), so two tasks in
27
+ one milestone can pass on two different toolchains (e.g. `pytest` and `vitest`). The
28
+ engine still never runs a suite — the AI runs it, the gate checks the right bar was
29
+ cited.
30
+ - **Cross-component contracts (`produces:` / `consumes:`)** — declare `[contract.<id>]`
31
+ (producer + consumers). A producer's §3 freeze writes an immutable snapshot at
32
+ `.add/contracts/<id>.json`; a consumer pins its hash; a changed re-freeze flags every
33
+ consumer stale. A missing/malformed snapshot HARD-STOPS — never build against a
34
+ guessed shape.
35
+ - **One milestone, full-stack slice** — a `consumes:` task is HELD from entering §3
36
+ (`producer_contract_unfrozen`) until its producer's contract is frozen, so a BE→FE
37
+ vertical slice ships in one milestone, ordered by the frozen contract.
38
+ - **Multi-repo federation (`add.py federate pull <id>`)** — a consumer repo declares
39
+ `[federation.<id>]` (a `source` path + optional `pin`) and pulls a byte-for-byte copy
40
+ of a producer repo's published snapshot into its local `.add/contracts/`, where the
41
+ rest of ADD treats mono- and multi-repo identically. Fail-loud: unknown id /
42
+ unreadable source / invalid snapshot / version mismatch each HARD-STOPS and lands
43
+ nothing.
44
+ - **Component pillar docs** — a new book chapter `17 · Components`, a skill guide
45
+ (`components.md`), and glossary terms (Component · Cross-component contract ·
46
+ Federation) teach the whole loop.
47
+ - **The book as a live site (`docs-site`)** — the AIDD book ships to GitHub Pages as a
48
+ MkDocs-Material site (`mkdocs.yml` + `requirements-docs.txt`, build-time only — the
49
+ published packages stay zero-dependency); `mkdocs build --strict` fails the deploy on
50
+ a broken intra-book link.
51
+ - **Rule-file mode for ccsk projects (`--rule-file`)** — when a project keeps its
52
+ workflow rules under `.claude/rules/` (the ccsk convention, detected by a `.ccsk/`
53
+ dir), ADD relocates CLAUDE.md's block to `.claude/rules/add-workflows.md` and leaves a
54
+ single reference bullet, instead of inlining. Triggers three ways (the explicit
55
+ `--rule-file` flag, a `.ccsk/` directory, or a rule file already present),
56
+ **CLAUDE-only**, migrates a prior inline block out (`.bak` on change), idempotent and
57
+ fail-soft. Mirrored across all three installers (engine `add.py`, pip `_installer.py`,
58
+ npm `cli.js`).
59
+
60
+ ### Changed
61
+ - **Guided dynamic loop (`loop-steering`)** — `status` and `guide` now STEER into the
62
+ build→verify loop at the loop juncture rather than only reporting it, so an agent is
63
+ pointed at the next loop step instead of having to infer it.
64
+
65
+ ### Compatibility
66
+ - Python 3.11+ is required to USE the component pillar (it parses `components.toml` with
67
+ the stdlib `tomllib`); on 3.10 the engine runs unchanged but a declared
68
+ `components.toml` fails loud (`components_malformed`). The published packages remain
69
+ zero-dependency.
70
+
7
71
  ## [1.9.0] — 2026-06-24
8
72
 
9
73
  Lean-pass major — make ADD's own surface (skill · flow · engine) the most-effective
package/bin/cli.js CHANGED
@@ -39,7 +39,7 @@ 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, ruleFile: false };
43
43
  for (let i = 0; i < argv.length; i++) {
44
44
  const a = argv[i];
45
45
  if (a === "--force") args.force = true;
@@ -58,6 +58,9 @@ function parseArgs(argv) {
58
58
  // already provides the `add` skill, so a plugin bootstrap uses this to materialize
59
59
  // .add/tooling/ + .add/docs/ into the project without a duplicate .claude/skills/add.
60
60
  else if (a === "--no-skill") args.noSkill = true;
61
+ // --rule-file: write the ADD block to .claude/rules/add-workflows.md + reference it from
62
+ // CLAUDE.md instead of inlining (auto-on for ccsk projects with a .ccsk/ dir).
63
+ else if (a === "--rule-file") args.ruleFile = true;
61
64
  else if (a === "--stage" || a === "--name") {
62
65
  const v = argv[++i];
63
66
  // fail loudly on a trailing/abutting flag — never silently drop a value
@@ -249,6 +252,126 @@ function writeAgentPointer(target, profile) {
249
252
  }
250
253
  }
251
254
 
255
+ // --- rule-file mode: ccsk-style relocation of the CLAUDE.md block ---------------------
256
+ // Mirror of add.py / _installer.py rule-file logic (twins by duplication). For ccsk projects
257
+ // (.ccsk/ dir) or an explicit --rule-file, the ADD pointer goes to .claude/rules/add-workflows.md
258
+ // and CLAUDE.md keeps only a reference bullet. CLAUDE-only — AGENTS.md/.clinerules stay inline.
259
+ const RULES_FILE_REL = path.join(".claude", "rules", "add-workflows.md");
260
+ const WORKFLOW_HEADINGS = ["Rules & Workflows", "Workflows", "Rules"];
261
+ const RULE_REF_LINE = "- ADD (AI-Driven Development) Workflows rules: ./.claude/rules/add-workflows.md";
262
+
263
+ // True when the ADD block belongs in .claude/rules/add-workflows.md instead of inline.
264
+ // Re-derived from disk: explicit flag, a ccsk project (.ccsk/), or a rule file already present.
265
+ function ruleFileMode(root, flag) {
266
+ if (flag) return true;
267
+ try {
268
+ if (fs.existsSync(path.join(root, ".ccsk")) &&
269
+ fs.statSync(path.join(root, ".ccsk")).isDirectory()) return true;
270
+ if (fs.existsSync(path.join(root, RULES_FILE_REL))) return true;
271
+ } catch (_e) { /* fail-soft */ }
272
+ return false;
273
+ }
274
+
275
+ // Remove an inline ADD:BEGIN..END region (migration), collapsing the gap. An unterminated
276
+ // BEGIN is left as-is rather than eating the rest of the file.
277
+ function stripInlineBlock(text) {
278
+ const begin = text.indexOf(GUIDE_BEGIN);
279
+ if (begin === -1) return text;
280
+ let end = text.indexOf(GUIDE_END, begin);
281
+ if (end === -1) return text;
282
+ end += GUIDE_END.length;
283
+ const head = text.slice(0, begin).replace(/\n+$/, "");
284
+ const tail = text.slice(end).replace(/^\n+/, "");
285
+ if (head && tail) return head + "\n\n" + tail;
286
+ return head || tail;
287
+ }
288
+
289
+ // Insert the ADD rule-file bullet under an existing Workflows/Rules heading, or append a fresh
290
+ // '## Workflows' section. Caller guarantees the bullet is absent.
291
+ function insertRuleReference(text) {
292
+ const lines = text.split("\n");
293
+ let headingIdx = -1;
294
+ for (let i = 0; i < lines.length; i++) {
295
+ const m = lines[i].match(/^(#{1,6})\s+(.*?)\s*$/);
296
+ if (m && WORKFLOW_HEADINGS.some((h) => m[2].trim().toLowerCase() === h.toLowerCase())) {
297
+ headingIdx = i;
298
+ break;
299
+ }
300
+ }
301
+ if (headingIdx === -1) {
302
+ const body = text.replace(/\n+$/, "");
303
+ const sep = body ? "\n\n" : "";
304
+ return body + sep + "## Workflows\n\n" + RULE_REF_LINE + "\n";
305
+ }
306
+ const level = lines[headingIdx].match(/^(#{1,6})/)[1].length;
307
+ let end = lines.length;
308
+ for (let j = headingIdx + 1; j < lines.length; j++) {
309
+ const m = lines[j].match(/^(#{1,6})\s+/);
310
+ if (m && m[1].length <= level) { end = j; break; }
311
+ }
312
+ let insertAt = headingIdx + 1;
313
+ for (let j = headingIdx + 1; j < end; j++) {
314
+ if (lines[j].trim()) insertAt = j + 1;
315
+ }
316
+ lines.splice(insertAt, 0, RULE_REF_LINE);
317
+ return lines.join("\n");
318
+ }
319
+
320
+ // Make CLAUDE.md reference the ADD rule file under a Workflows/Rules heading, migrating any prior
321
+ // inline ADD block out. created|updated|unchanged|skipped; .bak on change; fail-soft.
322
+ function ensureClaudeReference(claudeMd) {
323
+ try {
324
+ const existed = fs.existsSync(claudeMd);
325
+ const current = existed ? fs.readFileSync(claudeMd, "utf8") : "";
326
+ let next = stripInlineBlock(current);
327
+ if (next.indexOf("add-workflows.md") === -1) next = insertRuleReference(next);
328
+ if (!next.endsWith("\n")) next += "\n";
329
+ if (next === current) return "unchanged";
330
+ if (existed) fs.writeFileSync(claudeMd + ".bak", current);
331
+ fs.writeFileSync(claudeMd, next);
332
+ return existed ? "updated" : "created";
333
+ } catch (e) {
334
+ warn("could not write CLAUDE.md — " + (e && e.message ? e.message : e) + "; skipped");
335
+ return "skipped";
336
+ }
337
+ }
338
+
339
+ // Rule-file variant of writeAgentPointer for the Claude profile: write the ADD pointer block to
340
+ // .claude/rules/add-workflows.md and leave a reference in CLAUDE.md. Fail-soft.
341
+ function writeRuleFilePointer(target, profile) {
342
+ const rulesPath = path.join(target, RULES_FILE_REL);
343
+ const block = agentPointerBlock(profile);
344
+ try {
345
+ if (fs.existsSync(rulesPath)) {
346
+ const current = fs.readFileSync(rulesPath, "utf8");
347
+ const begin = current.indexOf(GUIDE_BEGIN);
348
+ let next;
349
+ if (begin !== -1) {
350
+ const endIdx = current.indexOf(GUIDE_END, begin);
351
+ if (endIdx !== -1) {
352
+ next = current.slice(0, begin) + block + current.slice(endIdx + GUIDE_END.length);
353
+ } else {
354
+ next = current.replace(/\n+$/, "") + "\n\n" + block + "\n";
355
+ }
356
+ } else {
357
+ next = current.replace(/\n+$/, "") + "\n\n" + block + "\n";
358
+ }
359
+ if (next !== current) {
360
+ fs.writeFileSync(rulesPath + ".bak", current);
361
+ fs.writeFileSync(rulesPath, next);
362
+ }
363
+ } else {
364
+ fs.mkdirSync(path.dirname(rulesPath), { recursive: true });
365
+ fs.writeFileSync(rulesPath, block + "\n");
366
+ }
367
+ } catch (e) {
368
+ warn("could not write " + RULES_FILE_REL + " — " + (e && e.message ? e.message : e) + "; skipped");
369
+ return "skipped";
370
+ }
371
+ ensureClaudeReference(path.join(target, "CLAUDE.md"));
372
+ return "ok";
373
+ }
374
+
252
375
  // --- interactive layer (clack on a real TTY; plain text everywhere else) -----
253
376
  // Designed-for-failure: any doubt (non-TTY, CI, --yes, a failed import, an
254
377
  // un-promptable stream) degrades to the EXACT plain-text path below. The clack
@@ -450,7 +573,16 @@ function dropFiles(args, target, profile, intent) {
450
573
  // Agent detection: write THE detected agent's integration file (a marker-delimited
451
574
  // pointer init's sync-guidelines later supersedes) + tailor the closing next-step.
452
575
  // Best-effort + fail-soft — never aborts the successful drop above.
453
- writeAgentPointer(target, profile);
576
+ // Rule-file mode (ccsk projects / --rule-file) relocates the CLAUDE.md pointer to
577
+ // .claude/rules/add-workflows.md + a reference — CLAUDE-only; other agents stay inline.
578
+ if (profile.integration_file === "CLAUDE.md" && ruleFileMode(target, args.ruleFile)) {
579
+ if (fs.existsSync(path.join(target, ".ccsk"))) {
580
+ log(" ccsk detected — ADD rules go to .claude/rules/add-workflows.md");
581
+ }
582
+ writeRuleFilePointer(target, profile);
583
+ } else {
584
+ writeAgentPointer(target, profile);
585
+ }
454
586
 
455
587
  // Gemini CLI auto-loads GEMINI.md, not AGENTS.md — so for the gemini profile we ALSO merge
456
588
  // .gemini/settings.json (context.fileName) to load the AGENTS.md pointer. Fail-soft + idempotent.
@@ -847,4 +979,7 @@ module.exports = {
847
979
  scopeOptions: scopeOptions,
848
980
  writeIntentNote: writeIntentNote,
849
981
  writeGeminiSettings: writeGeminiSettings,
982
+ ruleFileMode: ruleFileMode,
983
+ ensureClaudeReference: ensureClaudeReference,
984
+ writeRuleFilePointer: writeRuleFilePointer,
850
985
  };
@@ -1,6 +1,6 @@
1
1
  # 16 · Releasing
2
2
 
3
- [← 15 Foundations & Lineage](./15-foundations-and-lineage.md) · [Contents](./README.md) · Next: [Appendix A Templates →](./appendix-a-templates.md)
3
+ [← 15 Foundations & Lineage](./15-foundations-and-lineage.md) · [Contents](./README.md) · Next: [17 Components →](./17-components.md)
4
4
 
5
5
  ---
6
6
 
@@ -179,4 +179,4 @@ the risk, record the marker, and let a person make the outward call.
179
179
 
180
180
  ---
181
181
 
182
- [← 15 Foundations & Lineage](./15-foundations-and-lineage.md) · [Contents](./README.md) · Next: [Appendix A Templates →](./appendix-a-templates.md)
182
+ [← 15 Foundations & Lineage](./15-foundations-and-lineage.md) · [Contents](./README.md) · Next: [17 Components →](./17-components.md)
@@ -0,0 +1,125 @@
1
+ # 17 · Components — monorepo and multi-repo
2
+
3
+ [← 16 Releasing](./16-releasing.md) · [Contents](./README.md) · Next: [Appendix A Templates →](./appendix-a-templates.md)
4
+
5
+ ---
6
+
7
+ Most of this book treats a project as one codebase with one green bar. Real
8
+ systems are rarely that tidy: a backend and a frontend, a shared library and two
9
+ apps, or three services across three repos. ADD models all of these the same
10
+ way — as a **graph of components**. A component owns a source root, its own
11
+ green bar, and the contracts it produces or consumes. With that, **one milestone
12
+ can ship a vertical slice across components** — a backend endpoint and the
13
+ frontend that calls it — instead of splitting the slice across milestones.
14
+
15
+ This pillar is **opt-in and additive**: a project that declares no components
16
+ behaves exactly as the rest of the book describes. You reach for it only when a
17
+ milestone genuinely spans more than one green bar.
18
+
19
+ ## Declare the components
20
+
21
+ Components are **declared, never inferred** — ADD does not scan `apps/*` and
22
+ guess. You name them in `.add/components.toml`:
23
+
24
+ ```toml
25
+ [component.gateway]
26
+ root = "apps/gateway"
27
+ green-bar = "pytest + pyright"
28
+
29
+ [component.dashboard]
30
+ root = "apps/web"
31
+ green-bar = "vitest + a11y"
32
+ ```
33
+
34
+ Each component owns a **root** (the source subtree it governs) and a
35
+ **green-bar** (the suite + checks that prove *that* component healthy). A task
36
+ binds to a component with a `component:` header line; the component's root is
37
+ then added to the task's §5 Scope automatically. A project with no
38
+ `components.toml`, or a task with no `component:` line, is byte-identical to a
39
+ single-component project.
40
+
41
+ ## Verify each task against its own green bar
42
+
43
+ In a mixed milestone, a backend task and a frontend task pass on **different
44
+ toolchains**. The verify gate enforces this per component: a task bound to
45
+ `gateway` must cite its component's green-bar (`pytest + pyright`) in the §6
46
+ Build-expectations evidence, or the gate refuses `component_green_bar_uncited`.
47
+ The engine never *runs* the suite — that invariant holds here too. The AI runs
48
+ the right suite for the bound component; the gate checks the **right bar was
49
+ cited** in the evidence. Two tasks, one milestone, two green bars — each held to
50
+ its own.
51
+
52
+ ## Freeze a contract between components
53
+
54
+ When one component produces an interface another consumes, that boundary needs a
55
+ **frozen, machine-checkable contract**. Declare it, name its producer and
56
+ consumers:
57
+
58
+ ```toml
59
+ [contract.gateway-api]
60
+ producer = "gateway"
61
+ consumers = ["dashboard"]
62
+ ```
63
+
64
+ A task declares its role with a header line — `produces: gateway-api` or
65
+ `consumes: gateway-api`. When the **producer** task freezes its §3 and crosses
66
+ contract→tests, the engine writes an immutable snapshot at
67
+ `.add/contracts/gateway-api.json` (id, producer, version, frozen date, and a
68
+ hash over the frozen §3 shape). When a **consumer** task crosses contract→tests,
69
+ it **pins** that live hash. If the producer later re-freezes a *changed* shape,
70
+ `add.py check` flags every consumer `contract_consumer_stale` — a §7 delta to
71
+ re-pin against the new shape. A missing or malformed snapshot is a HARD-STOP, not
72
+ a guess: the consumer never builds against a shape that was never frozen.
73
+
74
+ ## One milestone, a full-stack slice
75
+
76
+ The reason to put a producer and a consumer in the *same* milestone is to ship a
77
+ vertical slice — but the frontend must not commit to an endpoint the backend has
78
+ not frozen yet. ADD enforces that ordering with a **hold**: a `consumes:` task
79
+ cannot advance scenarios→contract (it cannot write its §3) while its producer's
80
+ snapshot does not yet exist. The engine refuses `producer_contract_unfrozen` and
81
+ the task stays at `scenarios`. Once the backend freezes its contract, the
82
+ frontend proceeds and pins it. The slice is **ordered by the frozen contract**,
83
+ all inside one milestone — the FE stays downstream of the BE endpoint, not split
84
+ into a later milestone.
85
+
86
+ ## Across repositories: federation
87
+
88
+ Components in separate repositories work the same way; only the
89
+ **snapshot transport** differs. A consumer repo declares where a producer repo
90
+ publishes its frozen contract:
91
+
92
+ ```toml
93
+ [federation.gateway-api]
94
+ source = "../gateway/.add/contracts/gateway-api.json"
95
+ pin = "v1" # optional — the version this repo expects
96
+ ```
97
+
98
+ `add.py federate pull gateway-api` reads that source, validates it (valid JSON,
99
+ matching id, a hash, and — if `pin` is set — a matching version), and lands a
100
+ **byte-for-byte copy** at the local `.add/contracts/gateway-api.json`. From
101
+ there, the consuming repo's task holds and pins exactly as in a monorepo. The
102
+ pull is **fail-loud by design**: an unknown id, an unreadable source, an invalid
103
+ snapshot, or a version mismatch each HARD-STOPS and lands nothing — federation
104
+ never builds an FE against a guessed or stale endpoint. The producer's snapshot
105
+ is the published artifact; "publishing" is committing that file in the producer
106
+ repo. Each repo keeps its own git-native `state.json`; federation transports only
107
+ the immutable frozen shape, never shared mutable state.
108
+
109
+ ## What this pillar is not
110
+
111
+ - **Not auto-discovery.** Components are declared in `components.toml`, not
112
+ inferred from the directory tree.
113
+ - **Not a central server.** Federation copies an immutable snapshot between
114
+ repos; there is no shared service and no shared mutable state.
115
+ - **Not a new approval.** The component machinery rides the existing six-step
116
+ flow and its single contract-freeze approval — it adds gates the engine
117
+ enforces, not human checkpoints.
118
+
119
+ The whole pillar is structure, not policy: who *owns* a component and how
120
+ autonomy is set per component is the identity/governance story (chapters 11–12),
121
+ layered on top of this graph.
122
+
123
+ ---
124
+
125
+ [← 16 Releasing](./16-releasing.md) · [Contents](./README.md) · Next: [Appendix A Templates →](./appendix-a-templates.md)
@@ -16,6 +16,12 @@
16
16
 
17
17
  **Co-specification** — how a spec is made in ADD: the AI and the human **brainstorm the shape together** (diverge), the AI **drafts** it, and the human **validates with the AI's advice** (validate). The AI's decisive advice is the *lowest-confidence flag*. It replaces dictation-by-one-side — the human owns the decision, the AI owns surfacing what it does not yet know. See [03 Specify](./03-step-1-specify.md).
18
18
 
19
+ **Component** — a declared part of a multi-part codebase that owns a source `root` and its own `green-bar` (suite + checks), named in `.add/components.toml` under `[component.<name>]`. A task binds to one with a `component:` header line, which adds that root to its §5 Scope and holds it to that component's green bar at verify. Declared, never inferred; a project with no components is byte-identical to a single-codebase project. See [17 Components](./17-components.md).
20
+
21
+ **Cross-component contract** — the frozen, machine-checkable interface between a producer component and its consumers, declared under `[contract.<id>]` (producer + consumers). A task names its role with a `produces: <id>` or `consumes: <id>` header. On the producer's freeze the engine writes an immutable snapshot at `.add/contracts/<id>.json`; a consumer pins its hash and is flagged `contract_consumer_stale` if the producer later re-freezes a changed shape. Inside one milestone a `consumes:` task is HELD from writing its §3 until the producer's snapshot exists — the intra-milestone BE→FE ordering. See [17 Components](./17-components.md).
22
+
23
+ **Federation (multi-repo)** — the transport that carries a frozen cross-component contract between separate repositories. A consumer repo declares `[federation.<id>]` with a `source` (and optional `pin`); `add.py federate pull <id>` validates the producer repo's published snapshot and lands a byte-for-byte copy locally, where it behaves exactly as in a monorepo. Fail-loud: an unknown id, unreadable source, invalid snapshot, or version mismatch HARD-STOPS and lands nothing. Each repo keeps its own git-native `state.json`; only the immutable snapshot crosses. See [17 Components](./17-components.md).
24
+
19
25
  **Disposable code** — the view that code is one regenerable implementation of the artifacts, not a durable asset to be preserved.
20
26
 
21
27
  **Evidence bundle** — the proof attached to a change (passing tests, clean security scan, no coverage loss) that justifies trusting it and may unlock more AI autonomy.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotspace/add",
3
- "version": "1.9.0",
3
+ "version": "1.10.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"
@@ -46,7 +46,7 @@
46
46
  "type": "git",
47
47
  "url": "git+https://github.com/pilotspace/ADD.git"
48
48
  },
49
- "homepage": "https://github.com/pilotspace/ADD#readme",
49
+ "homepage": "https://pilotspace.github.io/ADD/",
50
50
  "bugs": {
51
51
  "url": "https://github.com/pilotspace/ADD/issues"
52
52
  }
@@ -130,6 +130,10 @@ once the human confirms, rewrites `SOUL.md` (the human is the only writer) — `
130
130
  (security HARD-STOP is un-forceable) → human confirms → `add.py release <version>` records the cut
131
131
  (CHANGELOG + `RELEASES.md` ledger + milestone attribution). The engine records; the human runs the
132
132
  tag / publish / deploy. A release bundles ≥1 milestone and is orthogonal to stage.
133
+ - **Monorepo / multi-repo** — a milestone spans more than one green bar (a BE + its FE, services across
134
+ repos) → the **component pillar**: declare components in `.add/components.toml`, gate each task on its
135
+ component's green-bar, freeze cross-component contracts (`produces:`/`consumes:`), hold the FE until the
136
+ BE freezes, and `federate pull` a contract across repos — `components.md`. Opt-in; no components = today.
133
137
 
134
138
  ## Non-negotiable rules (from the method)
135
139
 
@@ -0,0 +1,54 @@
1
+ # Components — monorepo and multi-repo slices
2
+
3
+ Opt-in pillar for a milestone that spans **more than one green bar** — a backend
4
+ and its frontend, a shared lib and two apps, services across repos. A project
5
+ that declares no components is byte-identical to a single-codebase project. Reach
6
+ for this only when one milestone genuinely crosses components. Full narrative:
7
+ book chapter `17-components.md`.
8
+
9
+ ## Declare (never inferred)
10
+
11
+ `.add/components.toml`:
12
+
13
+ ```toml
14
+ [component.gateway]
15
+ root = "apps/gateway"
16
+ green-bar = "pytest + pyright"
17
+ [component.dashboard]
18
+ root = "apps/web"
19
+ green-bar = "vitest + a11y"
20
+ ```
21
+
22
+ A task binds with a `component: <name>` header line → that root joins its §5
23
+ Scope, and verify holds it to that component's green-bar.
24
+
25
+ ## The loop
26
+
27
+ 1. **Per-component verify.** A bound task must CITE its component's green-bar in
28
+ the §6 Build-expectations evidence, or the gate refuses
29
+ `component_green_bar_uncited`. The engine never runs the suite — you run the
30
+ right one; the gate checks the right bar was cited.
31
+ 2. **Freeze a cross-component contract.** Declare `[contract.<id>]`
32
+ (producer + consumers). A task names its role `produces: <id>` / `consumes: <id>`.
33
+ The producer's freeze (contract→tests) writes the immutable snapshot
34
+ `.add/contracts/<id>.json`; the consumer pins its hash. A changed re-freeze
35
+ flags consumers `contract_consumer_stale`; a missing/malformed snapshot
36
+ HARD-STOPS — never build against an unfrozen shape.
37
+ 3. **One milestone, full slice.** A `consumes:` task is HELD from advancing
38
+ scenarios→contract (`producer_contract_unfrozen`) until the producer's
39
+ snapshot exists. The FE stays downstream of the frozen BE endpoint, in one
40
+ milestone.
41
+ 4. **Across repos — federate.** A consumer repo declares `[federation.<id>]`
42
+ (`source` + optional `pin`); `add.py federate pull <id>` validates and lands a
43
+ byte-for-byte copy of the producer repo's published snapshot locally, where it
44
+ behaves as in a monorepo. Fail-loud: unknown id / unreadable source / invalid
45
+ snapshot / version mismatch each HARD-STOPS and lands nothing.
46
+
47
+ ## Hold the line
48
+
49
+ - **Declared, not inferred** — no scanning `apps/*`.
50
+ - **No central server / no shared mutable state** — federation copies an
51
+ immutable snapshot; each repo keeps its own git-native `state.json`.
52
+ - **No new approval** — these are engine-enforced gates on the existing six-step
53
+ flow, not extra human checkpoints. Ownership/autonomy per component is the
54
+ identity story (`streams.md`, governance), layered on this graph.
package/tooling/add.py CHANGED
@@ -25,6 +25,11 @@ import urllib.request
25
25
  from datetime import date, datetime, timedelta, timezone
26
26
  from pathlib import Path
27
27
 
28
+ try: # component-aware-add registry parse (Python 3.11+ stdlib)
29
+ import tomllib
30
+ except ModuleNotFoundError: # < 3.11: the registry is unsupported → degrade to opt-out
31
+ tomllib = None
32
+
28
33
  # --- constants ---------------------------------------------------------------
29
34
 
30
35
  ROOT_DIRNAME = ".add"
@@ -116,6 +121,16 @@ GUIDELINE_FILES = ("AGENTS.md", "CLAUDE.md")
116
121
  _GUIDE_BEGIN = "<!-- ADD:BEGIN — managed by `add.py sync-guidelines`; do not edit inside -->"
117
122
  _GUIDE_END = "<!-- ADD:END -->"
118
123
 
124
+ # Rule-file mode (ccsk-style projects): instead of inlining the block into CLAUDE.md,
125
+ # write it to a dedicated rule file under .claude/rules/ and leave a one-line reference
126
+ # in CLAUDE.md's Workflows section. .claude/rules/ is a CLAUDE-only convention, so this
127
+ # mode only ever relocates CLAUDE.md — AGENTS.md/.clinerules keep the inline block.
128
+ RULES_FILE_REL = Path(".claude") / "rules" / "add-workflows.md"
129
+ # Headings (most→least specific) a project may already use to group rule/workflow links.
130
+ # Match is case-insensitive on the heading TEXT, at any `#` level.
131
+ WORKFLOW_HEADINGS = ("Rules & Workflows", "Workflows", "Rules")
132
+ _RULE_REF_LINE = "- ADD (AI-Driven Development) Workflows rules: ./.claude/rules/add-workflows.md"
133
+
119
134
  # Minimal embedded fallback so the tool still works if templates/ is missing
120
135
  # (circuit breaker: never hard-fail just because a template file was deleted).
121
136
  _FALLBACK_TASK = """# TASK: {title}
@@ -215,6 +230,20 @@ def _atomic_write(path: Path, text: str) -> None:
215
230
  os.unlink(tmp)
216
231
 
217
232
 
233
+ def _atomic_write_bytes(path: Path, data: bytes) -> None:
234
+ """Binary sibling of `_atomic_write` — lands `data` UNCHANGED (no newline translation), so a
235
+ byte-for-byte copy stays exact. Same temp-then-replace crash-safety."""
236
+ path.parent.mkdir(parents=True, exist_ok=True)
237
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
238
+ try:
239
+ with os.fdopen(fd, "wb") as fh:
240
+ fh.write(data)
241
+ os.replace(tmp, path)
242
+ finally:
243
+ if os.path.exists(tmp):
244
+ os.unlink(tmp)
245
+
246
+
218
247
  def _atomic_write_many(writes: list[tuple[Path, str]]) -> None:
219
248
  """True all-or-nothing commit across N files — design-for-failure for a multi-file write.
220
249
 
@@ -655,6 +684,13 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
655
684
  new = text
656
685
  for pat, repl in rules:
657
686
  new = re.sub(pat, repl, new, count=1)
687
+ # component-aware-add (per-component-verify): record WHICH green-bar a bound task gated
688
+ # against, right after the Outcome line. Unbound / no green_bar -> no line (byte-identical).
689
+ _bar = _task_green_bar(root, slug)
690
+ if _bar:
691
+ _line = f"component: {_task_component(root, slug)} · expected green-bar: {_bar}"
692
+ if _line not in new:
693
+ new = re.sub(r"(?m)^(Outcome:.*$)", lambda m: m.group(1) + "\n" + _line, new, count=1)
658
694
  if new != text: # no-op = no write (mtime stable)
659
695
  _atomic_write(f, new)
660
696
 
@@ -740,17 +776,131 @@ def _inject_block(path: Path) -> str:
740
776
  return "created"
741
777
 
742
778
 
743
- def _inject_guidelines(project_root: Path) -> list[tuple[str, str]]:
779
+ def _rule_file_mode(project_root: Path, flag: bool = False) -> bool:
780
+ """True when the ADD block should live in .claude/rules/add-workflows.md (referenced
781
+ from CLAUDE.md) instead of inline. Re-derived from disk EACH phase — no persisted
782
+ state — so an explicit `--rule-file` at install carries into init via the rule file it
783
+ leaves behind. Three triggers: the explicit flag, a ccsk project (.ccsk/ sibling to
784
+ .claude/), or a rule file already written by a prior run. Pure + fail-soft."""
785
+ if flag:
786
+ return True
787
+ try:
788
+ if (project_root / ".ccsk").is_dir():
789
+ return True
790
+ if (project_root / RULES_FILE_REL).exists():
791
+ return True
792
+ except OSError:
793
+ pass
794
+ return False
795
+
796
+
797
+ def _strip_inline_block(text: str) -> str:
798
+ """Remove an inline ADD:BEGIN..ADD:END region (migration to rule-file mode), collapsing
799
+ the blank-line gap it leaves behind. An unterminated BEGIN (no END) is left as-is rather
800
+ than eating the rest of the file (design-for-failure)."""
801
+ begin = text.find(_GUIDE_BEGIN)
802
+ if begin == -1:
803
+ return text
804
+ end = text.find(_GUIDE_END, begin)
805
+ if end == -1:
806
+ return text
807
+ end += len(_GUIDE_END)
808
+ head = text[:begin].rstrip("\n")
809
+ tail = text[end:].lstrip("\n")
810
+ if head and tail:
811
+ return head + "\n\n" + tail
812
+ return head or tail
813
+
814
+
815
+ def _insert_rule_reference(text: str) -> str:
816
+ """Insert the ADD rule-file bullet under an existing Workflows/Rules heading, or append a
817
+ fresh '## Workflows' section when none is found. Caller guarantees the bullet is absent."""
818
+ lines = text.split("\n")
819
+ heading_idx = -1
820
+ for i, line in enumerate(lines):
821
+ m = re.match(r"^(#{1,6})\s+(.*?)\s*$", line)
822
+ if m and any(m.group(2).strip().lower() == h.lower() for h in WORKFLOW_HEADINGS):
823
+ heading_idx = i
824
+ break
825
+ if heading_idx == -1: # no section — append a fresh one
826
+ body = text.rstrip("\n")
827
+ sep = "\n\n" if body else ""
828
+ return f"{body}{sep}## Workflows\n\n{_RULE_REF_LINE}\n"
829
+ level = len(re.match(r"^(#{1,6})", lines[heading_idx]).group(1))
830
+ end = len(lines) # section ends at next same/higher heading or EOF
831
+ for j in range(heading_idx + 1, len(lines)):
832
+ m = re.match(r"^(#{1,6})\s+", lines[j])
833
+ if m and len(m.group(1)) <= level:
834
+ end = j
835
+ break
836
+ insert_at = heading_idx + 1 # after the last non-blank line in the section
837
+ for j in range(heading_idx + 1, end):
838
+ if lines[j].strip():
839
+ insert_at = j + 1
840
+ lines.insert(insert_at, _RULE_REF_LINE)
841
+ return "\n".join(lines)
842
+
843
+
844
+ def _ensure_claude_reference(claude_md: Path) -> str:
845
+ """Make CLAUDE.md reference the ADD rule file under a Workflows/Rules heading, migrating
846
+ any prior inline ADD block out. Returns created|updated|unchanged.
847
+
848
+ - Strips a prior inline ADD:BEGIN..END block (rule-file mode supersedes inline).
849
+ - If a reference to add-workflows.md already exists -> no bullet change.
850
+ - Else inserts the bullet into the first matching section, or appends '## Workflows'.
851
+ .bak on change; idempotent. User content outside the touched region is preserved.
852
+ """
853
+ existed = claude_md.exists()
854
+ current = claude_md.read_text(encoding="utf-8") if existed else ""
855
+ new = _strip_inline_block(current)
856
+ if "add-workflows.md" not in new:
857
+ new = _insert_rule_reference(new)
858
+ if not new.endswith("\n"):
859
+ new += "\n"
860
+ if new == current:
861
+ return "unchanged"
862
+ if existed:
863
+ _atomic_write(Path(str(claude_md) + ".bak"), current) # rollback path before mutate
864
+ _atomic_write(claude_md, new)
865
+ return "updated" if existed else "created"
866
+
867
+
868
+ def _inject_guidelines(project_root: Path, rule_file: bool = False) -> list[tuple[str, str]]:
744
869
  """Inject the block into each guideline file under `project_root`.
745
870
 
746
871
  Symlink-dedup: targets resolving (os.path.realpath) to the same inode are
747
872
  written once, against the REAL file (never replacing the symlink with a
748
873
  regular file). Per-target OSError is isolated (warn+skip) so one unwritable
749
874
  file never aborts the run or `init`.
875
+
876
+ Rule-file mode (ccsk projects / `--rule-file`): CLAUDE.md's full block is relocated
877
+ to .claude/rules/add-workflows.md and CLAUDE.md keeps only a reference bullet. This is
878
+ CLAUDE-only — AGENTS.md (and any other guideline file) keeps the inline block.
750
879
  """
751
880
  results: list[tuple[str, str]] = []
752
881
  seen: set[str] = set()
882
+ mode = _rule_file_mode(project_root, rule_file)
753
883
  for name in GUIDELINE_FILES:
884
+ if name == "CLAUDE.md" and mode:
885
+ rules_path = project_root / RULES_FILE_REL
886
+ real = os.path.realpath(rules_path)
887
+ if real not in seen:
888
+ seen.add(real)
889
+ try:
890
+ action = _inject_block(rules_path)
891
+ except (OSError, UnicodeDecodeError) as exc:
892
+ print(f"add: warning: could not sync {RULES_FILE_REL} — {exc}; skipped",
893
+ file=sys.stderr)
894
+ action = "skipped"
895
+ results.append((str(RULES_FILE_REL), action))
896
+ try:
897
+ ref_action = _ensure_claude_reference(project_root / "CLAUDE.md")
898
+ except (OSError, UnicodeDecodeError) as exc:
899
+ print(f"add: warning: could not sync CLAUDE.md — {exc}; skipped",
900
+ file=sys.stderr)
901
+ ref_action = "skipped"
902
+ results.append(("CLAUDE.md", ref_action))
903
+ continue
754
904
  target = project_root / name
755
905
  real = os.path.realpath(target)
756
906
  if real in seen:
@@ -841,7 +991,7 @@ def cmd_init(args: argparse.Namespace) -> None:
841
991
  state["setup"] = {"locked": False, "locked_at": None, "locked_by": None, "layers": []}
842
992
  save_state(root, state)
843
993
  # zero-config: give any agent a stable pointer into the ADD runtime.
844
- for name, action in _inject_guidelines(base):
994
+ for name, action in _inject_guidelines(base, getattr(args, "rule_file", False)):
845
995
  if action != "unchanged":
846
996
  print(f"{action:>9} {name}")
847
997
  print(f"initialised ADD project '{state['project']}' (stage: {state['stage']}) at {root}")
@@ -857,7 +1007,7 @@ def cmd_init(args: argparse.Namespace) -> None:
857
1007
 
858
1008
  def cmd_sync_guidelines(args: argparse.Namespace) -> None:
859
1009
  project_root = _require_root().parent
860
- for name, action in _inject_guidelines(project_root):
1010
+ for name, action in _inject_guidelines(project_root, getattr(args, "rule_file", False)):
861
1011
  print(f"{action:>9} {name}")
862
1012
 
863
1013
 
@@ -1061,6 +1211,19 @@ def cmd_advance(args: argparse.Namespace) -> None:
1061
1211
  # into build/verify/observe/done is refused until `add.py lock`.
1062
1212
  if not _setup_locked(state) and nxt in ("build", "verify", "observe", "done"):
1063
1213
  _die("setup_unlocked: lock the foundation first — add.py lock")
1214
+ # intra-milestone cross-component HOLD (cross-component-milestone): a consumer of a DECLARED
1215
+ # contract may not enter §3 (scenarios->contract) until its producer froze — proven by the
1216
+ # snapshot the producer's contract->tests crossing writes (task 3). This orders a BE→FE slice
1217
+ # inside ONE milestone (the FE stays downstream of the frozen endpoint). Validate-before-write:
1218
+ # the HARD-STOP precedes the phase bump, so the task stays at `scenarios`. Undeclared id / no
1219
+ # `consumes:` header -> no hold (byte-identical; a typo'd id is a cmd_check registry finding).
1220
+ if nxt == "contract":
1221
+ _cid = _task_consumes(root, slug)
1222
+ _cmap = _contracts(root)
1223
+ if _cid and _cid in _cmap and not _contract_snapshot(root, _cid).exists():
1224
+ _die(f"producer_contract_unfrozen: the producer '{_cmap[_cid].get('producer', '?')}' of "
1225
+ f"contract '{_cid}' must freeze its contract before you write §3 — wait for "
1226
+ f".add/contracts/{_cid}.json")
1064
1227
  # flag-first freeze guard (task unflagged-freeze): a FROZEN §3 may not cross
1065
1228
  # into build without a WELL-FORMED lowest-confidence flag. On pass, stamp the
1066
1229
  # verified marker so `audit` enforces the flag on THIS record only (open/new
@@ -1124,6 +1287,44 @@ def cmd_advance(args: argparse.Namespace) -> None:
1124
1287
  side.unlink()
1125
1288
  except OSError:
1126
1289
  pass
1290
+ # cross-component contract artifact (cross-component-contract): the contract->tests crossing
1291
+ # is the producer's freeze-approval moment. A `produces:` task WRITES the immutable snapshot;
1292
+ # a `consumes:` task PINS the live hash — a missing/unreadable snapshot HARD-STOPS here (the
1293
+ # phase stays at `contract`, nothing pinned), so a consumer never builds against a guessed
1294
+ # shape. No role / no contracts ⇒ neither branch is taken (byte-identical).
1295
+ if nxt == "tests":
1296
+ _prod = _task_produces(root, slug)
1297
+ if _prod:
1298
+ raw3c = _raw_phase_bodies(root, slug).get(3, "")
1299
+ vm = re.search(r"FROZEN @ (v\d+)", raw3c)
1300
+ snap = {"id": _prod, "producer": (_contracts(root).get(_prod) or {}).get("producer", "?"),
1301
+ "task": slug, "version": vm.group(1) if vm else "?",
1302
+ "frozen": date.today().isoformat(), "hash": _contract_body_hash(raw3c)}
1303
+ sp = _contract_snapshot(root, _prod)
1304
+ cur_snap = None
1305
+ if sp.exists():
1306
+ try:
1307
+ cur_snap = json.loads(sp.read_text(encoding="utf-8"))
1308
+ except (OSError, ValueError):
1309
+ cur_snap = None
1310
+ # idempotent: re-write only when the shape-hash or version actually changed (so a
1311
+ # no-op re-cross leaves the file — and its `frozen` date — byte-identical).
1312
+ if not (cur_snap and cur_snap.get("hash") == snap["hash"]
1313
+ and cur_snap.get("version") == snap["version"]):
1314
+ sp.parent.mkdir(parents=True, exist_ok=True)
1315
+ _atomic_write(sp, json.dumps(snap, sort_keys=True))
1316
+ _cons = _task_consumes(root, slug)
1317
+ if _cons:
1318
+ sp = _contract_snapshot(root, _cons)
1319
+ try:
1320
+ pinned = json.loads(sp.read_text(encoding="utf-8")).get("hash")
1321
+ except (OSError, ValueError, AttributeError):
1322
+ pinned = None
1323
+ if not pinned: # absent / unreadable / valid-JSON-but-no-hash all fail loud
1324
+ _die(f"contract_snapshot_missing: no readable hashed .add/contracts/{_cons}.json — the "
1325
+ f"producer of '{_cons}' must freeze its contract first "
1326
+ "(never build against a guessed shape)")
1327
+ state["tasks"][slug]["contract_pin"] = {"id": _cons, "hash": pinned}
1127
1328
  state["tasks"][slug]["phase"] = nxt
1128
1329
  state["tasks"][slug]["updated"] = _now()
1129
1330
  _sync_task_marker(root, slug, nxt)
@@ -1162,6 +1363,14 @@ _AUTONOMY_LEVELS = ("manual", "conservative", "auto")
1162
1363
  # and reads as UNSET.
1163
1364
  _AUTONOMY_LINE_RE = re.compile(r"(?:^|·)[ \t]*autonomy:[ \t]*([^\s<#|]+)", re.MULTILINE)
1164
1365
 
1366
+ # component-aware-add: a task binds to a registered component via a `component: <name>`
1367
+ # header token — the SAME anchored grammar as autonomy (line-start or the `·`-inline
1368
+ # slug-line form), and an unfilled `<name>` placeholder captures nothing (reads UNBOUND).
1369
+ _COMPONENT_LINE_RE = re.compile(r"(?:^|·)[ \t]*component:[ \t]*([^\s<#|]+)", re.MULTILINE)
1370
+ # cross-component-contract: a task's role in a cross-component seam — same anchored grammar.
1371
+ _PRODUCES_LINE_RE = re.compile(r"(?:^|·)[ \t]*produces:[ \t]*([^\s<#|]+)", re.MULTILINE)
1372
+ _CONSUMES_LINE_RE = re.compile(r"(?:^|·)[ \t]*consumes:[ \t]*([^\s<#|]+)", re.MULTILINE)
1373
+
1165
1374
 
1166
1375
  def _autonomy_level(hdr: str):
1167
1376
  """The declared autonomy rung from a TASK.md header region (HTML comments
@@ -1260,6 +1469,18 @@ def cmd_gate(args: argparse.Namespace) -> None:
1260
1469
  # §5 scope gate (build-scope-lock): touched ⊆ declared, or a named refusal —
1261
1470
  # same placement discipline as the tripwire (before the waiver, never on HARD-STOP).
1262
1471
  _scope_guard(root, state, slug)
1472
+ # per-component verify (component-aware-add): a component-bound task with a declared
1473
+ # green_bar must CITE that bar in its §6 evidence before a completing outcome — the
1474
+ # engine never runs the suite, it checks the right bar was recorded. Unbound / no
1475
+ # green_bar -> _bar is None -> this is skipped (byte-identical). HARD-STOP never here.
1476
+ _bar = _task_green_bar(root, slug)
1477
+ # the cite must live in the user-authored Build-expectations evidence region (_cite_region,
1478
+ # v3): excludes the §6 checklist boilerplate + the GATE RECORD template/stamp, works for both
1479
+ # the standard and fast-lane §6 shapes. Unbound / no green_bar -> _bar None -> skipped.
1480
+ if _bar and _bar not in _cite_region(_raw_phase_bodies(root, slug).get(6, "")):
1481
+ _die(f"component_green_bar_uncited: task '{slug}' is bound to component "
1482
+ f"'{_task_component(root, slug)}'; its §6 Build-expectations must cite the "
1483
+ f"green-bar '{_bar}' — record the evidence that bar was met before PASS")
1263
1484
  if args.outcome == "RISK-ACCEPTED":
1264
1485
  # A waiver must be SIGNED: owner, ticket, expiry (glossary). Stored in state
1265
1486
  # so a later `check` can read/expire it. Refuse a partial waiver outright.
@@ -1279,6 +1500,9 @@ def cmd_gate(args: argparse.Namespace) -> None:
1279
1500
  save_state(root, state)
1280
1501
  _stamp_gate_record(root, state, slug, args.outcome) # mirror the verdict into §6 (Finding C)
1281
1502
  print(f"task '{slug}' gate -> {args.outcome}")
1503
+ _gbar = _task_green_bar(root, slug) # per-component-verify: surface the bound bar
1504
+ if _gbar:
1505
+ print(f"component: {_task_component(root, slug)} · expected green-bar: {_gbar}")
1282
1506
  # the engine-sourced next step (next-footer-engine): a completing gate hands off to the
1283
1507
  # state arm; HARD-STOP routes to "resolve HARD-STOP …" — converging the old bespoke line.
1284
1508
  print(_next_footer(root, state))
@@ -1575,6 +1799,38 @@ def cmd_stage(args: argparse.Namespace) -> None:
1575
1799
  print(_next_footer(root, state))
1576
1800
 
1577
1801
 
1802
+ def _done_resume(root: Path, state: dict, slug: str) -> tuple[str, str, str]:
1803
+ """At a DONE task, what should the agent do NEXT? Classify from the task's
1804
+ milestone exit-criteria tally (_exit_criteria) so the orient surfaces (status,
1805
+ guide) STEER into the loop instead of always saying "start the next feature".
1806
+
1807
+ Returns (headline, next_step, chapter) where chapter is a docs/ filename:
1808
+ LOOP-JUNCTURE total>0 and met<total -> name the unmet goal, route to the loop
1809
+ GOAL-MET total>0 and met==total -> point at milestone-done
1810
+ PLAIN no criteria / no milestone / any read error -> today's "next feature"
1811
+ PURE and fail-closed (design-for-failure): a missing milestone or unreadable
1812
+ MILESTONE.md degrades to PLAIN — it never raises into a status/guide print path."""
1813
+ PLAIN = ("this task is done",
1814
+ "start the next feature -> add.py new-task <slug>",
1815
+ "02-the-flow.md")
1816
+ try:
1817
+ ms = ((state.get("tasks") or {}).get(slug) or {}).get("milestone")
1818
+ if not ms:
1819
+ return PLAIN
1820
+ met, total = _exit_criteria(root, ms)
1821
+ except Exception: # noqa: BLE001 — never break orient output
1822
+ return PLAIN
1823
+ if total > 0 and met < total:
1824
+ return (f"milestone '{ms}' goal not met ({met}/{total} exit criteria)",
1825
+ "propose the next tasks from open deltas / the unscaffolded plan -> add.py deltas",
1826
+ "09-the-loop.md")
1827
+ if total > 0 and met == total:
1828
+ return (f"milestone '{ms}' goal met ({met}/{total})",
1829
+ f"close it -> add.py milestone-done {ms}",
1830
+ "09-the-loop.md")
1831
+ return PLAIN
1832
+
1833
+
1578
1834
  def cmd_status(args: argparse.Namespace) -> None:
1579
1835
  if getattr(args, "json", False):
1580
1836
  root, state = _load_state_for_json()
@@ -1769,8 +2025,16 @@ def cmd_status(args: argparse.Namespace) -> None:
1769
2025
  elif active and active in tasks:
1770
2026
  ph = tasks[active]["phase"]
1771
2027
  if ph == "done":
2028
+ # loop-aware resume (loop-aware-orient): a done task is NOT always "start the
2029
+ # next feature" — if its milestone goal is unmet we are at the loop juncture, so
2030
+ # STEER into the loop; if met, point at the close. PLAIN stays byte-identical.
2031
+ _hl, _nxt, _chap = _done_resume(root, state, active)
1772
2032
  print(f"\nresume : task '{active}' is done ({tasks[active]['gate']}).")
1773
- print(" start the next feature: add.py new-task <slug>")
2033
+ if _chap == "02-the-flow.md":
2034
+ print(" start the next feature: add.py new-task <slug>")
2035
+ else:
2036
+ print(f" {_hl} — {_nxt}")
2037
+ print(f" (the loop: .add/docs/{_chap})")
1774
2038
  else:
1775
2039
  print(f"\nresume : task '{active}' is at phase '{ph}'.")
1776
2040
  print(f" read .add/tasks/{active}/TASK.md and continue that phase.")
@@ -1820,6 +2084,10 @@ def cmd_guide(args: argparse.Namespace) -> None:
1820
2084
  phase = t.get("phase")
1821
2085
  owner = _phase_owner(phase) # _die unmapped_phase before any stdout
1822
2086
  action, chapter = PHASE_GUIDE[phase] # phase is mapped, so PHASE_GUIDE has it too
2087
+ if phase == "done": # loop-aware-orient: steer the --json surface too
2088
+ _hl, _nxt, _chap = _done_resume(json_root, state, slug)
2089
+ if _chap != "02-the-flow.md": # loop juncture / goal met; PLAIN stays unchanged
2090
+ action, chapter = _nxt, _chap
1823
2091
  print(json.dumps({"task": slug, "phase": phase, "owner": owner,
1824
2092
  "stop": owner != "ai", "next_step": action,
1825
2093
  "chapter": f".add/docs/{chapter}", "gate": t.get("gate"),
@@ -1840,6 +2108,10 @@ def cmd_guide(args: argparse.Namespace) -> None:
1840
2108
  if entry is None: # corrupted/hand-edited state.json — fail clean, not KeyError
1841
2109
  _die(f"task '{slug}' has unknown phase '{phase}' (state.json corrupted?)")
1842
2110
  action, chapter = entry
2111
+ if phase == "done": # loop-aware-orient: steer at the loop juncture
2112
+ _hl, _nxt, _chap = _done_resume(root, state, slug)
2113
+ if _chap != "02-the-flow.md": # loop juncture / goal met; PLAIN stays unchanged
2114
+ action, chapter = _nxt, _chap
1843
2115
  # the guide names the driver too (task gate-owner-marker) — the SAME _driver_stop the
1844
2116
  # footer renders, on the next-step line. Computed AFTER the unknown-phase guard above,
1845
2117
  # so a bad phase fails clean and never reaches the marker (it invents no default).
@@ -1856,7 +2128,10 @@ def cmd_guide(args: argparse.Namespace) -> None:
1856
2128
  if phase == "verify":
1857
2129
  print("then : add.py gate PASS | RISK-ACCEPTED | HARD-STOP")
1858
2130
  elif phase == "done":
1859
- print("then : start the next feature -> add.py new-task <slug>")
2131
+ if chapter != "02-the-flow.md": # loop juncture / goal met -> the steered command
2132
+ print(f"then : {action}")
2133
+ else:
2134
+ print("then : start the next feature -> add.py new-task <slug>")
1860
2135
  else:
1861
2136
  print("then : add.py advance")
1862
2137
 
@@ -2292,6 +2567,41 @@ def _missing_captures(root: Path) -> list[str]:
2292
2567
  if not any((cap_dir / f"{n}.{ext}").is_file() for ext in _CAPTURE_EXTS)]
2293
2568
 
2294
2569
 
2570
+ def cmd_federate(args: argparse.Namespace) -> None:
2571
+ """Multi-repo federation: pull a producer repo's published, immutable contract snapshot into
2572
+ this repo. Mono vs multi-repo differ ONLY in snapshot-transport — this lands the byte-copy at
2573
+ the SAME local `.add/contracts/<id>.json` the monorepo path (tasks 3/4) already reads, so a
2574
+ `consumes: <id>` task then holds/pins identically. Designed-for-failure: unknown / missing /
2575
+ invalid / version-mismatched sources HARD-STOP and land NOTHING (never build blind)."""
2576
+ root = find_root()
2577
+ if root is None:
2578
+ _die("no_project")
2579
+ fid = args.id
2580
+ fed = _federation(root)
2581
+ if fid not in fed:
2582
+ _die(f"federation_unknown: no [federation.{fid}] in components.toml — declare the producer "
2583
+ f"repo's published snapshot source before pulling")
2584
+ source = (root.parent / fed[fid]["source"])
2585
+ try:
2586
+ raw = source.read_bytes() # bytes — the landed snapshot must be a byte-for-byte copy
2587
+ except OSError:
2588
+ _die(f"federation_source_missing: cannot read the producer snapshot at '{fed[fid]['source']}' "
2589
+ f"(resolved {source}) — publish/commit it in the producer repo first")
2590
+ try:
2591
+ snap = json.loads(raw.decode("utf-8"))
2592
+ except (json.JSONDecodeError, ValueError, UnicodeDecodeError):
2593
+ snap = None
2594
+ if not isinstance(snap, dict) or snap.get("id") != fid or not snap.get("hash"):
2595
+ _die(f"federation_snapshot_invalid: the source for '{fid}' is not a valid contract snapshot "
2596
+ f"(needs JSON with matching id + a hash) — refusing to land a guessed shape")
2597
+ pin = fed[fid]["pin"]
2598
+ if pin and snap.get("version") != pin:
2599
+ _die(f"federation_version_mismatch: [federation.{fid}] pins '{pin}' but the source is "
2600
+ f"'{snap.get('version')}' — bump the pin or wait for the producer to publish {pin}")
2601
+ _atomic_write_bytes(_contract_snapshot(root, fid), raw)
2602
+ print(f"federated '{fid}' {snap.get('version', '?')} {snap['hash']} from {fed[fid]['source']}")
2603
+
2604
+
2295
2605
  def cmd_check(args: argparse.Namespace) -> None:
2296
2606
  """Read-only integrity check of the .add project. Exit 1 if anything fails."""
2297
2607
  as_json = getattr(args, "json", False)
@@ -2340,6 +2650,29 @@ def cmd_check(args: argparse.Namespace) -> None:
2340
2650
  if _alvl is None and t.get("phase") not in ("done", "observe"):
2341
2651
  warnings.append((f"task '{slug}'", "has no explicit autonomy level (implicit_autonomy) "
2342
2652
  "— run `add.py autonomy set <level>` to set it"))
2653
+ # per-component-verify: a bound task whose component declares no green_bar can't be
2654
+ # gated on a bar — surface it (WARN, never red). Unbound / "?" -> silent.
2655
+ _tc = _task_component(root, slug)
2656
+ if _tc and _tc != "?" and not (_components(root).get(_tc) or {}).get("green_bar"):
2657
+ warnings.append((f"task '{slug}'", f"component_green_bar_unset — bound component '{_tc}' "
2658
+ "declares no green_bar; the per-component gate cannot check a bar"))
2659
+ # cross-component-contract: a consumer whose pinned hash drifted from the live snapshot
2660
+ # (the producer re-froze a CHANGED shape) — the §7-stale cue. Degrade-safe (unreadable
2661
+ # snapshot ⇒ no finding here; the missing-snapshot HARD-STOP lives at the advance crossing).
2662
+ _pin = t.get("contract_pin")
2663
+ if _pin:
2664
+ try:
2665
+ _live = json.loads(_contract_snapshot(root, _pin["id"]).read_text(encoding="utf-8")).get("hash")
2666
+ except (OSError, ValueError, KeyError, TypeError, AttributeError):
2667
+ _live = None
2668
+ if _live is None: # missing / corrupt / hash-less ⇒ SURFACE, never mask
2669
+ warnings.append((f"task '{slug}'", f"contract_snapshot_unreadable — pinned contract "
2670
+ f"'{_pin.get('id')}' snapshot is missing or corrupt; re-publish the "
2671
+ "producer contract (cannot confirm the pin is current)"))
2672
+ elif _live != _pin.get("hash"):
2673
+ warnings.append((f"task '{slug}'", f"contract_consumer_stale — pinned contract "
2674
+ f"'{_pin.get('id')}' changed shape since pin; re-pin (re-cross contract→tests) "
2675
+ "after reviewing the producer's new frozen shape"))
2343
2676
  for dep in t.get("depends_on") or []:
2344
2677
  checks.append((dep in tasks or dep in archived_slugs,
2345
2678
  f"task '{slug}' dep '{dep}' resolves", "unknown task"))
@@ -2453,6 +2786,26 @@ def cmd_check(args: argparse.Namespace) -> None:
2453
2786
  checks.append((cycle is None, "task dependencies are acyclic",
2454
2787
  f"cycle: {' -> '.join(cycle)}" if cycle else ""))
2455
2788
 
2789
+ # component registry (component-aware-add): a malformed .add/components.toml, a root
2790
+ # escaping the project, or a task binding an unregistered component are integrity FAILS
2791
+ # — fail-closed RED (like wave_ledger_malformed), loud but never a crash (the readers
2792
+ # themselves degrade-safe). Silent when there is no components.toml.
2793
+ for _ccode, _cdetail in _component_findings(root):
2794
+ checks.append((False, f"component registry ({_ccode})", _cdetail))
2795
+ # cross-component-contract: a [contract.<id>] naming an unregistered producer is an
2796
+ # integrity FAIL (same fail-closed RED discipline; the readers stay degrade-safe).
2797
+ for _ccode, _cdetail in _contract_findings(root):
2798
+ checks.append((False, f"contract registry ({_ccode})", _cdetail))
2799
+ # multirepo-federation: a declared [federation.<id>] whose producer-repo source is unreadable
2800
+ # is a BROKEN JOIN — surface it EARLY as a WARN (never red alone; `federate pull` is where it
2801
+ # HARD-STOPs). Silent when no federation is declared (opt-in / byte-identical).
2802
+ for _fid, _fspec in _federation(root).items():
2803
+ if not (root.parent / _fspec["source"]).is_file():
2804
+ warnings.append((f"federation '{_fid}'",
2805
+ f"federation_source_unreadable — the producer snapshot at "
2806
+ f"'{_fspec['source']}' is missing/unreadable; `federate pull {_fid}` "
2807
+ "will hard-stop until the producer repo publishes it"))
2808
+
2456
2809
  # UDD foundation (udd-check-lint): lint a project's named set under .add/design/ —
2457
2810
  # composes the token + catalog/tree validators + the cross-file prop-token resolution.
2458
2811
  # Silent when absent; read-only; fail-closed on malformed JSON.
@@ -3701,6 +4054,204 @@ _SCOPE_EXCLUDE_FILES = (".DS_Store",) # plus *.pyc / *.tsbuildi
3701
4054
  _SCOPE_EXCLUDE_SUFFIXES = (".pyc", ".tsbuildinfo")
3702
4055
 
3703
4056
 
4057
+ # ── component registry (component-aware-add): declared components + task binding ─────
4058
+ # OPT-IN + DEGRADE-SAFE: with no .add/components.toml every reader is byte-identical to
4059
+ # pre-component ADD. A read NEVER raises (absent/unreadable/malformed → {} / dropped
4060
+ # cover); the loud surface is _component_findings, consumed by the scope gate (cmd_check).
4061
+ def _components(root: Path) -> dict[str, dict]:
4062
+ """The registry from .add/components.toml → {name: {root, verify, green_bar,
4063
+ language}}. `root` required per entry; an entry missing it is skipped (the finding
4064
+ surface reports it). `verify` is stored OPAQUE — parsed as data, NEVER executed. PURE."""
4065
+ if tomllib is None:
4066
+ return {}
4067
+ try:
4068
+ raw = (root / "components.toml").read_bytes()
4069
+ except OSError:
4070
+ return {}
4071
+ try:
4072
+ data = tomllib.loads(raw.decode("utf-8"))
4073
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
4074
+ return {}
4075
+ out: dict[str, dict] = {}
4076
+ for name, spec in (data.get("component") or {}).items():
4077
+ # "?" is the reserved unknown-binding sentinel (_task_component) — a component
4078
+ # named "?" would collide and silently drop cover, so it never registers.
4079
+ if name == "?" or not isinstance(spec, dict) or not isinstance(spec.get("root"), str):
4080
+ continue
4081
+ out[name] = {"root": spec["root"], "verify": spec.get("verify"),
4082
+ "green_bar": spec.get("green_bar"), "language": spec.get("language")}
4083
+ return out
4084
+
4085
+
4086
+ def _component_root(root: Path, name: str) -> str | None:
4087
+ """Project-root-relative path (trailing '/') of component `name`'s root, or None
4088
+ when the name is absent OR the root escapes the project (fail-closed — grants no
4089
+ scope cover, mirroring _declared_scope's _confined drop). PURE."""
4090
+ spec = _components(root).get(name)
4091
+ if not spec:
4092
+ return None
4093
+ rootp = root.parent.resolve()
4094
+ p = root.parent / spec["root"]
4095
+ if not _confined(p, rootp):
4096
+ return None
4097
+ try:
4098
+ return str(p.resolve().relative_to(rootp)).rstrip("/") + "/"
4099
+ except (OSError, ValueError):
4100
+ return None
4101
+
4102
+
4103
+ def _task_component(root: Path, slug: str):
4104
+ """The component a task binds to via its `component:` header token (anchored like
4105
+ autonomy). None = no line / unfilled `<…>` placeholder; "?" = a real token absent
4106
+ from the registry; otherwise the component name. PURE."""
4107
+ m = _COMPONENT_LINE_RE.search(_task_header(root, slug))
4108
+ if not m:
4109
+ return None
4110
+ tok = m.group(1).strip()
4111
+ return tok if tok in _components(root) else "?"
4112
+
4113
+
4114
+ def _task_green_bar(root: Path, slug: str) -> str | None:
4115
+ """The green_bar phrase of the task's bound component (per-component-verify), else
4116
+ None — unbound, "?", or no green_bar declared all yield None. PURE."""
4117
+ comp = _task_component(root, slug)
4118
+ if not comp or comp == "?":
4119
+ return None
4120
+ return (_components(root).get(comp) or {}).get("green_bar") or None
4121
+
4122
+
4123
+ def _cite_region(body: str) -> str:
4124
+ """The user-authored "Build expectations" evidence region of a §6 body, stamp-stripped —
4125
+ the only place a per-component green-bar cite counts (per-component-verify, v3). PURE.
4126
+
4127
+ The marker matches BOTH template shapes: the standard "### Build expectations …" heading AND
4128
+ the fast-lane bare "Build expectations (from …):" line, running up to the GATE RECORD sub-block.
4129
+ So the top-of-§6 checklist ("- [ ] all tests pass") and the "Outcome: <PASS|…>" placeholder are
4130
+ excluded, and a component-bound FAST task is still citable. The trailing strip removes the
4131
+ engine's own "component: … · expected green-bar: …" stamp wherever it landed, so a stamp that
4132
+ fell inside the region can never self-satisfy the gate. No marker -> "" (fail-closed for a bound
4133
+ task: it must declare its evidence)."""
4134
+ m = re.search(r"(?im)^#*[ \t]*Build expectations\b.*?(?=\n#+[ \t]*GATE RECORD\b|\Z)", body, re.DOTALL)
4135
+ region = m.group(0) if m else ""
4136
+ return re.sub(r"(?m)^component:.*·.*expected green-bar:.*$", "", region)
4137
+
4138
+
4139
+ def _component_findings(root: Path) -> list[tuple[str, str]]:
4140
+ """The loud gate surface for the registry — the codes a degrade-safe read passes
4141
+ over silently. Consumed by cmd_check (the scope_violation surface). [] when clean."""
4142
+ findings: list[tuple[str, str]] = []
4143
+ try:
4144
+ raw = (root / "components.toml").read_bytes()
4145
+ except OSError:
4146
+ return findings # absent/unreadable = opt-out, nothing to report
4147
+ data = None
4148
+ if tomllib is None:
4149
+ findings.append(("components_malformed", "components.toml present but tomllib unavailable (Python < 3.11)"))
4150
+ else:
4151
+ try:
4152
+ data = tomllib.loads(raw.decode("utf-8"))
4153
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError) as e:
4154
+ findings.append(("components_malformed", f"components.toml: {e}"))
4155
+ if data is not None:
4156
+ rootp = root.parent.resolve()
4157
+ for name, spec in (data.get("component") or {}).items():
4158
+ if name == "?":
4159
+ findings.append(("components_malformed", "component name '?' is reserved (the unknown-binding sentinel)"))
4160
+ continue
4161
+ if not isinstance(spec, dict) or not isinstance(spec.get("root"), str):
4162
+ findings.append(("components_malformed", f"[component.{name}] missing required `root`"))
4163
+ continue
4164
+ if not _confined(root.parent / spec["root"], rootp):
4165
+ findings.append(("component_root_outside", f"[component.{name}] root {spec['root']!r} escapes the project"))
4166
+ known = set(_components(root))
4167
+ try:
4168
+ task_dirs = sorted(p for p in (root / "tasks").iterdir() if p.is_dir())
4169
+ except OSError:
4170
+ task_dirs = [] # unreadable tasks/ degrades safe — never crash a read
4171
+ for d in task_dirs:
4172
+ tc = _task_component(root, d.name)
4173
+ if tc is not None and tc not in known: # "?" or a stale name
4174
+ findings.append(("component_unknown", f"task {d.name} binds an unregistered component"))
4175
+ return findings
4176
+
4177
+
4178
+ # ── cross-component contracts (cross-component-contract) ──────────────────────────────────
4179
+ # OPT-IN + DEGRADE-SAFE, like the component readers: no [contract.*] / no produces|consumes
4180
+ # header ⇒ every path below is byte-identical to pre-contract ADD. A read NEVER raises.
4181
+ def _contracts(root: Path) -> dict[str, dict]:
4182
+ """[contract.<id>] from .add/components.toml -> {id: {producer: str, consumers: list[str]}}.
4183
+ A malformed entry (producer not a str) is skipped (the finding surface reports it). PURE."""
4184
+ if tomllib is None:
4185
+ return {}
4186
+ try:
4187
+ data = tomllib.loads((root / "components.toml").read_bytes().decode("utf-8"))
4188
+ except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
4189
+ return {}
4190
+ out: dict[str, dict] = {}
4191
+ for cid, spec in (data.get("contract") or {}).items():
4192
+ if not isinstance(spec, dict) or not isinstance(spec.get("producer"), str):
4193
+ continue
4194
+ cons = spec.get("consumers")
4195
+ out[cid] = {"producer": spec["producer"],
4196
+ "consumers": [c for c in cons if isinstance(c, str)] if isinstance(cons, list) else []}
4197
+ return out
4198
+
4199
+
4200
+ def _federation(root: Path) -> dict[str, dict]:
4201
+ """[federation.<id>] from .add/components.toml -> {id: {source: str, pin: str|None}}.
4202
+ The cross-REPO join: a consumer repo names where a producer repo's published snapshot lives.
4203
+ A malformed entry (no string source) is skipped; a non-string `pin` degrades to None. Degrade-safe
4204
+ — never raises. PURE. On Python < 3.11 (no tomllib) this returns {} like the other component
4205
+ readers, so `federate` reports federation_unknown — components.toml needs a 3.11+ runtime."""
4206
+ if tomllib is None:
4207
+ return {}
4208
+ try:
4209
+ data = tomllib.loads((root / "components.toml").read_bytes().decode("utf-8"))
4210
+ except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
4211
+ return {}
4212
+ out: dict[str, dict] = {}
4213
+ for fid, spec in (data.get("federation") or {}).items():
4214
+ if not isinstance(spec, dict) or not isinstance(spec.get("source"), str):
4215
+ continue
4216
+ pin = spec.get("pin")
4217
+ out[fid] = {"source": spec["source"], "pin": pin if isinstance(pin, str) else None}
4218
+ return out
4219
+
4220
+
4221
+ def _task_produces(root: Path, slug: str) -> str | None:
4222
+ m = _PRODUCES_LINE_RE.search(_task_header(root, slug))
4223
+ return m.group(1).strip() if m else None
4224
+
4225
+
4226
+ def _task_consumes(root: Path, slug: str) -> str | None:
4227
+ m = _CONSUMES_LINE_RE.search(_task_header(root, slug))
4228
+ return m.group(1).strip() if m else None
4229
+
4230
+
4231
+ def _contract_snapshot(root: Path, cid: str) -> Path:
4232
+ return root / "contracts" / f"{cid}.json"
4233
+
4234
+
4235
+ def _contract_body_hash(raw3: str) -> str:
4236
+ """md5 of the §3 contract SHAPE — the first ```fenced``` block, whitespace-normalized. The
4237
+ version stamp + freeze flags are excluded (fallback strips Status:/flag/change-request lines)
4238
+ so a pure version bump does NOT churn pinned consumers stale. PURE."""
4239
+ m = re.search(r"```(.*?)```", raw3, re.DOTALL)
4240
+ body = m.group(1) if m else re.sub(r"(?m)^(Status:|.*surfaced at freeze:|v\d+ CHANGE REQUEST).*$", "", raw3)
4241
+ return _md5_text(re.sub(r"\s+", " ", body).strip())
4242
+
4243
+
4244
+ def _contract_findings(root: Path) -> list[tuple[str, str]]:
4245
+ """The loud gate surface for cross-component contracts — [] when clean / opted-out."""
4246
+ findings: list[tuple[str, str]] = []
4247
+ known = set(_components(root))
4248
+ for cid, spec in _contracts(root).items():
4249
+ if spec["producer"] not in known:
4250
+ findings.append(("contract_producer_unknown",
4251
+ f"[contract.{cid}] producer {spec['producer']!r} is not a declared component"))
4252
+ return findings
4253
+
4254
+
3704
4255
  def _declared_scope(root: Path, slug: str) -> list[str] | None:
3705
4256
  """Resolve the §5 'Scope (may touch):' declaration to project-root-relative
3706
4257
  strings (directory tokens keep a trailing '/'). The frozen scope-decl-template
@@ -3710,11 +4261,19 @@ def _declared_scope(root: Path, slug: str) -> list[str] | None:
3710
4261
  root, fail-closed — with ONE divergence: a directory token covers its WHOLE
3711
4262
  subtree (containment, judged by _in_scope). None = no Scope line (UNDECLARED,
3712
4263
  grandfathered — never retro-red); [] = a line whose every token was dropped
3713
- (a garbage declaration grants NO cover)."""
4264
+ (a garbage declaration grants NO cover).
4265
+
4266
+ component-aware-add: when the task binds a known `component:` (_task_component),
4267
+ that component's root subtree (_component_root) is APPENDED to the resolved tokens
4268
+ (dedup) — composing with the explicit declaration, never redrawing token resolution.
4269
+ A bound task with NO Scope line returns [component_root] (not None); an UNBOUND task
4270
+ is byte-identical to before."""
4271
+ comp = _task_component(root, slug)
4272
+ croot = _component_root(root, comp) if comp and comp != "?" else None
3714
4273
  body = _raw_phase_bodies(root, slug).get(5, "")
3715
4274
  m = re.search(r"^\s*Scope \(may touch\):.*$", body, re.M)
3716
4275
  if not m:
3717
- return None
4276
+ return [croot] if croot else None
3718
4277
  tdir = root / "tasks" / slug
3719
4278
  rootp = root.parent.resolve()
3720
4279
  out: list[str] = []
@@ -3740,6 +4299,8 @@ def _declared_scope(root: Path, slug: str) -> list[str] | None:
3740
4299
  continue
3741
4300
  if rel not in out:
3742
4301
  out.append(rel)
4302
+ if croot and croot not in out: # component-aware-add: compose, never redraw
4303
+ out.append(croot)
3743
4304
  return out
3744
4305
 
3745
4306
 
@@ -5847,6 +6408,9 @@ def build_parser() -> argparse.ArgumentParser:
5847
6408
  pi.add_argument("--force", action="store_true", help="reset state.json if present")
5848
6409
  pi.add_argument("--await-lock", dest="await_lock", action="store_true",
5849
6410
  help="seed an unlocked setup; gates new-task/advance/gate until `add.py lock`")
6411
+ pi.add_argument("--rule-file", dest="rule_file", action="store_true",
6412
+ help="write the ADD block to .claude/rules/add-workflows.md and reference it "
6413
+ "from CLAUDE.md (auto-on for ccsk projects with a .ccsk/ dir)")
5850
6414
  pi.set_defaults(func=cmd_init)
5851
6415
 
5852
6416
  pl = sub.add_parser("lock",
@@ -6033,6 +6597,14 @@ def build_parser() -> argparse.ArgumentParser:
6033
6597
  pck.add_argument("--json", action="store_true", help="machine-readable JSON output")
6034
6598
  pck.set_defaults(func=cmd_check)
6035
6599
 
6600
+ pfed = sub.add_parser("federate", help="multi-repo: pull a producer repo's published, immutable "
6601
+ "contract snapshot into this repo (fail-loud)")
6602
+ pfedsub = pfed.add_subparsers(dest="action", required=True)
6603
+ pfedpull = pfedsub.add_parser("pull", help="land [federation.<id>].source at the local "
6604
+ ".add/contracts/<id>.json (hard-stops on a bad source)")
6605
+ pfedpull.add_argument("id", help="the contract id declared under [federation.<id>]")
6606
+ pfedpull.set_defaults(func=cmd_federate)
6607
+
6036
6608
  pdoc = sub.add_parser("doctor", help="read-only diagnosis of state.json integrity + "
6037
6609
  "referential consistency (run after a git merge)")
6038
6610
  pdoc.set_defaults(func=cmd_doctor)
@@ -6053,6 +6625,9 @@ def build_parser() -> argparse.ArgumentParser:
6053
6625
 
6054
6626
  psg = sub.add_parser("sync-guidelines",
6055
6627
  help="(re)write the ADD guideline block into AGENTS.md + CLAUDE.md")
6628
+ psg.add_argument("--rule-file", dest="rule_file", action="store_true",
6629
+ help="relocate CLAUDE.md's block to .claude/rules/add-workflows.md + reference "
6630
+ "it (auto-on for ccsk projects)")
6056
6631
  psg.set_defaults(func=cmd_sync_guidelines)
6057
6632
 
6058
6633
  pgd = sub.add_parser("guide", help="print the one concrete next step for the active task")
@@ -1,7 +1,7 @@
1
1
  # TASK: {{title}}
2
2
 
3
3
  slug: {{slug}} · created: {{date}} · stage: {{stage}}
4
- autonomy: {{autonomy}} <!-- inherited from the project default (PROJECT.md); explicit level: manual < conservative < auto (visible · overridable) — lower below if a high-risk task needs it, or run `add.py autonomy set`. -->
4
+ autonomy: {{autonomy}} <!-- inherited from the project default (PROJECT.md); explicit level: manual < conservative < auto (visible · overridable) — lower below if a high-risk task needs it, or run `add.py autonomy set`. Multi-component repo (monorepo/multi-repo)? add a `component: <name>` line (declared in `.add/components.toml`) to ADD that component's root to your §5 Scope; omit for single-component projects (byte-identical default). -->
5
5
  phase: ground <!-- ground -> specify -> scenarios -> contract -> tests -> build -> verify -> observe -> done -->
6
6
  <!-- high-risk/method-defining scope? declare `risk: high` on the slug line above and lower the
7
7
  autonomy level to `manual` or `conservative` — the engine refuses an unguarded completion