@iceinvein/agent-skills 0.21.4 → 0.22.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.
Files changed (131) hide show
  1. package/README.md +9 -0
  2. package/package.json +4 -1
  3. package/skills/bug-hunter/SKILL.md +82 -0
  4. package/skills/bug-hunter/evals/README.md +282 -0
  5. package/skills/bug-hunter/evals/baselines/claude-opus-5-5.json +93 -0
  6. package/skills/bug-hunter/evals/bug-cites-intent-from-spec/case.yaml +4 -0
  7. package/skills/bug-hunter/evals/bug-cites-intent-from-spec/fixture.sh +71 -0
  8. package/skills/bug-hunter/evals/bug-cites-intent-from-spec/graders/bug-cites-the-spec.md +36 -0
  9. package/skills/bug-hunter/evals/bug-cites-intent-from-spec/graders/bug-hunter-fired.md +5 -0
  10. package/skills/bug-hunter/evals/bug-cites-intent-from-spec/graders/intent-written.md +8 -0
  11. package/skills/bug-hunter/evals/bug-cites-intent-from-spec/prompt.md +11 -0
  12. package/skills/bug-hunter/evals/callers-tree-unchanged/case.yaml +4 -0
  13. package/skills/bug-hunter/evals/callers-tree-unchanged/fixture.sh +67 -0
  14. package/skills/bug-hunter/evals/callers-tree-unchanged/graders/bug-hunter-fired.md +5 -0
  15. package/skills/bug-hunter/evals/callers-tree-unchanged/graders/source-unchanged.md +10 -0
  16. package/skills/bug-hunter/evals/callers-tree-unchanged/graders/tests-unchanged.md +10 -0
  17. package/skills/bug-hunter/evals/callers-tree-unchanged/graders/tree-unchanged.md +15 -0
  18. package/skills/bug-hunter/evals/callers-tree-unchanged/prompt.md +11 -0
  19. package/skills/bug-hunter/evals/correct-code-has-no-bugs/case.yaml +4 -0
  20. package/skills/bug-hunter/evals/correct-code-has-no-bugs/fixture.sh +82 -0
  21. package/skills/bug-hunter/evals/correct-code-has-no-bugs/graders/bug-hunter-fired.md +5 -0
  22. package/skills/bug-hunter/evals/correct-code-has-no-bugs/graders/bugs-empty.md +18 -0
  23. package/skills/bug-hunter/evals/correct-code-has-no-bugs/graders/written-with-write-tool.md +13 -0
  24. package/skills/bug-hunter/evals/correct-code-has-no-bugs/prompt.md +11 -0
  25. package/skills/bug-hunter/evals/impossible-value-is-a-bug/case.yaml +4 -0
  26. package/skills/bug-hunter/evals/impossible-value-is-a-bug/fixture.sh +68 -0
  27. package/skills/bug-hunter/evals/impossible-value-is-a-bug/graders/bug-for-sixty-seconds.md +37 -0
  28. package/skills/bug-hunter/evals/impossible-value-is-a-bug/graders/bug-hunter-fired.md +5 -0
  29. package/skills/bug-hunter/evals/impossible-value-is-a-bug/prompt.md +11 -0
  30. package/skills/bug-hunter/evals/proves-a-spec-boundary-bug/case.yaml +4 -0
  31. package/skills/bug-hunter/evals/proves-a-spec-boundary-bug/fixture.sh +79 -0
  32. package/skills/bug-hunter/evals/proves-a-spec-boundary-bug/graders/bug-cites-the-limit.md +31 -0
  33. package/skills/bug-hunter/evals/proves-a-spec-boundary-bug/graders/bug-hunter-fired.md +5 -0
  34. package/skills/bug-hunter/evals/proves-a-spec-boundary-bug/graders/findings-written.md +10 -0
  35. package/skills/bug-hunter/evals/proves-a-spec-boundary-bug/prompt.md +11 -0
  36. package/skills/bug-hunter/evals/rules.json +177 -0
  37. package/skills/bug-hunter/evals/silent-spec-is-an-ambiguity/case.yaml +4 -0
  38. package/skills/bug-hunter/evals/silent-spec-is-an-ambiguity/fixture.sh +97 -0
  39. package/skills/bug-hunter/evals/silent-spec-is-an-ambiguity/graders/bug-hunter-fired.md +5 -0
  40. package/skills/bug-hunter/evals/silent-spec-is-an-ambiguity/graders/bugs-empty.md +18 -0
  41. package/skills/bug-hunter/evals/silent-spec-is-an-ambiguity/graders/cap-is-an-ambiguity.md +12 -0
  42. package/skills/bug-hunter/evals/silent-spec-is-an-ambiguity/graders/written-with-write-tool.md +13 -0
  43. package/skills/bug-hunter/evals/silent-spec-is-an-ambiguity/prompt.md +11 -0
  44. package/skills/bug-hunter/evals/spec-outranks-target-docstring/case.yaml +4 -0
  45. package/skills/bug-hunter/evals/spec-outranks-target-docstring/fixture.sh +82 -0
  46. package/skills/bug-hunter/evals/spec-outranks-target-docstring/graders/bug-cites-the-spec.md +40 -0
  47. package/skills/bug-hunter/evals/spec-outranks-target-docstring/graders/bug-hunter-fired.md +5 -0
  48. package/skills/bug-hunter/evals/spec-outranks-target-docstring/prompt.md +11 -0
  49. package/skills/bug-hunter/references/findings-format.md +82 -0
  50. package/skills/bug-hunter/references/scenario-catalog.md +92 -0
  51. package/skills/bug-hunter/skill.json +24 -0
  52. package/skills/index.json +17 -5
  53. package/skills/magpie/evals/README.md +13 -0
  54. package/skills/magpie/evals/baselines/claude-opus-5-5.json +69 -0
  55. package/skills/magpie/evals/rules.json +192 -0
  56. package/skills/magpie/skill.json +1 -1
  57. package/skills/migrate/evals/README.md +13 -0
  58. package/skills/migrate/evals/baselines/claude-opus-5-5.json +129 -0
  59. package/skills/migrate/evals/rules.json +160 -0
  60. package/skills/migrate/skill.json +10 -4
  61. package/skills/scope-estimate/README.md +173 -0
  62. package/skills/scope-estimate/SKILL.md +168 -0
  63. package/skills/scope-estimate/references/capability-map.md +122 -0
  64. package/skills/scope-estimate/references/deliverables.md +60 -0
  65. package/skills/scope-estimate/references/requirements.md +104 -0
  66. package/skills/scope-estimate/references/schedule.md +138 -0
  67. package/skills/scope-estimate/scripts/__tests__/schedule.test.ts +366 -0
  68. package/skills/scope-estimate/scripts/schedule.ts +553 -0
  69. package/skills/scope-estimate/skill.json +19 -0
  70. package/skills/scope-estimate/templates/plan.example.json +33 -0
  71. package/skills/sluice/SKILL.md +35 -12
  72. package/skills/sluice/evals/README.md +111 -6
  73. package/skills/sluice/evals/announcement-reaches-the-ledger/fixture.sh +32 -19
  74. package/skills/sluice/evals/announcement-reaches-the-ledger/graders/routes-main-before-source.md +14 -0
  75. package/skills/sluice/evals/announcement-reaches-the-ledger/prompt.md +2 -2
  76. package/skills/sluice/evals/baselines/claude-opus-5-5.json +237 -0
  77. package/skills/sluice/evals/deep-run-blocks-on-a-real-decision/fixture.sh +19 -12
  78. package/skills/sluice/evals/deep-run-fans-out/graders/task-1-merged-back.md +8 -0
  79. package/skills/sluice/evals/deep-run-fans-out/graders/task-2-merged-back.md +8 -0
  80. package/skills/sluice/evals/deep-run-fans-out/graders/task-3-merged-back.md +6 -0
  81. package/skills/sluice/evals/deep-run-fans-out/graders/worktree-per-implementer.md +12 -0
  82. package/skills/sluice/evals/deep-run-hunts-after-the-flip/case.yaml +5 -0
  83. package/skills/sluice/evals/deep-run-hunts-after-the-flip/fixture.sh +258 -0
  84. package/skills/sluice/evals/deep-run-hunts-after-the-flip/graders/bug-hunter-fired.md +5 -0
  85. package/skills/sluice/evals/deep-run-hunts-after-the-flip/graders/four-attempts-allowed.md +34 -0
  86. package/skills/sluice/evals/deep-run-hunts-after-the-flip/graders/fourth-attempt-tested.md +20 -0
  87. package/skills/sluice/evals/deep-run-hunts-after-the-flip/graders/hunt-before-the-final-review.md +21 -0
  88. package/skills/sluice/evals/deep-run-hunts-after-the-flip/graders/record-has-a-hunt-section.md +11 -0
  89. package/skills/sluice/evals/deep-run-hunts-after-the-flip/graders/sluice-fired.md +5 -0
  90. package/skills/sluice/evals/deep-run-hunts-after-the-flip/prompt.md +12 -0
  91. package/skills/sluice/evals/deep-run-survives-a-milestone/fixture.sh +46 -24
  92. package/skills/sluice/evals/fast-verifies-before-handback/case.yaml +4 -0
  93. package/skills/sluice/evals/fast-verifies-before-handback/fixture.sh +73 -0
  94. package/skills/sluice/evals/fast-verifies-before-handback/graders/quiet-flag-implemented.md +5 -0
  95. package/skills/sluice/evals/fast-verifies-before-handback/graders/sluice-fired.md +5 -0
  96. package/skills/sluice/evals/fast-verifies-before-handback/graders/verified-after-the-last-edit.md +18 -0
  97. package/skills/sluice/evals/fast-verifies-before-handback/prompt.md +11 -0
  98. package/skills/sluice/evals/main-hunts-a-checkable-claim/case.yaml +5 -0
  99. package/skills/sluice/evals/main-hunts-a-checkable-claim/fixture.sh +64 -0
  100. package/skills/sluice/evals/main-hunts-a-checkable-claim/graders/announces-main-channel.md +12 -0
  101. package/skills/sluice/evals/main-hunts-a-checkable-claim/graders/bug-hunter-fired.md +5 -0
  102. package/skills/sluice/evals/main-hunts-a-checkable-claim/graders/hunt-after-the-source-edits.md +12 -0
  103. package/skills/sluice/evals/main-hunts-a-checkable-claim/graders/last-page-holds-the-remainder.md +27 -0
  104. package/skills/sluice/evals/main-hunts-a-checkable-claim/graders/remainder-page-tested.md +21 -0
  105. package/skills/sluice/evals/main-hunts-a-checkable-claim/graders/sluice-fired.md +5 -0
  106. package/skills/sluice/evals/main-hunts-a-checkable-claim/prompt.md +12 -0
  107. package/skills/sluice/evals/main-new-interface/graders/shape-agreed-before-building.md +8 -4
  108. package/skills/sluice/evals/main-skips-the-hunt-without-a-claim/case.yaml +5 -0
  109. package/skills/sluice/evals/main-skips-the-hunt-without-a-claim/fixture.sh +73 -0
  110. package/skills/sluice/evals/main-skips-the-hunt-without-a-claim/graders/announces-main-channel.md +12 -0
  111. package/skills/sluice/evals/main-skips-the-hunt-without-a-claim/graders/no-hunt.md +12 -0
  112. package/skills/sluice/evals/main-skips-the-hunt-without-a-claim/graders/skip-said-in-one-line.md +21 -0
  113. package/skills/sluice/evals/main-skips-the-hunt-without-a-claim/graders/sluice-fired.md +5 -0
  114. package/skills/sluice/evals/main-skips-the-hunt-without-a-claim/prompt.md +12 -0
  115. package/skills/sluice/evals/rules.json +364 -0
  116. package/skills/sluice/references/deep-channel.md +64 -18
  117. package/skills/sluice/references/finish.md +8 -2
  118. package/skills/sluice/references/intent.md +4 -2
  119. package/skills/sluice/references/review.md +32 -0
  120. package/skills/sluice/references/status.md +17 -4
  121. package/skills/sluice/references/verify.md +38 -0
  122. package/skills/sluice/scripts/content-digest.sh +65 -0
  123. package/skills/sluice/scripts/run-stats.sh +9 -8
  124. package/skills/sluice/scripts/status.sh +78 -13
  125. package/skills/sluice/scripts/stop-guard.sh +97 -24
  126. package/skills/sluice/skill.json +2 -2
  127. package/skills/terse/evals/README.md +16 -0
  128. package/skills/terse/evals/baselines/claude-opus-5-5.json +105 -0
  129. package/skills/terse/evals/rules.json +161 -0
  130. package/skills/terse/skill.json +1 -1
  131. package/skills/sluice/evals/announcement-reaches-the-ledger/graders/escalates-fast-to-main.md +0 -12
@@ -0,0 +1,60 @@
1
+ # Architecture and proposal checks
2
+
3
+ ## Architecture pages
4
+
5
+ Each page is Markdown first, HTML generated from it.
6
+
7
+ - **`architecture/current-architecture.md`:** runtime, background processing,
8
+ delivery and reference data, each its own Mermaid diagram, plus a numbered
9
+ table of the problems the architecture causes: hosting, data storage,
10
+ processing harness, delivery, reference data and edge security. Application
11
+ defects belong in the capability map, not here.
12
+ - **`architecture/target-architecture.md`:** the same views for the new
13
+ platform, a sequence diagram for the main flow with numbered steps, and a
14
+ "what changes from today" table keyed to the problems.
15
+ - **`architecture/<name>-architecture-comparison.md`:** side by side pairs of
16
+ the same view, glance cards for the headline differences, and the problems
17
+ table. Its job is to show the target is stronger and simpler. Say under the
18
+ table that these are the architecture problems only, with the count of
19
+ capability changes and risk findings and links to them, so the page never
20
+ reads as the whole job.
21
+ - **`architecture/design-<topic>.md`** for each fork with the most
22
+ requirements behind it (typically identity, audit, machine identity): the
23
+ options, the decision or the Pending recommendation, the gaps still open.
24
+
25
+ Mermaid in local HTML: load it from a pinned CDN version, use the base theme
26
+ with colours from CSS variables, and show the diagram source when offline.
27
+ Wide diagrams become unreadable, and in a side by side pair every diagram is
28
+ half width: keep to about three nodes per rank, shorten edge labels, move
29
+ detail into bullets, or split one diagram into two. `direction` inside a
30
+ subgraph is ignored once edges link to nodes inside it, so link subgraphs to
31
+ each other instead.
32
+
33
+ ## Reviewing a proposal
34
+
35
+ Write `build-plan/<vendor>-proposal-review.md`. Judge the proposal against how
36
+ the team builds (platform, tooling, delivery, identity, security services,
37
+ compliance claims, cost gaps), as numbered judgements: replace, change, drop,
38
+ correct, missing. List what to keep and the questions it raises for the
39
+ client. The proposal's own figures may be quoted with a page reference; our
40
+ figures come from `schedule.json` and the capability map.
41
+
42
+ Always check the framing: count the scheduled requirements in areas the
43
+ proposal never mentions. A list of hosting or architecture problems presented
44
+ as the scope undersells an application rebuild. When the proposal quotes our
45
+ estimate, also check every figure against the latest schedule output and the
46
+ team shape it assumes.
47
+
48
+ An editable document (Word, Google Docs) is changed only in a copy, with
49
+ tracked changes under the user's name; commercial calls (fees, role titles,
50
+ rates) get comments, not edits. A PDF or other fixed document gets the review
51
+ file only.
52
+
53
+ ## Keeping it in sync
54
+
55
+ A decision changes requirements, which changes packages, which changes the
56
+ schedule, which changes every page quoting a number. After each decision:
57
+ mark it Decided in `decisions.md`, update the requirement files, re-run the
58
+ scheduler, then update the slices page, the capability map's open questions
59
+ and risks, the architecture pages and any proposal review. Search the
60
+ engagement folder for the old figures before saying it is done.
@@ -0,0 +1,104 @@
1
+ # Functional requirements
2
+
3
+ ## Fix the vocabulary first
4
+
5
+ Area agents run in parallel and reference each other, so before dispatching
6
+ write the first draft of `build-plan/plan.json` (from
7
+ `templates/plan.example.json`) with:
8
+
9
+ - **`dependsOn.layers`:** every layer key the requirements may use, each with
10
+ an empty list for now. Use the register's layer set.
11
+ - **`aliases`:** the shorthand agents may write in `Depends on` (`Auth`, `DB`,
12
+ `Contracts`, `Storage`, `Queue`), each mapped to the foundation capability
13
+ that will own it. Narrow targets beat broad ones.
14
+ - **Foundation capability ids:** `FD-REPO`, `FD-CI`, `FD-CON`, `FD-API`,
15
+ `FD-DB`, `FD-AUTH`, `FD-WEB`, `FD-INF`, `FD-SEC`, `FD-OBS`, plus one for any
16
+ domain content package. List which register rows foundations own outright
17
+ (platform infrastructure, auth plumbing) so no area restates them.
18
+
19
+ Put the same lists in the shared brief, `scratch/requirements-brief.md`.
20
+
21
+ ## Agents
22
+
23
+ One agent per area: a **foundations** file plus one per product area of the
24
+ register. Brief each with the shared brief above, its register rows and
25
+ appendix, the non-functional requirements, `decisions.md`, the rules and the
26
+ table format below.
27
+
28
+ Disposition rules:
29
+
30
+ - **Carry:** today's behaviour, with exact values.
31
+ - **Fix:** the intended behaviour, naming the defect it corrects.
32
+ - **Redesign:** the new shape.
33
+ - **New:** built to the non-functional requirements.
34
+ - **Undecided, Optional:** written as if the answer were yes, self-contained so
35
+ the whole capability can be gated in phase 4.
36
+ - **Drop:** no requirements; a short map of what replaces each job it did.
37
+
38
+ **Granularity drives the estimate,** so every agent cuts the same way: one
39
+ requirement is one behaviour that one test checks, at the level a reviewer
40
+ would accept or reject. Values, labels, defaults and copy that belong to one
41
+ behaviour go in that requirement (list them), not one row each; two
42
+ behaviours that fail independently are two rows. Aim for most capabilities
43
+ landing between 3 and 10 requirements. After the agents, compare each file's
44
+ share of S rows and its requirements per capability; a file well off the
45
+ others was cut differently, so re-brief it before moving on. Blind runs of the
46
+ same system with an unstated cut produced 537 and 984 requirements.
47
+
48
+ New forks found by parallel agents go in their file's findings as
49
+ `D-new: <question>`; the controller numbers them into `decisions.md` once all
50
+ agents finish. Register rows that foundations own keep their register ids as
51
+ capability headings in `foundations.md` (`### IA-01 ...`), so references to
52
+ them still resolve.
53
+
54
+ A value no source gives (a URL lifetime, a retry count) is written with
55
+ `placeholder` in Notes. A new library or pattern the requirement implies (a
56
+ charting library, a service worker) is an engineering fork: a `D-new` in the
57
+ file's findings and `see D-xx` in Notes, never `Decision:`. Work several areas need that nobody owns (a background
58
+ job runner) becomes a new foundation capability; tell the controller rather
59
+ than writing it three times.
60
+
61
+ ## Format
62
+
63
+ One `###` heading per capability, then its table:
64
+
65
+ ```
66
+ ### RC-01 Session setup (Carry)
67
+
68
+ | FR | Requirement | Actor | Layers | Size | Depends on | Source | Notes |
69
+ |---|---|---|---|---|---|---|---|
70
+ | RC-01.1 | The API shall return ... | System | api, db | M | Auth, Contracts, DB | A §1.1 | Decision: who may record |
71
+ ```
72
+
73
+ - **FR:** `<capability>.<n>`, unique across all files.
74
+ - **Requirement:** one observable "shall" statement a test could check.
75
+ - **Layers:** comma-separated from `dependsOn.layers`.
76
+ - **Size:** `S` under half a day, `M` about a day, `L` two to three days, for
77
+ an engineer working with agents.
78
+ - **Depends on:** requirement ids, capability ids, a range
79
+ (`FD-DB.2 to FD-DB.9`), or an alias. `-` for none.
80
+ - **Source:** appendix and section.
81
+ - **Notes:** `Decision:` (with the colon) marks a client question, and only
82
+ that does; `Decision: see D-04` points at a bigger one. An engineering fork
83
+ is `see D-07` without the marker (see `SKILL.md`, Decisions).
84
+
85
+ Parser rules: only tables whose header starts with `| FR |` are read, so other
86
+ tables can sit in the same file. Cells are separated by a space, a pipe and a
87
+ space (`| a | b |`, not `|a|b|`). A literal `|` inside a cell is written `\|`.
88
+ Every row has exactly eight cells.
89
+
90
+ ## After the agents
91
+
92
+ 1. **Check.** Run the scheduler against the draft plan: one slice per
93
+ requirements file listing its capabilities, no `foundations`, empty
94
+ `dependsOn` lists, and aliases pointing at capability ids (phase 4 remaps
95
+ them to foundation packages). Every capability becomes one package and
96
+ cross references become links, so it checks ids, sizes, row shape, layers
97
+ and unresolved dependencies without any cycles. Fix every problem it names.
98
+ 2. **Merge overlaps to one owner.** Where two files describe the same work,
99
+ keep it in the owning file and trim the other to its difference. Totals
100
+ then count each behaviour once.
101
+ 3. **Write back.** Copy new findings and questions into the capability map
102
+ (section 10 and open questions), new forks into `decisions.md`, and apply
103
+ register corrections (status, disposition, layers, splits) directly,
104
+ re-running the map's counting script.
@@ -0,0 +1,138 @@
1
+ # Slices, packages and the schedule
2
+
3
+ ## Slices
4
+
5
+ A slice is a feature one engineer owns end to end: schema, route with its
6
+ permission check, UI, tests and the infrastructure it needs. With generalist
7
+ engineers nobody is "the frontend person"; slices rotate. A scheduled slice
8
+ is 2 to 14 planning days, and its requirement tables are its spec card.
9
+
10
+ `phase` groups slices for reading; the scheduler ignores it. A package may sit
11
+ in a slice other than its capability's (a guard against copied files belongs
12
+ with CI), and counts toward the slice it sits in, but never across the gated
13
+ line: the scheduler refuses a scheduled package claiming gated work, or the
14
+ reverse. Contracts first means operations slices start as
15
+ soon as their foundations land, long before the product apps finish; say in
16
+ the document when they actually start, from the schedule.
17
+
18
+ - **0 Foundations:** monorepo and local dev stack, CI, contracts and API
19
+ skeleton, data model, sign-in, web shell, domain content package, dev
20
+ infrastructure and security baseline, logging.
21
+ - **1 Platform core:** tenancy and permissions, user management, account
22
+ security, the core entities, consent or equivalent, storage, audit.
23
+ - **2 Product apps:** the user-facing apps and background workers.
24
+ - **3 Environments and operations:** promotion, monitoring, backup.
25
+
26
+ **Gated slices** hold every Undecided and Optional capability, one slice per
27
+ client decision, with `"gated": true`. They are counted and totalled, never
28
+ scheduled, and the size rule does not apply. A scheduled package may not wait
29
+ on a gated one; the scheduler refuses it.
30
+
31
+ Each slice gets a one-sentence goal stating what works when it is done. For
32
+ "depends on" per slice in the document, roll up package dependencies; two
33
+ slices can each have a package waiting on the other without a cycle.
34
+
35
+ ## Work packages
36
+
37
+ - **Foundations** are split by hand into small packages in `foundations`,
38
+ each a handful of related requirements (`F8f Auth configuration and VPC
39
+ endpoints`). Work the sources forgot (often a local dev stack) gets
40
+ `assumedDays` and is reported as an assumption. Scope that nobody has agreed
41
+ is not given an assumed size; list it under what the estimate leaves out.
42
+ - **Products** need no listing: every requirement not claimed by a package
43
+ becomes one package per capability. List a product package in `products`
44
+ only to split a large capability (over about 5 planning days, or one that
45
+ ends the critical chain) or to give it a real wait.
46
+ - **Contracts first.** A product package waits only on the foundation
47
+ packages it uses: `dependsOn.product` for all, plus `dependsOn.layers` by
48
+ the layers its requirements touch, plus any foundation its requirements
49
+ name. A reference to another product is an **integration link**, checked
50
+ when both sides land, never a blocker.
51
+ - **Real product waits** are for data, not contracts: a pipeline stage that
52
+ needs the previous stage's output to build or test against. Give it
53
+ `after` in `products`, and say in the document why a mock or fixture
54
+ cannot stand in.
55
+ - **What a dependency means:** needed to build and test locally, not needed
56
+ to deploy. `dependsOn.product` is what every product needs to run locally
57
+ (the local dev stack, the API and web skeletons); deploy infrastructure is
58
+ a dependency of the environment slices, not of every product.
59
+ - **Layers:** every layer used in a requirement must be a key in
60
+ `dependsOn.layers`, even with an empty list.
61
+ - **Aliases** from the phase 3 draft point at capability ids; remap each to
62
+ the foundation package that owns the work. Keys match regardless of case. Keep them narrow: if a
63
+ requirement inside package A uses an alias that also names package B, and B
64
+ depends on A, the two wait on each other. The scheduler refuses the cycle
65
+ and names the requirement behind each edge.
66
+ - **Unresolved dependencies** are listed under "Dependencies that resolved to
67
+ nothing". Fix the requirement or add an alias; each is a missing edge.
68
+
69
+ ## Rates and team
70
+
71
+ `rates` are days per requirement by size. A starting point from a past
72
+ engagement:
73
+
74
+ | Rate | S | M | L | Meaning |
75
+ |---|---|---|---|---|
76
+ | fast | 0.1 | 0.4 | 1.0 | agents do most of it, little rework |
77
+ | plan | 0.25 | 0.75 | 2.0 | the planning case |
78
+ | floor | 0.5 | 1.0 | 2.5 | barely faster than without agents |
79
+
80
+ Lanes are engineers x worktrees, and each placed package in `schedule.json`
81
+ names its engineer and worktree. N worktrees per engineer holds only while
82
+ the engineer's own time on a package (spec, steering, review, checking in
83
+ dev) stays at or under about 1/N of its planning time; say so next to the
84
+ headline, and show the one-worktree column as the fallback.
85
+
86
+ The schedule is list scheduling: the lane that frees first takes the package
87
+ that can start now with the longest chain behind it. A credible order, not an
88
+ optimum. It does not keep one engineer's worktrees off the same files, and it
89
+ does not follow "finish your slice first", so real lanes will follow slices
90
+ more closely than the printed order; say both.
91
+
92
+ ## Rules that go in the slices document
93
+
94
+ - Integration is continuous: whoever merges the second side of a link runs
95
+ the flow end to end in dev that day.
96
+ - Contracts change through one PR that updates both sides.
97
+ - Pick up the oldest ready package in your slice before starting a new slice.
98
+ - One engineer's worktrees never touch the same migration or contract at once;
99
+ pair packages from different slices or layers.
100
+ - Review is a second engineer, and reviews scale with worktrees.
101
+ - Parts agents cannot check (browser devices, sign-in on real devices, domain
102
+ sign-off) carry more people time than their size suggests.
103
+
104
+ ## Calibration
105
+
106
+ Nobody knows the team's real rate with agents until it is measured. Plan a
107
+ two-week calibration on the first foundation slices: record actual days per
108
+ S, M and L, and the engineer's own hours per package. At the end, put the
109
+ measured rates into `plan.json`, re-run, and commit to a date only then. Start
110
+ anything that waits on people in week 1: client decisions, domain sign-off of
111
+ the content package, account and landing-zone access.
112
+
113
+ ## Slices document outline
114
+
115
+ 1. Purpose, scope (build only unless agreed otherwise), sources.
116
+ 2. Summary: totals, gated totals, the engineers x worktrees table at fast,
117
+ planning and floor, the headline, the worktree condition.
118
+ 3. How the requirements were produced, checks done and not done.
119
+ 4. How slices work with generalist engineers.
120
+ 5. Slice catalogue by phase: FRs (S/M/L), decisions, depends on, plan days.
121
+ 6. Work packages: what "blocked by" means, real product waits and why, the
122
+ effect on the critical chain, the rules above, the package catalogue.
123
+ 7. Schedule: lane order for the headline team, things to start early,
124
+ calibration.
125
+ 8. Findings from the requirements work.
126
+ 9. What this does not cover: verification, test cycles, pen test, UAT,
127
+ gated slices (with their total), scope with no requirement yet, time away
128
+ from building (spec cards, decisions, reviews, meetings), leave and
129
+ ramp-up.
130
+
131
+ ## HTML page
132
+
133
+ Engineers, worktrees and rate as selectors over the `schedules` array from
134
+ `schedule.json`, embedded in the page at build time (a page opened from disk
135
+ cannot fetch it). A Gantt per engineer and worktree whose scale ends at the
136
+ total (no empty tail); slices always visible as articles listing their
137
+ packages, with only the requirement tables collapsed; labels hidden on bars
138
+ too narrow to hold them.
@@ -0,0 +1,366 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { estimate, parseRequirements, type Plan } from '../schedule.ts'
3
+
4
+ const HEADER = `| FR | Requirement | Actor | Layers | Size | Depends on | Source | Notes |
5
+ |---|---|---|---|---|---|---|---|`
6
+
7
+ const REQUIREMENTS = `# Area
8
+
9
+ ### FD-DB Data model (New)
10
+
11
+ ${HEADER}
12
+ | FD-DB.1 | Migrations shall create the core tables. | System | db | M | - | C §1 | |
13
+ | FD-DB.2 | Seed data shall be synthetic only. | System | db | S | FD-DB.1 | C §2 | Decision: seed shape |
14
+
15
+ ### PM-01 Participants (Carry)
16
+
17
+ ${HEADER}
18
+ | PM-01.1 | The API shall create a participant. | Clinician | api, db | L | FD-DB.1, CO-01 | C §3 | |
19
+ | PM-01.2 | The web app shall list participants. | Clinician | web | S | PM-01.1 | A §1 | |
20
+
21
+ ### CO-01 Consent (New)
22
+
23
+ ${HEADER}
24
+ | CO-01.1 | The API shall send a consent request. | System | api | M | Auth | C §4 | |
25
+
26
+ ### AA-01 Acoustic analysis (Undecided)
27
+
28
+ ${HEADER}
29
+ | AA-01.1 | The worker shall run acoustic analysis. | System | worker | S | - | D §1 | Decision: in pilot? |
30
+ `
31
+
32
+ // Whole-day rates so every expected value below can be worked out by hand.
33
+ const plan = (overrides: Partial<Plan> = {}): Plan => ({
34
+ daysPerWeek: 5,
35
+ rates: {
36
+ fast: { S: 1, M: 2, L: 4 },
37
+ plan: { S: 1, M: 2, L: 4 },
38
+ floor: { S: 1, M: 2, L: 4 },
39
+ },
40
+ team: { engineers: [1, 2], worktrees: [1], headline: { engineers: 2, worktrees: 1 } },
41
+ slices: [
42
+ { id: 'F', name: 'Foundations', goal: 'Schema exists', phase: 0, capabilities: ['FD-DB'] },
43
+ { id: 'P', name: 'Participants', goal: 'Participants exist', phase: 1, capabilities: ['PM-01', 'CO-01'] },
44
+ { id: 'D', name: 'Acoustic', goal: 'Only if wanted', phase: 1, capabilities: ['AA-01'], gated: true },
45
+ ],
46
+ foundations: [
47
+ { id: 'F1', slice: 'F', name: 'Migrations', members: ['FD-DB.1'] },
48
+ { id: 'F2', slice: 'F', name: 'Seed data', members: ['FD-DB.2'] },
49
+ ],
50
+ dependsOn: { foundation: [], product: [], layers: { db: ['F1'], web: ['F2'], api: [], worker: [] } },
51
+ aliases: { auth: ['F1'] },
52
+ ...overrides,
53
+ })
54
+
55
+ const reqs = () => parseRequirements(REQUIREMENTS, 'area.md')
56
+
57
+ test('parsed requirements carry their capability, size, layers and decision flag', () => {
58
+ const pm = reqs().find((r) => r.id === 'PM-01.1')
59
+ expect(pm).toEqual({
60
+ id: 'PM-01.1',
61
+ file: 'area.md',
62
+ capability: 'PM-01',
63
+ disposition: 'Carry',
64
+ size: 'L',
65
+ layers: ['api', 'db'],
66
+ depends: ['FD-DB.1', 'CO-01'],
67
+ decision: false,
68
+ })
69
+ expect(reqs().find((r) => r.id === 'FD-DB.2')?.decision).toBe(true)
70
+ })
71
+
72
+ test('only a Decision: marker flags a requirement, not prose that mentions a decision', () => {
73
+ const md = `### X-01 Thing (New)\n\n${HEADER}\n| X-01.1 | A. | System | api | S | - | s | Decision: as X-02.1 |\n| X-01.2 | B. | System | api | S | - | s | Built per Decision D-05 |\n`
74
+ expect(parseRequirements(md, 'x.md').map((r) => r.decision)).toEqual([true, false])
75
+ })
76
+
77
+ test('an escaped pipe stays inside its cell', () => {
78
+ const md = `### X-01 Thing (New)\n\n${HEADER}\n| X-01.1 | Shows a \\| b. | System | api | S | - | s | |\n`
79
+ expect(parseRequirements(md, 'x.md')).toHaveLength(1)
80
+ })
81
+
82
+ test('a requirement row with the wrong number of cells is rejected with its location', () => {
83
+ const broken = `### X-01 Thing (New)\n\n${HEADER}\n| X-01.1 | Too few cells | System | S |\n`
84
+ expect(() => parseRequirements(broken, 'x.md')).toThrow('x.md:5')
85
+ })
86
+
87
+ test('tables that are not requirement tables are ignored', () => {
88
+ const mixed = `### X-01 Thing (Drop)\n\n| Job today | Replaced by |\n|---|---|\n| Create users | IA-01.2 |\n\n${HEADER}\n| X-01.1 | A thing. | System | api | S | - | s | |\n`
89
+ expect(parseRequirements(mixed, 'x.md').map((r) => r.id)).toEqual(['X-01.1'])
90
+ })
91
+
92
+ test('a size other than S, M or L is rejected', () => {
93
+ const broken = `### X-01 Thing (New)\n\n${HEADER}\n| X-01.1 | A thing. | System | api | XL | - | s | |\n`
94
+ expect(() => parseRequirements(broken, 'x.md')).toThrow('XL')
95
+ })
96
+
97
+ test('one lane builds every scheduled package back to back', () => {
98
+ // F1 2 + F2 1 + PM-01 5 + CO-01 2 = 10 days = 2 weeks; AA-01 is gated and left out.
99
+ const report = estimate(reqs(), plan())
100
+ expect(report.schedules.find((s) => s.engineers === 1 && s.rate === 'plan')?.weeks).toBe(2)
101
+ })
102
+
103
+ test('two lanes finish when the longest dependency chain does', () => {
104
+ // F1 (0-2) > F2 (2-3) > PM-01 (3-8), with CO-01 (2-4) on the other lane: 8 days.
105
+ const report = estimate(reqs(), plan())
106
+ expect(report.schedules.find((s) => s.engineers === 2 && s.rate === 'plan')?.weeks).toBe(1.6)
107
+ expect(report.criticalChain).toEqual({ ids: ['F1', 'F2', 'PM-01'], weeks: 1.6 })
108
+ })
109
+
110
+ test('a product package is blocked by the foundations its layers use, not by other products', () => {
111
+ const report = estimate(reqs(), plan())
112
+ const pm = report.packages.find((p) => p.id === 'PM-01')
113
+ expect(pm?.deps).toEqual(['F1', 'F2'])
114
+ expect(pm?.integrationLinks).toEqual(['CO-01'])
115
+ })
116
+
117
+ test('a dependency named by alias resolves to the packages it stands for', () => {
118
+ const report = estimate(reqs(), plan())
119
+ expect(report.packages.find((p) => p.id === 'CO-01')?.deps).toEqual(['F1'])
120
+ })
121
+
122
+ test('worktrees multiply the lanes each engineer runs', () => {
123
+ const team = { engineers: [1], worktrees: [2], headline: { engineers: 1, worktrees: 2 } }
124
+ const report = estimate(reqs(), plan({ team }))
125
+ expect(report.schedules.find((s) => s.rate === 'plan')?.weeks).toBe(1.6)
126
+ })
127
+
128
+ test('a foundation package with an assumed size is scheduled at that size', () => {
129
+ const foundations = [
130
+ { id: 'F0', slice: 'F', name: 'Local dev stack', members: [], assumedDays: { fast: 3, plan: 3, floor: 3 } },
131
+ { id: 'F1', slice: 'F', name: 'Migrations', members: ['FD-DB.1'] },
132
+ { id: 'F2', slice: 'F', name: 'Seed data', members: ['FD-DB.2'] },
133
+ ]
134
+ const report = estimate(reqs(), plan({ foundations }))
135
+ expect(report.schedules.find((s) => s.engineers === 1 && s.rate === 'plan')?.weeks).toBe(2.6)
136
+ })
137
+
138
+ test('a dependency cycle between foundation packages is refused and named', () => {
139
+ const foundations = [
140
+ { id: 'F1', slice: 'F', name: 'Migrations', members: ['FD-DB.1'], after: ['F2'] },
141
+ { id: 'F2', slice: 'F', name: 'Seed data', members: ['FD-DB.2'] },
142
+ ]
143
+ expect(() => estimate(reqs(), plan({ foundations }))).toThrow('F1 waits on F2 waits on F1')
144
+ })
145
+
146
+ test('a dependency cycle names the requirement behind each edge', () => {
147
+ const foundations = [
148
+ { id: 'F1', slice: 'F', name: 'Migrations', members: ['FD-DB.1'], after: ['F2'] },
149
+ { id: 'F2', slice: 'F', name: 'Seed data', members: ['FD-DB.2'] },
150
+ ]
151
+ expect(() => estimate(reqs(), plan({ foundations }))).toThrow('F2 waits on F1: FD-DB.2 depends on FD-DB.1')
152
+ })
153
+
154
+ test('a capability that belongs to no slice is refused', () => {
155
+ const slices = plan().slices.filter((s) => s.id !== 'D')
156
+ expect(() => estimate(reqs(), plan({ slices }))).toThrow('AA-01')
157
+ })
158
+
159
+ test('a requirement claimed by two foundation packages is refused', () => {
160
+ const foundations = [
161
+ { id: 'F1', slice: 'F', name: 'Migrations', members: ['FD-DB.1', 'FD-DB.2'] },
162
+ { id: 'F2', slice: 'F', name: 'Seed data', members: ['FD-DB.2'] },
163
+ ]
164
+ expect(() => estimate(reqs(), plan({ foundations }))).toThrow('FD-DB.2')
165
+ })
166
+
167
+ test('a dependency that names nothing known is reported, not dropped silently', () => {
168
+ const extra = REQUIREMENTS.replace('| Auth |', '| Auth, Billing |')
169
+ const report = estimate(parseRequirements(extra, 'area.md'), plan())
170
+ expect(report.unresolved).toEqual({ Billing: 1 })
171
+ })
172
+
173
+ test('a scheduled package that waits on a gated one is refused and named', () => {
174
+ const slices = plan().slices.map((s) => (s.id === 'F' ? { ...s, gated: true } : s))
175
+ expect(() => estimate(reqs(), plan({ slices }))).toThrow('PM-01 waits on F1, which is gated')
176
+ })
177
+
178
+ test('two packages with the same id are refused', () => {
179
+ const foundations = [
180
+ { id: 'F1', slice: 'F', name: 'Migrations', members: ['FD-DB.1'] },
181
+ { id: 'F1', slice: 'F', name: 'Seed data', members: ['FD-DB.2'] },
182
+ ]
183
+ expect(() => estimate(reqs(), plan({ foundations }))).toThrow('package id F1 is used twice')
184
+ })
185
+
186
+ test('a range waits on every package that owns a requirement in it', () => {
187
+ const md = REQUIREMENTS.replace('| Auth |', '| FD-DB.1 to FD-DB.2 |')
188
+ expect(estimate(parseRequirements(md, 'area.md'), plan()).packages.find((p) => p.id === 'CO-01')?.deps).toEqual(['F1', 'F2'])
189
+ })
190
+
191
+ test('a range that names no known requirement is reported', () => {
192
+ const md = REQUIREMENTS.replace('| Auth |', '| ZZ-01.1 to ZZ-01.3 |')
193
+ expect(estimate(parseRequirements(md, 'area.md'), plan()).unresolved).toEqual({ 'ZZ-01.1 to ZZ-01.3': 1 })
194
+ })
195
+
196
+ test('a misspelled requirement id is reported rather than widened to its capability', () => {
197
+ const md = REQUIREMENTS.replace('| Auth |', '| FD-DB.9 |')
198
+ const report = estimate(parseRequirements(md, 'area.md'), plan())
199
+ expect(report.unresolved).toEqual({ 'FD-DB.9': 1 })
200
+ expect(report.packages.find((p) => p.id === 'CO-01')?.deps).toEqual([])
201
+ })
202
+
203
+ test('a bare capability id waits on every package holding that capability', () => {
204
+ const md = REQUIREMENTS.replace('| Auth |', '| FD-DB |')
205
+ expect(estimate(parseRequirements(md, 'area.md'), plan()).packages.find((p) => p.id === 'CO-01')?.deps).toEqual(['F1', 'F2'])
206
+ })
207
+
208
+ test('a layer the plan does not declare is refused', () => {
209
+ const dependsOn = { foundation: [], product: [], layers: { db: ['F1'], web: ['F2'] } }
210
+ expect(() => estimate(reqs(), plan({ dependsOn }))).toThrow('layer api')
211
+ })
212
+
213
+ test('alias keys match whatever their case', () => {
214
+ const report = estimate(reqs(), plan({ aliases: { Auth: ['F1'] } }))
215
+ expect(report.packages.find((p) => p.id === 'CO-01')?.deps).toEqual(['F1'])
216
+ })
217
+
218
+ test('a table padded by a formatter is still read', () => {
219
+ const md = `### X-01 Thing (New)\n\n| FR | Requirement | Actor | Layers | Size | Depends on | Source | Notes |\n| ------ | --- | :--- | --- | --- | --- | --- | --- |\n| X-01.1 | A thing. | System | api | S | - | s | |\n`
220
+ expect(parseRequirements(md, 'x.md').map((r) => r.id)).toEqual(['X-01.1'])
221
+ })
222
+
223
+ test('a capability heading without a title still starts a new capability', () => {
224
+ const md = `### X-01 Thing (New)\n\n${HEADER}\n| X-01.1 | A. | System | api | S | - | s | |\n\n### Y-01\n\n${HEADER}\n| Y-01.1 | B. | System | api | S | - | s | |\n`
225
+ expect(parseRequirements(md, 'x.md').map((r) => r.capability)).toEqual(['X-01', 'Y-01'])
226
+ })
227
+
228
+ test('a plan with nothing left to schedule is refused', () => {
229
+ const slices = plan().slices.map((s) => ({ ...s, gated: true }))
230
+ expect(() => estimate(reqs(), plan({ slices }))).toThrow('nothing to schedule')
231
+ })
232
+
233
+ test('every team combination carries its own lane order', () => {
234
+ const report = estimate(reqs(), plan())
235
+ const two = report.schedules.find((s) => s.engineers === 2 && s.rate === 'plan')
236
+ expect(two?.order.map((o) => [o.id, o.engineer, o.startWeek, o.endWeek])).toEqual([
237
+ ['F1', 1, 0, 0.4],
238
+ ['F2', 2, 0.4, 0.6],
239
+ ['CO-01', 1, 0.4, 0.8],
240
+ ['PM-01', 2, 0.6, 1.6],
241
+ ])
242
+ })
243
+
244
+ test('slices report their requirement counts and planning days, gated ones marked', () => {
245
+ const report = estimate(reqs(), plan())
246
+ expect(report.slices).toEqual([
247
+ { id: 'F', frs: 2, sizes: { S: 1, M: 1, L: 0 }, decisions: 1, planDays: 3, gated: false },
248
+ { id: 'P', frs: 3, sizes: { S: 1, M: 1, L: 1 }, decisions: 0, planDays: 7, gated: false },
249
+ { id: 'D', frs: 1, sizes: { S: 1, M: 0, L: 0 }, decisions: 1, planDays: 1, gated: true },
250
+ ])
251
+ })
252
+
253
+ test('a headline team outside the team ranges is refused', () => {
254
+ const team = { engineers: [2], worktrees: [1], headline: { engineers: 2, worktrees: 3 } }
255
+ expect(() => estimate(reqs(), plan({ team }))).toThrow('headline 2 x 3')
256
+ })
257
+
258
+ test('a product package can wait on another product when the plan says so', () => {
259
+ const products = [{ id: 'PM-01', slice: 'P', name: 'Participants', members: ['PM-01'], after: ['CO-01'] }]
260
+ const report = estimate(reqs(), plan({ products }))
261
+ expect(report.packages.find((p) => p.id === 'PM-01')?.deps).toEqual(['CO-01', 'F1', 'F2'])
262
+ // F1 (0-2), CO-01 (2-4) and F2 (2-3) in parallel, then PM-01 (4-9): 9 days.
263
+ expect(report.schedules.find((s) => s.engineers === 2 && s.rate === 'plan')?.weeks).toBe(1.8)
264
+ })
265
+
266
+ test('a large product capability can be split into packages', () => {
267
+ const products = [
268
+ { id: 'PM-01a', slice: 'P', name: 'Create', members: ['PM-01.1'] },
269
+ { id: 'PM-01b', slice: 'P', name: 'List', members: ['PM-01.2'], after: ['PM-01a'] },
270
+ ]
271
+ const report = estimate(reqs(), plan({ products }))
272
+ expect(report.packages.filter((p) => p.slice === 'P').map((p) => [p.id, p.days.plan])).toEqual([
273
+ ['PM-01a', 4],
274
+ ['PM-01b', 1],
275
+ ['CO-01', 2],
276
+ ])
277
+ })
278
+
279
+ test('gated work is totalled so the cost of each yes is visible', () => {
280
+ expect(estimate(reqs(), plan()).gated).toEqual({ requirements: 1, planDays: 1 })
281
+ })
282
+
283
+ test('each placed package names its engineer and worktree', () => {
284
+ const team = { engineers: [1], worktrees: [2], headline: { engineers: 1, worktrees: 2 } }
285
+ const order = estimate(reqs(), plan({ team })).schedules.find((s) => s.rate === 'plan')?.order
286
+ expect(order?.map((o) => [o.id, o.engineer, o.worktree])).toEqual([
287
+ ['F1', 1, 1],
288
+ ['F2', 1, 2],
289
+ ['CO-01', 1, 1],
290
+ ['PM-01', 1, 2],
291
+ ])
292
+ })
293
+
294
+ test('a scheduled package that references gated work is refused, even as an integration link', () => {
295
+ const md = REQUIREMENTS.replace('| Auth |', '| Auth, AA-01 |')
296
+ expect(() => estimate(parseRequirements(md, 'area.md'), plan())).toThrow('CO-01 references AA-01, which is gated')
297
+ })
298
+
299
+ test('package totals are in the report, not only in the printed summary', () => {
300
+ // Scheduled packages: F1 2, F2 1, PM-01 5, CO-01 2 days; PM-01 links to CO-01.
301
+ expect(estimate(reqs(), plan()).totals).toEqual({ planDays: 10, medianPlanDays: 2, largestPlanDays: 5, integrationLinks: 1 })
302
+ })
303
+
304
+ test('a scheduled package may not claim requirements from a gated slice', () => {
305
+ const products = [{ id: 'AA-01x', slice: 'P', name: 'Acoustic', members: ['AA-01'] }]
306
+ expect(() => estimate(reqs(), plan({ products }))).toThrow('AA-01x is scheduled but claims AA-01.1, whose slice D is gated')
307
+ })
308
+
309
+ test('a package placed in another slice counts toward the slice it is placed in', () => {
310
+ const foundations = [
311
+ { id: 'F1', slice: 'F', name: 'Migrations', members: ['FD-DB.1'] },
312
+ { id: 'F2', slice: 'P', name: 'Seed data', members: ['FD-DB.2'] },
313
+ ]
314
+ const report = estimate(reqs(), plan({ foundations }))
315
+ expect(report.slices.map((x) => [x.id, x.frs, x.planDays])).toEqual([
316
+ ['F', 1, 2],
317
+ ['P', 4, 8],
318
+ ['D', 1, 1],
319
+ ])
320
+ })
321
+
322
+ test('a split that leaves part of a capability under the same id names the clash', () => {
323
+ const products = [{ id: 'PM-01', slice: 'P', name: 'Create', members: ['PM-01.1'] }]
324
+ expect(() => estimate(reqs(), plan({ products }))).toThrow('PM-01.2 is left unclaimed')
325
+ })
326
+
327
+ test('a package listed as waiting on itself is refused', () => {
328
+ const products = [{ id: 'PM-01', slice: 'P', name: 'Participants', members: ['PM-01'], after: ['PM-01'] }]
329
+ expect(() => estimate(reqs(), plan({ products }))).toThrow('PM-01 lists itself under after')
330
+ })
331
+
332
+ test('an escaped pipe is read back as a plain pipe', () => {
333
+ const md = `### X-01 Thing (New)\n\n${HEADER}\n| X-01.1 | A. | System | api | S | - | s | a \\| b |\n`
334
+ const md2 = md.replace('| - | s |', '| X-02.1 \\| x | s |')
335
+ expect(parseRequirements(md2, 'x.md')[0].depends).toEqual(['X-02.1 | x'])
336
+ })
337
+
338
+ test('work spreads across engineers before it doubles up on one engineer', () => {
339
+ const team = { engineers: [2], worktrees: [2], headline: { engineers: 2, worktrees: 2 } }
340
+ const order = estimate(reqs(), plan({ team })).schedules.find((s) => s.rate === 'plan')?.order
341
+ // F1 starts alone at day 0; the next lane to free is engineer 2's first worktree.
342
+ expect(order?.slice(0, 2).map((o) => [o.id, o.engineer, o.worktree])).toEqual([
343
+ ['F1', 1, 1],
344
+ ['F2', 2, 1],
345
+ ])
346
+ })
347
+
348
+ test('the median of an odd number of packages is the middle one', () => {
349
+ // With AA-01 ungated: 1, 1, 2, 2, 5 days.
350
+ const slices = plan().slices.map((s) => ({ ...s, gated: false }))
351
+ expect(estimate(reqs(), plan({ slices })).totals.medianPlanDays).toBe(2)
352
+ })
353
+
354
+ test('--help prints usage and the plan fields, and exits cleanly', async () => {
355
+ const proc = Bun.spawn(['bun', `${import.meta.dir}/../schedule.ts`, '--help'], { stdout: 'pipe', stderr: 'pipe' })
356
+ const out = await new Response(proc.stdout).text()
357
+ expect(await proc.exited).toBe(0)
358
+ expect(out).toContain('usage: bun schedule.ts <requirements-dir> <plan.json> [--json <out.json>]')
359
+ for (const field of ['daysPerWeek', 'rates', 'team', 'slices', 'foundations', 'products', 'dependsOn', 'aliases']) expect(out).toContain(field)
360
+ })
361
+
362
+ test('a missing argument prints usage to stderr and fails', async () => {
363
+ const proc = Bun.spawn(['bun', `${import.meta.dir}/../schedule.ts`], { stdout: 'pipe', stderr: 'pipe' })
364
+ expect(await proc.exited).toBe(2)
365
+ expect(await new Response(proc.stderr).text()).toContain('usage:')
366
+ })