@afokapu/atdd-bun 0.9.0 → 0.9.1

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/README.md CHANGED
@@ -125,9 +125,22 @@ docs/delivery/tranches/<tranche>/evidence.yaml one tranche's review record (
125
125
  docs/delivery/tranches/<tranche>/*.json the retained raw reviewer reports
126
126
  ```
127
127
 
128
- Where delivery is adopted, the docs profile leaves the records folder to the delivery profile: its
129
- YAML and reports are not authored documentation, and changing them needs no docs declaration. Every
130
- key is optional; these are the defaults:
128
+ Where delivery is adopted, the docs profile leaves the records folder's records and data files to
129
+ the delivery profile: they are not authored documentation, and changing them needs no docs
130
+ declaration. AsciiDoc there stays documentation. Where the docs profile is active, the first tranche
131
+ also brings `docs/index.adoc` and `docs/delivery/index.adoc`, each with `:doc-id:` and `:status:`,
132
+ since every docs area needs an index.
133
+
134
+ Upgrading from 0.8.0: records under `delivery/` are reported until `delivery.root: delivery` is set
135
+ (a reported root change, approved once) or they are moved. A data file in an old tranche that no
136
+ record there names is reported on local runs, not at the merge gate; removing it is a gate change a
137
+ human approves, or a new tranche's record can name it.
138
+
139
+ Upgrading from 0.9.0: an `atdd-bun.yaml` field with the wrong type (a quoted number, `yes`/`no`, a
140
+ non-string list item, `.inf`) is now reported, and on the base branch it blocks every pull request,
141
+ since the policy cannot be compared. Correct such fields on the base branch before upgrading.
142
+
143
+ Every key is optional; these are the defaults:
131
144
 
132
145
  ```yaml
133
146
  delivery:
package/integrity.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "0.9.0",
2
+ "version": "0.9.1",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
6
- "README.md": "f21a04f8688ce6bcc4d2dbf065eb24e886971546871935067c6f96314418312f",
6
+ "README.md": "bd4749f81d83b92e0154692f3ee8e926b0c05ba3fbb41b430da03d56d72d7508",
7
7
  "bunfig.toml": "b9fc65eca9014c5179380259d70e76385a6a80788fa9a2df5fb3eaa5554fd2fe",
8
8
  "conventions/atdd-bun.planner/atdd-bun.planner.acceptance-identity.convention.yaml": "81c5c773d5ee15e8d99a2c237aa9846b110b2533df45ebf407d0e3ec6ed3dd03",
9
9
  "conventions/atdd-bun.planner/atdd-bun.planner.identity-required.convention.yaml": "e96d7c1455d0c221072d82e7f2b55da1c4f9ceb17718eb6ed96affdeef37675b",
@@ -1190,12 +1190,12 @@
1190
1190
  "src/agent.ts": "b2015873940ca83bb8fdc683dbb85c2d53fe6f8c3e2b6b0b71bda895f0c1eb2f",
1191
1191
  "src/ci.ts": "67e54d2cbfc44a9e42837d5e750af526d6255c539d3c452a4d5fca3226c0d9ac",
1192
1192
  "src/cli.ts": "bb2047cee60f48bfe3eaee4c1b3dcd3538a0e1edc170cf58e87686117bea07a7",
1193
- "src/delivery.ts": "938af0efee874888b20d0f862cccd3a492dafa69352649a6a584972583df361c",
1193
+ "src/delivery.ts": "8c2f964a4acc4471b6541f980c9c6f270b33cd64c95f5fe852a401060d507975",
1194
1194
  "src/docs-capability.ts": "115cf27049a5cf19c133bac5ec237072be6f969a59a2ef6767e054108a21dc5c",
1195
1195
  "src/enforce.ts": "58ad0593895912bdc1effe97430b07acf8cda066052397957558cbb331c16778",
1196
1196
  "src/hooks.ts": "adbb72f7a83f53d596fd217f01f4ce32c5a59a23ab81993fed226fb96fd1cbd1",
1197
1197
  "src/index.ts": "8ee4d7716794f6990ae8580e471b98256a0d0223dad063cd3b4542e598b6e744",
1198
- "src/integrity.ts": "7cbb1ad4991079a4852d2d131c0769f66e13dc8b5fa0e637780a1b70a75eaf78",
1198
+ "src/integrity.ts": "ac516b1d3a73f872d0ed2b20a27ef0ac14ecc015293bd8e51954f322f8a063bb",
1199
1199
  "src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
1200
1200
  "src/planner-kernel.ts": "5d3f5305fb59becb2cc97d8a03aca6b104f385805d91cc0410856e289dd6fbb1",
1201
1201
  "src/planner-schema-validator.ts": "ce529c0936171075e93c7539b3655fe895a98dd2973ebda5775958e6ebc316d9",
@@ -1208,7 +1208,7 @@
1208
1208
  "templates/agents/AGENTS.block.md": "7e9687b1eff245b66da4127b336a9e20af2f5fa273e08895188c0a0dffebd77d",
1209
1209
  "templates/agents/atdd-bun.integrity.test.ts": "dec6f6e5f65a08d9512fc703b367c163f8c9da1f6aa7fd466835c1ec30348a41",
1210
1210
  "templates/agents/atdd/SKILL.md": "b621abe22851a75b30fc0e6c339a2c0a3ed6bbf78b8c17000a4ce301e05df96d",
1211
- "templates/agents/delivery/SKILL.md": "aa9833d1ea4dbeb04a639f8a9dcd38803ae6ed10370da2d5298778c4636e25c9",
1211
+ "templates/agents/delivery/SKILL.md": "9d9b80394929220f6ec1521137df02976e3240601bbc92ee6513e078442856a6",
1212
1212
  "templates/agents/delivery/review.md": "289ee5d55ba0f321e5703a6fd8d5417d55bf0d9c6dc3103ea0f79af11c159d9c",
1213
1213
  "templates/github/atdd-bun-release.yml": "d9547e9e6ef3ae010d53d55314f6a54567f88bd900f7e50d994cd308601d2684",
1214
1214
  "templates/github/atdd-bun.yml": "bb42414f72f4b9a2fb1eb69530695e193f105a984542f4ca124c898a5f1489be"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
package/src/delivery.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import Ajv, { type ValidateFunction } from "ajv";
2
2
  import addFormats from "ajv-formats";
3
- import { existsSync, lstatSync } from "node:fs";
3
+ import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
4
4
  import { readdir, readFile } from "node:fs/promises";
5
5
  import { join, resolve } from "node:path";
6
6
  import type { PlanFinding } from "./planner-kernel";
@@ -230,6 +230,13 @@ const namedReports = (data: unknown): string[] => {
230
230
  return Array.isArray(reviews) ? reviews.flatMap(review => { const report = record(review)?.report; return typeof report === "string" && report ? [report] : []; }) : [];
231
231
  };
232
232
  const isFolder = (path: string) => { try { return lstatSync(path).isDirectory(); } catch { return false; } };
233
+ /** A folder, through a symlink too: the legacy and hidden-records probes look wherever records could be. */
234
+ const isLink = (path: string) => { try { return lstatSync(path).isSymbolicLink(); } catch { return false; } };
235
+ /** Whether any folder on the way to `relative` (below `root`) is a symlink. */
236
+ const throughLink = (root: string, relative: string) => relative.split("/").some((_, i, parts) => isLink(join(root, ...parts.slice(0, i + 1))));
237
+ /** Two paths that resolve to one folder (a compatibility symlink to the root): its records are the root's, not outside it. */
238
+ const sameFolder = (a: string, b: string) => { try { return realpathSync(a) === realpathSync(b); } catch { return false; } };
239
+ const reachesFolder = (path: string) => { try { return statSync(path).isDirectory(); } catch { return false; } };
233
240
  const regularFile = (path: string) => { try { return lstatSync(path).isFile(); } catch { return false; } };
234
241
 
235
242
  /** Two spellings of one commit: an abbreviated SHA is a prefix of the full one. */
@@ -353,12 +360,12 @@ export async function validateDelivery(root = process.cwd(), options: DeliveryOp
353
360
  policy = { ...policy, root: DEFAULT_ROOT };
354
361
  }
355
362
  // 0.8.0 kept records under delivery/ by default. Records left there under any other effective root would be unseen.
356
- if (policy.root !== LEGACY_ROOT && isFolder(join(absolute, LEGACY_ROOT))) {
363
+ if (policy.root !== LEGACY_ROOT && reachesFolder(join(absolute, LEGACY_ROOT)) && !sameFolder(join(absolute, LEGACY_ROOT), join(absolute, policy.root))) {
357
364
  const legacy = (await readdir(join(absolute, LEGACY_ROOT), { withFileTypes: true })).filter(entry => entry.isDirectory() && existsSync(join(absolute, LEGACY_ROOT, entry.name, "evidence.yaml")));
358
365
  if (legacy.length) findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `tranche records under delivery/ (${legacy.map(entry => entry.name).join(", ")}) are outside the root ${policy.root}; 0.8.0 kept them there by default; set delivery.root: delivery (a root change the integrity check reports for a human to approve once), or move them there, which rewrites merged records and so needs a human-supervised merge`));
359
366
  }
360
367
  // The other direction: records under the default root while another root is configured are outside what is judged.
361
- if (policy.root !== DEFAULT_ROOT && isFolder(join(absolute, DEFAULT_ROOT))) {
368
+ if (policy.root !== DEFAULT_ROOT && reachesFolder(join(absolute, DEFAULT_ROOT)) && !sameFolder(join(absolute, DEFAULT_ROOT), join(absolute, policy.root))) {
362
369
  const hidden = (await readdir(join(absolute, DEFAULT_ROOT), { withFileTypes: true })).filter(entry => entry.isDirectory() && existsSync(join(absolute, DEFAULT_ROOT, entry.name, "evidence.yaml")));
363
370
  if (hidden.length) findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `tranche records under ${DEFAULT_ROOT} (${hidden.map(entry => entry.name).join(", ")}) are outside the configured root ${policy.root}; move them there, or remove delivery.root`));
364
371
  }
@@ -374,7 +381,11 @@ export async function validateDelivery(root = process.cwd(), options: DeliveryOp
374
381
  // Per tranche: a data file is a report only when a record in its own tranche names it.
375
382
  const reported = new Set(files.flatMap(entry => namedReports(entry.data).filter(report => report.startsWith(`${policy.root}/${entry.tranche}/`))));
376
383
  const unnamed = (await dataFiles(absolute, policy)).filter(path => !reported.has(path));
377
- if (existsSync(join(absolute, policy.root)) && !isFolder(join(absolute, policy.root))) findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `delivery.root ${policy.root} is a file, not a folder; the records cannot be read`));
384
+ const rootPath = join(absolute, policy.root);
385
+ if (existsSync(rootPath) || isLink(rootPath)) {
386
+ if (isLink(rootPath) || throughLink(absolute, policy.root)) findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `delivery.root ${policy.root} is, or is reached through, a symlink; the delivery root must be a real folder, whose records Git tracks`));
387
+ else if (!isFolder(rootPath)) findings.push(finding("delivery.config-schema", "atdd-bun.yaml", `delivery.root ${policy.root} is a file, not a folder; the records cannot be read`));
388
+ }
378
389
  for (const path of [...await strayFiles(absolute, policy), ...unnamed].sort()) if (!onBase || (await git(absolute, ["diff", "--quiet", onBase, "--", path])).code) findings.push(finding("delivery.evidence-schema", path, `${path} is neither a tranche's evidence.yaml nor a report a record in its tranche names (a data file: ${REPORT_EXTENSION.source.slice(3, -2).replaceAll("|", ", ")}); the records folder holds only records and their reports`));
379
390
  for (const entry of files) {
380
391
  if (onBase && existsSync(join(absolute, entry.file)) && !(await git(absolute, ["diff", "--quiet", onBase, "--", `${policy.root}/${entry.tranche}`])).code) continue;
package/src/integrity.ts CHANGED
@@ -118,6 +118,36 @@ async function checkGenerated(root: string, packageRoot: string, skipWorkflow =
118
118
  * its absent-means-all default is right for execution and wrong for deciding whether a policy was ever declared. */
119
119
  const explicitProfiles = (config: { profiles?: unknown }): string[] | null => Array.isArray(config.profiles) ? config.profiles.map(String) : null;
120
120
 
121
+ /** A policy with null-valued hook keys (and worktrees children) removed: YAML gives null for a key with no value, and the
122
+ * hooks read null as absent, so the comparison must too. Other keys keep their null: delivery and profiles are read by
123
+ * readers that tell null apart from absent (`delivery:` with no value adopts delivery). */
124
+ function withoutNulls(config: Record<string, unknown>): Record<string, unknown> {
125
+ const out = Object.fromEntries(Object.entries(config).filter(([key, value]) => value !== null || !(key in defaultHookPolicy)));
126
+ const worktrees = out.worktrees;
127
+ if (typeof worktrees === "object" && worktrees !== null && !Array.isArray(worktrees)) out.worktrees = Object.fromEntries(Object.entries(worktrees).filter(([, value]) => value !== null));
128
+ return out;
129
+ }
130
+
131
+ /** The hook policy fields whose value has the wrong type. The comparison below would otherwise crash on them (a string
132
+ * where a list is expected) or compare them as the defaults. An empty or null document is the default policy, and fine. */
133
+ export function policyShapeErrors(config: Record<string, unknown>): string[] {
134
+ const out: string[] = [];
135
+ for (const key of ["max_staged_files", "max_staged_changed_lines", "max_uncommitted_files", "max_commits_per_push", "max_registry_removed_lines"])
136
+ if (config[key] !== undefined && !(typeof config[key] === "number" && Number.isFinite(config[key]))) out.push(`${key} must be a finite number`);
137
+ for (const key of ["require_plan_reference", "require_traceability"]) if (config[key] !== undefined && typeof config[key] !== "boolean") out.push(`${key} must be true or false`);
138
+ for (const key of ["protected_branches", "registry_paths"]) if (config[key] !== undefined && !(Array.isArray(config[key]) && (config[key] as unknown[]).every(item => typeof item === "string"))) out.push(`${key} must be a list of strings`);
139
+ const worktrees = config.worktrees;
140
+ if (worktrees !== undefined) {
141
+ if (typeof worktrees !== "object" || worktrees === null || Array.isArray(worktrees)) out.push("worktrees must be a mapping");
142
+ else {
143
+ const layout = worktrees as Record<string, unknown>;
144
+ for (const key of ["enabled", "require_linked_worktree"]) if (layout[key] !== undefined && typeof layout[key] !== "boolean") out.push(`worktrees.${key} must be true or false`);
145
+ for (const key of ["root", "primary_directory", "primary_branch"]) if (layout[key] !== undefined && typeof layout[key] !== "string") out.push(`worktrees.${key} must be a string`);
146
+ }
147
+ }
148
+ return out;
149
+ }
150
+
121
151
  /** Names of the policy fields in `current` that are looser than in `base`. */
122
152
  export function loosenedPolicy(base: Partial<HookPolicy> & { profiles?: unknown; delivery?: unknown }, current: Partial<HookPolicy> & { profiles?: unknown; delivery?: unknown }): string[] {
123
153
  const b = { ...defaultHookPolicy, ...base, worktrees: { ...defaultHookPolicy.worktrees, ...base.worktrees } }, c = { ...defaultHookPolicy, ...current, worktrees: { ...defaultHookPolicy.worktrees, ...current.worktrees } };
@@ -174,11 +204,20 @@ async function checkPolicy(root: string, base?: string, push = process.env.GITHU
174
204
  const unreadable = (why: string) => [{ file: "atdd-bun.yaml", detail: `the baseline atdd-bun.yaml at ${against.slice(0, 7)} ${why}, so the policy cannot be compared`, restore: push
175
205
  ? `this push is compared with the tip it replaced (${against.slice(0, 7)}), whose atdd-bun.yaml is broken; once the repaired atdd-bun.yaml is on the branch, the next push is compared with a readable tip`
176
206
  : `repair the atdd-bun.yaml on the base branch (git show ${against.slice(0, 7)}:atdd-bun.yaml) and land it there, which may need a maintainer; then bring that repair into this branch (rebase onto the base, or merge it in: a pull request is compared with its merge base) and re-run the check` }];
177
- let baseline: Partial<HookPolicy>;
178
- try { baseline = await read(before.code ? null : before.out); }
207
+ let baseline: Record<string, unknown>;
208
+ try { baseline = await read(before.code ? null : before.out) as Record<string, unknown>; }
179
209
  catch (error) { return unreadable(`could not be parsed (${String(error)})`); }
180
210
  if (typeof baseline !== "object" || baseline === null || Array.isArray(baseline)) return unreadable(`is not a policy mapping (${JSON.stringify(baseline)})`);
181
- const loosened = loosenedPolicy(baseline, await read(existsSync(path) ? await readFile(path, "utf8") : null));
211
+ // A key with no value (or only commented-out children) parses to null; the hooks read it as absent, and so does this.
212
+ baseline = withoutNulls(baseline);
213
+ const baseShape = policyShapeErrors(baseline);
214
+ if (baseShape.length) return unreadable(`has wrongly typed fields (${baseShape.join("; ")})`);
215
+ const raw = await read(existsSync(path) ? await readFile(path, "utf8") : null);
216
+ const current = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? withoutNulls(raw) : raw;
217
+ // A wrongly typed field in the working tree is read by the hooks as its default, silently; it is reported instead.
218
+ const shape = typeof current === "object" && current !== null && !Array.isArray(current) ? policyShapeErrors(current) : ["the document is not a policy mapping"];
219
+ if (shape.length) return [{ file: "atdd-bun.yaml", detail: `has wrongly typed fields, which the hooks would ignore or misread: ${shape.join("; ")}`, restore: "correct the field types in atdd-bun.yaml, then re-run the check" }];
220
+ const loosened = loosenedPolicy(baseline, current);
182
221
  return loosened.length ? [{ file: "atdd-bun.yaml", detail: `loosens the policy of ${against.slice(0, 7)}: ${loosened.join("; ")}`, restore: `git checkout ${against.slice(0, 7)} -- atdd-bun.yaml` }] : [];
183
222
  }
184
223
 
@@ -12,7 +12,7 @@ A **tranche** is one independently mergeable piece of the program, on its own br
12
12
 
13
13
  Its job is throughput: every worker slot busy, every tranche moving. It never implements, repairs tests, reviews, or merges a tranche.
14
14
 
15
- 1. Split the program into tranches with explicit dependencies, and write why the program exists, its scope and how it was split in `docs/delivery/index.adoc` (a docs-profile document). Activate a tranche as soon as its own dependencies have merged; do not wait for a whole wave. A tranche whose dependencies are still open may run PLAN and `plan_review` but nothing after; revalidate its plan once they merge.
15
+ 1. Split the program into tranches with explicit dependencies, and write why the program exists, its scope and how it was split in `docs/delivery/index.adoc` (a docs-profile document; where the docs profile is active, `docs/index.adoc` is needed too, each with `:doc-id:` and `:status:`). Activate a tranche as soon as its own dependencies have merged; do not wait for a whole wave. A tranche whose dependencies are still open may run PLAN and `plan_review` but nothing after; revalidate its plan once they merge.
16
16
  2. For each active tranche, create a worktree from the owning repository's workspace and start a driver in it through the multiplexer (see Multiplexer). Send the mandate, submit it, wait 4–6 s, and read the pane: a working indicator or agent output means it landed; an empty prompt or placeholder means retry before waiting on anything.
17
17
  3. Wait on the multiplexer's events, not polling loops, and on every driver at once. When a slot frees, give it to the next ready tranche or to planning ahead.
18
18
  4. Keep provider health for the whole program. When a driver reports a model unavailable, tell every driver to go straight to the next model in its lists until it recovers, so no tranche spends time rediscovering an outage.