@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 +64 -0
- package/bin/cli.js +137 -2
- package/docs/16-releasing.md +2 -2
- package/docs/17-components.md +125 -0
- package/docs/appendix-c-glossary.md +6 -0
- package/package.json +2 -2
- package/skill/add/SKILL.md +4 -0
- package/skill/add/components.md +54 -0
- package/tooling/add.py +582 -7
- package/tooling/templates/TASK.md.tmpl +1 -1
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
|
-
|
|
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
|
};
|
package/docs/16-releasing.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 16 · Releasing
|
|
2
2
|
|
|
3
|
-
[← 15 Foundations & Lineage](./15-foundations-and-lineage.md) · [Contents](./README.md) · Next: [
|
|
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: [
|
|
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.
|
|
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.
|
|
49
|
+
"homepage": "https://pilotspace.github.io/ADD/",
|
|
50
50
|
"bugs": {
|
|
51
51
|
"url": "https://github.com/pilotspace/ADD/issues"
|
|
52
52
|
}
|
package/skill/add/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|