@iceinvein/agent-skills 0.21.5 → 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 (130) 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 +17 -3
  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 +48 -6
  117. package/skills/sluice/references/finish.md +5 -0
  118. package/skills/sluice/references/review.md +32 -0
  119. package/skills/sluice/references/status.md +17 -4
  120. package/skills/sluice/references/verify.md +38 -0
  121. package/skills/sluice/scripts/content-digest.sh +65 -0
  122. package/skills/sluice/scripts/run-stats.sh +9 -8
  123. package/skills/sluice/scripts/status.sh +78 -13
  124. package/skills/sluice/scripts/stop-guard.sh +97 -24
  125. package/skills/sluice/skill.json +2 -2
  126. package/skills/terse/evals/README.md +16 -0
  127. package/skills/terse/evals/baselines/claude-opus-5-5.json +105 -0
  128. package/skills/terse/evals/rules.json +161 -0
  129. package/skills/terse/skill.json +1 -1
  130. package/skills/sluice/evals/announcement-reaches-the-ledger/graders/escalates-fast-to-main.md +0 -12
@@ -0,0 +1,553 @@
1
+ #!/usr/bin/env bun
2
+ // Turns functional-requirement tables plus a slice/package plan into work
3
+ // packages and a lane schedule, so every figure in the estimate comes from
4
+ // the same arithmetic and re-runs when a requirement or a rate changes.
5
+ //
6
+ // Usage: bun schedule.ts <requirements-dir> <plan.json> [--json <out.json>]
7
+
8
+ import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ export type Size = 'S' | 'M' | 'L'
12
+ export type RateName = 'fast' | 'plan' | 'floor'
13
+ type Days = Record<RateName, number>
14
+
15
+ export type Requirement = {
16
+ id: string
17
+ file: string
18
+ capability: string
19
+ disposition: string
20
+ size: Size
21
+ layers: string[]
22
+ depends: string[]
23
+ decision: boolean
24
+ }
25
+
26
+ export type Slice = {
27
+ id: string
28
+ name: string
29
+ goal: string
30
+ phase: number
31
+ capabilities: string[]
32
+ gated?: boolean
33
+ }
34
+
35
+ export type PackageSpec = {
36
+ id: string
37
+ slice: string
38
+ name: string
39
+ // Requirement ids or whole capability ids.
40
+ members: string[]
41
+ // Packages that must merge first. On a product this is a real wait, unlike an integration link.
42
+ after?: string[]
43
+ // For work the sources have no requirement for yet; reported as an assumption.
44
+ assumedDays?: Days
45
+ }
46
+
47
+ export type Plan = {
48
+ daysPerWeek: number
49
+ rates: Record<RateName, Record<Size, number>>
50
+ team: { engineers: number[]; worktrees: number[]; headline: { engineers: number; worktrees: number } }
51
+ slices: Slice[]
52
+ foundations: PackageSpec[]
53
+ // Optional: products default to one package per capability; list them here to split one or add waits.
54
+ products?: PackageSpec[]
55
+ dependsOn: { foundation: string[]; product: string[]; layers: Record<string, string[]> }
56
+ aliases: Record<string, string[]>
57
+ }
58
+
59
+ export type Package = {
60
+ id: string
61
+ slice: string
62
+ name: string
63
+ kind: 'foundation' | 'product'
64
+ gated: boolean
65
+ assumed: boolean
66
+ requirements: string[]
67
+ sizes: Record<Size, number>
68
+ decisions: number
69
+ days: Days
70
+ deps: string[]
71
+ integrationLinks: string[]
72
+ }
73
+
74
+ type Placed = { id: string; engineer: number; worktree: number; startWeek: number; endWeek: number }
75
+
76
+ type SliceTotals = { id: string; frs: number; sizes: Record<Size, number>; decisions: number; planDays: number; gated: boolean }
77
+
78
+ export type Report = {
79
+ requirements: { total: number; scheduled: number; sizes: Record<Size, number>; decisions: number }
80
+ slices: SliceTotals[]
81
+ gated: { requirements: number; planDays: number }
82
+ totals: { planDays: number; medianPlanDays: number; largestPlanDays: number; integrationLinks: number }
83
+ packages: Package[]
84
+ unresolved: Record<string, number>
85
+ criticalChain: { ids: string[]; weeks: number }
86
+ schedules: { engineers: number; worktrees: number; rate: RateName; weeks: number; order: Placed[] }[]
87
+ }
88
+
89
+ const RATE_NAMES: RateName[] = ['fast', 'plan', 'floor']
90
+ const HEADING = /^###\s+(\S+)(?:\s+(.*?))?\s*(?:\(([^)]*)\))?\s*$/
91
+ const REQUIREMENT_HEADER = /^\|\s*FR\s*\|/
92
+ const SEPARATOR = /^\|[\s:|-]+$/
93
+
94
+ export function parseRequirements(markdown: string, file: string): Requirement[] {
95
+ const out: Requirement[] = []
96
+ let capability = ''
97
+ let disposition = ''
98
+ let inRequirementTable = false
99
+ markdown.split('\n').forEach((line, i) => {
100
+ const heading = line.match(HEADING)
101
+ if (heading) {
102
+ capability = heading[1]
103
+ disposition = heading[3] ?? ''
104
+ inRequirementTable = false
105
+ return
106
+ }
107
+ if (!line.startsWith('|')) {
108
+ inRequirementTable = false
109
+ return
110
+ }
111
+ if (REQUIREMENT_HEADER.test(line)) {
112
+ inRequirementTable = Boolean(capability)
113
+ return
114
+ }
115
+ if (!inRequirementTable || SEPARATOR.test(line.trim())) return
116
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split(' | ').map((c) => c.trim().replaceAll('\\|', '|'))
117
+ if (cells.length !== 8) throw new Error(`${file}:${i + 1}: expected 8 cells, found ${cells.length}`)
118
+ const [id, , , layers, size, depends, , notes] = cells
119
+ if (size !== 'S' && size !== 'M' && size !== 'L') throw new Error(`${file}:${i + 1}: ${id} has size ${size}`)
120
+ out.push({
121
+ id,
122
+ file,
123
+ capability,
124
+ disposition,
125
+ size,
126
+ layers: layers.split(',').map((l) => l.trim()).filter(Boolean),
127
+ depends: depends === '-' || depends === '' ? [] : depends.split(/,\s*/).map((d) => d.trim()),
128
+ decision: notes.includes('Decision:'),
129
+ })
130
+ })
131
+ return out
132
+ }
133
+
134
+ function round(n: number, places = 2): number {
135
+ const f = 10 ** places
136
+ return Math.round(n * f) / f
137
+ }
138
+
139
+ function buildPackages(reqs: Requirement[], plan: Plan): { packages: Package[]; unresolved: Record<string, number> } {
140
+ const problems: string[] = []
141
+ const byId = new Map(reqs.map((r) => [r.id, r]))
142
+ const byCapability = new Map<string, Requirement[]>()
143
+ for (const r of reqs) byCapability.set(r.capability, [...(byCapability.get(r.capability) ?? []), r])
144
+
145
+ const seen = new Set<string>()
146
+ for (const r of reqs) {
147
+ if (seen.has(r.id)) problems.push(`requirement ${r.id} appears twice`)
148
+ seen.add(r.id)
149
+ }
150
+
151
+ const sliceOf = new Map<string, Slice>()
152
+ for (const s of plan.slices) {
153
+ for (const c of s.capabilities) {
154
+ if (sliceOf.has(c)) problems.push(`capability ${c} is in slices ${sliceOf.get(c)?.id} and ${s.id}`)
155
+ sliceOf.set(c, s)
156
+ if (!byCapability.has(c)) problems.push(`slice ${s.id} names capability ${c}, which has no requirements`)
157
+ }
158
+ }
159
+ for (const c of byCapability.keys()) if (!sliceOf.has(c)) problems.push(`capability ${c} belongs to no slice`)
160
+ const sliceIds = new Set(plan.slices.map((s) => s.id))
161
+
162
+ const owner = new Map<string, string>()
163
+ const packages: Package[] = []
164
+ const add = (id: string, slice: Slice | undefined, name: string, kind: Package['kind'], members: Requirement[], assumedDays?: Days) => {
165
+ for (const r of members) {
166
+ const prior = owner.get(r.id)
167
+ if (prior) problems.push(`requirement ${r.id} is claimed by ${prior} and ${id}`)
168
+ owner.set(r.id, id)
169
+ }
170
+ const sizes = { S: 0, M: 0, L: 0 }
171
+ for (const r of members) sizes[r.size]++
172
+ const days = Object.fromEntries(
173
+ RATE_NAMES.map((k) => [k, assumedDays ? assumedDays[k] : round(members.reduce((t, r) => t + plan.rates[k][r.size], 0))]),
174
+ ) as Days
175
+ packages.push({
176
+ id,
177
+ slice: slice?.id ?? '',
178
+ name,
179
+ kind,
180
+ gated: slice?.gated ?? false,
181
+ assumed: Boolean(assumedDays),
182
+ requirements: members.map((r) => r.id),
183
+ sizes,
184
+ decisions: members.filter((r) => r.decision).length,
185
+ days,
186
+ deps: [],
187
+ integrationLinks: [],
188
+ })
189
+ }
190
+
191
+ const listed: [PackageSpec, Package['kind']][] = [
192
+ ...plan.foundations.map((f): [PackageSpec, Package['kind']] => [f, 'foundation']),
193
+ ...(plan.products ?? []).map((f): [PackageSpec, Package['kind']] => [f, 'product']),
194
+ ]
195
+ for (const [f, kind] of listed) {
196
+ if (!sliceIds.has(f.slice)) problems.push(`${kind} ${f.id} names unknown slice ${f.slice}`)
197
+ const members: Requirement[] = []
198
+ for (const m of f.members) {
199
+ const r = byId.get(m)
200
+ if (r) members.push(r)
201
+ else if (byCapability.has(m)) members.push(...(byCapability.get(m) ?? []))
202
+ else problems.push(`${kind} ${f.id} names ${m}, which is neither a requirement nor a capability`)
203
+ }
204
+ if (members.length === 0 && !f.assumedDays) problems.push(`${kind} ${f.id} has no requirements and no assumedDays`)
205
+ // A package may sit in another slice, but never across the gated line, or it would be built when its work is not.
206
+ const placedGated = plan.slices.find((s) => s.id === f.slice)?.gated ?? false
207
+ for (const r of members) {
208
+ const home = sliceOf.get(r.capability)
209
+ if (home && (home.gated ?? false) !== placedGated)
210
+ problems.push(`${f.id} is ${placedGated ? 'gated' : 'scheduled'} but claims ${r.id}, whose slice ${home.id} is ${home.gated ? 'gated' : 'scheduled'}`)
211
+ }
212
+ if (f.after?.includes(f.id)) problems.push(`${f.id} lists itself under after`)
213
+ add(f.id, plan.slices.find((s) => s.id === f.slice), f.name, kind, members, f.assumedDays)
214
+ }
215
+ for (const [capability, members] of byCapability) {
216
+ const left = members.filter((r) => !owner.has(r.id))
217
+ if (left.length === 0) continue
218
+ if (listed.some(([f]) => f.id === capability)) {
219
+ problems.push(`${left.map((r) => r.id).join(', ')} is left unclaimed while package ${capability} claims the rest; claim it or give the split packages new ids`)
220
+ continue
221
+ }
222
+ add(capability, sliceOf.get(capability), capability, 'product', left)
223
+ }
224
+
225
+ const pkg = new Map<string, Package>()
226
+ for (const p of packages) {
227
+ if (pkg.has(p.id)) problems.push(`package id ${p.id} is used twice`)
228
+ pkg.set(p.id, p)
229
+ }
230
+ for (const r of reqs)
231
+ for (const l of r.layers) if (!(l in plan.dependsOn.layers)) problems.push(`${r.id} uses layer ${l}, which dependsOn.layers does not declare`)
232
+ const configured = [
233
+ ...plan.dependsOn.foundation,
234
+ ...plan.dependsOn.product,
235
+ ...Object.values(plan.dependsOn.layers).flat(),
236
+ ...Object.values(plan.aliases).flat(),
237
+ ...listed.flatMap(([f]) => f.after ?? []),
238
+ ]
239
+ for (const id of configured) if (!pkg.has(id)) problems.push(`plan names package ${id}, which does not exist`)
240
+ if (problems.length) throw new Error(`plan does not fit the requirements:\n- ${problems.join('\n- ')}`)
241
+
242
+ const unresolved: Record<string, number> = {}
243
+ const aliases = new Map(Object.entries(plan.aliases).map(([k, v]) => [k.toLowerCase(), v]))
244
+ const resolve = (token: string): string[] => {
245
+ const t = token.replace(/`/g, '').trim()
246
+ const range = t.match(/^([A-Z][A-Z0-9-]*)\.(\d+) to \1\.(\d+)$/)
247
+ if (range) {
248
+ const ids: string[] = []
249
+ for (let n = Number(range[2]); n <= Number(range[3]); n++) {
250
+ const o = owner.get(`${range[1]}.${n}`)
251
+ if (o) ids.push(o)
252
+ }
253
+ if (ids.length) return ids
254
+ } else {
255
+ const direct = owner.get(t)
256
+ if (direct) return [direct]
257
+ const alias = aliases.get(t.toLowerCase())
258
+ if (alias) return alias
259
+ const members = byCapability.get(t)
260
+ if (members) return [...new Set(members.map((r) => owner.get(r.id) as string))]
261
+ }
262
+ unresolved[t] = (unresolved[t] ?? 0) + 1
263
+ return []
264
+ }
265
+
266
+ // Why each edge exists, so a cycle can be traced back to the line that made it.
267
+ const reasons = new Map<string, string>()
268
+ for (const p of packages) {
269
+ const deps = new Set<string>()
270
+ const links = new Set<string>()
271
+ const wait = (target: string, reason: string) => {
272
+ if (target === p.id) return
273
+ deps.add(target)
274
+ if (!reasons.has(`${p.id}>${target}`)) reasons.set(`${p.id}>${target}`, reason)
275
+ }
276
+ const base = p.kind === 'foundation' ? 'foundation' : 'product'
277
+ for (const d of plan.dependsOn[base]) wait(d, `plan dependsOn.${base}`)
278
+ for (const a of listed.find(([f]) => f.id === p.id)?.[0].after ?? []) wait(a, `plan lists ${a} under ${p.id}.after`)
279
+ for (const id of p.requirements) {
280
+ const r = byId.get(id) as Requirement
281
+ if (p.kind === 'product') for (const l of r.layers) for (const d of plan.dependsOn.layers[l] ?? []) wait(d, `${r.id} touches layer ${l}`)
282
+ for (const token of r.depends.flatMap((d) => d.split(/;\s*|\s+and\s+/))) {
283
+ for (const target of resolve(token)) {
284
+ // Contracts first: products build against each other's contracts, so a
285
+ // product-to-product reference is checked at integration, not waited on.
286
+ if (pkg.get(target)?.kind === 'foundation') wait(target, `${r.id} depends on ${token}`)
287
+ else if (target !== p.id) links.add(target)
288
+ }
289
+ }
290
+ }
291
+ p.deps = [...deps].sort()
292
+ p.integrationLinks = [...links].sort()
293
+ }
294
+
295
+ const cycle = findCycle(packages)
296
+ if (cycle) {
297
+ const edges = cycle.slice(1).map((to, i) => `- ${cycle[i]} waits on ${to}: ${reasons.get(`${cycle[i]}>${to}`)}`)
298
+ throw new Error(`dependency cycle between packages: ${cycle.join(' waits on ')}\n${edges.join('\n')}`)
299
+ }
300
+ // A link to gated work dangles if the client says no, so it is refused like a wait.
301
+ const onGated = packages.flatMap((p) =>
302
+ p.gated
303
+ ? []
304
+ : [
305
+ ...p.deps.filter((d) => pkg.get(d)?.gated).map((d) => `${p.id} waits on ${d}, which is gated (${reasons.get(`${p.id}>${d}`)})`),
306
+ ...p.integrationLinks.filter((d) => pkg.get(d)?.gated).map((d) => `${p.id} references ${d}, which is gated`),
307
+ ],
308
+ )
309
+ if (onGated.length) throw new Error(`scheduled work depends on gated work:\n- ${onGated.join('\n- ')}`)
310
+ return { packages, unresolved }
311
+ }
312
+
313
+ function findCycle(packages: Package[]): string[] | null {
314
+ const deps = new Map(packages.map((p) => [p.id, p.deps]))
315
+ const state = new Map<string, 'open' | 'done'>()
316
+ const path: string[] = []
317
+ const visit = (id: string): string[] | null => {
318
+ state.set(id, 'open')
319
+ path.push(id)
320
+ for (const d of deps.get(id) ?? []) {
321
+ if (state.get(d) === 'open') return [...path.slice(path.indexOf(d)), d]
322
+ if (!state.has(d)) {
323
+ const found = visit(d)
324
+ if (found) return found
325
+ }
326
+ }
327
+ path.pop()
328
+ state.set(id, 'done')
329
+ return null
330
+ }
331
+ for (const p of packages) {
332
+ if (state.has(p.id)) continue
333
+ const found = visit(p.id)
334
+ if (found) return found
335
+ }
336
+ return null
337
+ }
338
+
339
+ // Days of work from the start of each package to the end of the longest chain behind it.
340
+ function tails(active: Package[], rate: RateName): Map<string, number> {
341
+ const successors = new Map<string, string[]>()
342
+ for (const p of active) for (const d of p.deps) successors.set(d, [...(successors.get(d) ?? []), p.id])
343
+ const byId = new Map(active.map((p) => [p.id, p]))
344
+ const memo = new Map<string, number>()
345
+ const tail = (id: string): number => {
346
+ const known = memo.get(id)
347
+ if (known !== undefined) return known
348
+ const after = (successors.get(id) ?? []).map(tail)
349
+ const value = (byId.get(id) as Package).days[rate] + Math.max(0, ...after)
350
+ memo.set(id, value)
351
+ return value
352
+ }
353
+ for (const p of active) tail(p.id)
354
+ return memo
355
+ }
356
+
357
+ function criticalChain(active: Package[], rate: RateName): string[] {
358
+ const tail = tails(active, rate)
359
+ const successors = new Map<string, string[]>()
360
+ for (const p of active) for (const d of p.deps) successors.set(d, [...(successors.get(d) ?? []), p.id])
361
+ const longest = (ids: string[]) => ids.reduce((a, b) => ((tail.get(b) ?? 0) > (tail.get(a) ?? 0) ? b : a))
362
+ let id = longest(active.map((p) => p.id))
363
+ const chain = [id]
364
+ while (successors.get(id)?.length) {
365
+ id = longest(successors.get(id) as string[])
366
+ chain.push(id)
367
+ }
368
+ return chain
369
+ }
370
+
371
+ // List scheduling: the lane that frees first takes the package that can start
372
+ // now with the longest chain behind it. A credible order, not an optimum.
373
+ function schedule(active: Package[], engineers: number, worktrees: number, rate: RateName, daysPerWeek: number): { days: number; order: Placed[] } {
374
+ const lanes = engineers * worktrees
375
+ const tail = tails(active, rate)
376
+ const free = Array<number>(lanes).fill(0)
377
+ const done = new Map<string, number>()
378
+ const todo = active.map((p) => p.id)
379
+ const byId = new Map(active.map((p) => [p.id, p]))
380
+ const order: Placed[] = []
381
+ const availableAt = (id: string) => Math.max(0, ...(byId.get(id) as Package).deps.map((d) => done.get(d) as number))
382
+ while (todo.length) {
383
+ const lane = free.indexOf(Math.min(...free))
384
+ const now = free[lane]
385
+ const ready = todo.filter((id) => (byId.get(id) as Package).deps.every((d) => done.has(d)))
386
+ const startable = ready.filter((id) => availableAt(id) <= now + 1e-9)
387
+ const pick = startable.length
388
+ ? startable.reduce((a, b) => ((tail.get(b) as number) > (tail.get(a) as number) ? b : a))
389
+ : ready.reduce((a, b) => (availableAt(b) < availableAt(a) || (availableAt(b) === availableAt(a) && (tail.get(b) as number) > (tail.get(a) as number)) ? b : a))
390
+ const start = Math.max(now, availableAt(pick))
391
+ const end = start + (byId.get(pick) as Package).days[rate]
392
+ free[lane] = end
393
+ done.set(pick, end)
394
+ todo.splice(todo.indexOf(pick), 1)
395
+ order.push({ id: pick, engineer: (lane % engineers) + 1, worktree: Math.floor(lane / engineers) + 1, startWeek: round(start / daysPerWeek), endWeek: round(end / daysPerWeek) })
396
+ }
397
+ return { days: Math.max(0, ...done.values()), order }
398
+ }
399
+
400
+ export function estimate(reqs: Requirement[], plan: Plan): Report {
401
+ const { engineers: he, worktrees: hw } = plan.team.headline
402
+ if (!plan.team.engineers.includes(he) || !plan.team.worktrees.includes(hw))
403
+ throw new Error(`team headline ${he} x ${hw} is not inside team.engineers and team.worktrees`)
404
+ const { packages, unresolved } = buildPackages(reqs, plan)
405
+ const active = packages.filter((p) => !p.gated)
406
+ if (active.length === 0) throw new Error('nothing to schedule: every slice is gated')
407
+ const scheduled = new Set(active.flatMap((p) => p.requirements))
408
+ const sizes = { S: 0, M: 0, L: 0 }
409
+ for (const r of reqs) sizes[r.size]++
410
+
411
+ const schedules: Report['schedules'] = []
412
+ for (const engineers of plan.team.engineers)
413
+ for (const worktrees of plan.team.worktrees)
414
+ for (const rate of RATE_NAMES) {
415
+ const { days, order } = schedule(active, engineers, worktrees, rate, plan.daysPerWeek)
416
+ schedules.push({ engineers, worktrees, rate, weeks: round(days / plan.daysPerWeek, 1), order })
417
+ }
418
+
419
+ const chain = criticalChain(active, 'plan')
420
+ const chainDays = chain.reduce((t, id) => t + (active.find((p) => p.id === id) as Package).days.plan, 0)
421
+ const byReq = new Map(reqs.map((r) => [r.id, r]))
422
+ const slices = plan.slices.map((slice) => {
423
+ const placed = packages.filter((p) => p.slice === slice.id)
424
+ const members = placed.flatMap((p) => p.requirements.map((id) => byReq.get(id) as Requirement))
425
+ const counts = { S: 0, M: 0, L: 0 }
426
+ for (const r of members) counts[r.size]++
427
+ return {
428
+ id: slice.id,
429
+ frs: members.length,
430
+ sizes: counts,
431
+ decisions: members.filter((r) => r.decision).length,
432
+ planDays: round(placed.reduce((t, p) => t + p.days.plan, 0), 1),
433
+ gated: slice.gated ?? false,
434
+ }
435
+ })
436
+ const gatedRequirements = reqs.filter((r) => !scheduled.has(r.id))
437
+ const planDays = active.map((p) => p.days.plan).sort((a, b) => a - b)
438
+ const mid = Math.floor(planDays.length / 2)
439
+ return {
440
+ gated: {
441
+ requirements: gatedRequirements.length,
442
+ planDays: round(packages.filter((p) => p.gated).reduce((t, p) => t + p.days.plan, 0), 1),
443
+ },
444
+ totals: {
445
+ planDays: round(planDays.reduce((a, b) => a + b, 0), 1),
446
+ medianPlanDays: planDays.length % 2 ? planDays[mid] : round((planDays[mid - 1] + planDays[mid]) / 2),
447
+ largestPlanDays: planDays[planDays.length - 1],
448
+ integrationLinks: active.reduce((t, p) => t + p.integrationLinks.length, 0),
449
+ },
450
+ requirements: { total: reqs.length, scheduled: scheduled.size, sizes, decisions: reqs.filter((r) => r.decision).length },
451
+ slices,
452
+ packages,
453
+ unresolved,
454
+ criticalChain: { ids: chain, weeks: round(chainDays / plan.daysPerWeek, 1) },
455
+ schedules,
456
+ }
457
+ }
458
+
459
+ function weeksFor(report: Report, engineers: number, worktrees: number, rate: RateName): string {
460
+ const s = report.schedules.find((x) => x.engineers === engineers && x.worktrees === worktrees && x.rate === rate)
461
+ return s ? `${s.weeks}` : '-'
462
+ }
463
+
464
+ export function toMarkdown(report: Report, plan: Plan): string {
465
+ const active = report.packages.filter((p) => !p.gated)
466
+ const { engineers: he, worktrees: hw } = plan.team.headline
467
+ const t = report.totals
468
+ const r = report.requirements
469
+ const lines = [
470
+ '## Totals',
471
+ '',
472
+ `- Requirements: ${r.total} (${r.sizes.S} S, ${r.sizes.M} M, ${r.sizes.L} L), ${r.scheduled} scheduled, ${r.decisions} flagged Decision:`,
473
+ `- Work packages: ${report.packages.length}, ${active.length} scheduled, ${report.packages.length - active.length} gated`,
474
+ `- Gated (built only if the client says yes): ${report.gated.requirements} requirements, ${report.gated.planDays} planning days`,
475
+ `- Planning days per package: median ${t.medianPlanDays}, largest ${t.largestPlanDays}, total ${t.planDays}`,
476
+ `- Integration links (product to product, checked when both sides land): ${t.integrationLinks}`,
477
+ `- Longest dependency chain at planning rates: ${report.criticalChain.weeks} weeks (${report.criticalChain.ids.join(' > ')})`,
478
+ `- Build days per engineer-week: ${plan.daysPerWeek}`,
479
+ '',
480
+ '## Slices',
481
+ '',
482
+ '| Slice | Name | FRs (S/M/L) | Decisions | Plan days |',
483
+ '|---|---|---|---|---|',
484
+ ...report.slices.map((x) => {
485
+ const name = plan.slices.find((s) => s.id === x.id)?.name ?? ''
486
+ return `| ${x.id} | ${name}${x.gated ? ' (gated, not scheduled)' : ''} | ${x.frs} (${x.sizes.S}/${x.sizes.M}/${x.sizes.L}) | ${x.decisions} | ${x.planDays} |`
487
+ }),
488
+ '',
489
+ `## Weeks at planning rates (engineers x worktrees)`,
490
+ '',
491
+ `| Engineers | ${plan.team.worktrees.map((w) => `${w} worktree${w === 1 ? '' : 's'}`).join(' | ')} |`,
492
+ `|---|${plan.team.worktrees.map(() => '---').join('|')}|`,
493
+ ...plan.team.engineers.map((e) => `| ${e} | ${plan.team.worktrees.map((w) => weeksFor(report, e, w, 'plan')).join(' | ')} |`),
494
+ '',
495
+ `## Headline: ${he} engineers x ${hw} worktrees`,
496
+ '',
497
+ `| Fast | Planning | Floor |`,
498
+ `|---|---|---|`,
499
+ `| ${RATE_NAMES.map((k) => weeksFor(report, he, hw, k)).join(' | ')} |`,
500
+ '',
501
+ `${hw} worktrees per engineer holds only while the engineer's own time on a package (spec, steering, review, checking in dev) stays at or under about 1/${hw} of its planning time.`,
502
+ ]
503
+ const assumed = report.packages.filter((p) => p.assumed)
504
+ if (assumed.length) lines.push('', '## Sized by assumption (no requirement yet)', '', ...assumed.map((p) => `- ${p.id} ${p.name}: ${p.days.plan} planning days`))
505
+ const unresolved = Object.entries(report.unresolved)
506
+ if (unresolved.length) lines.push('', '## Dependencies that resolved to nothing', '', ...unresolved.map(([t, n]) => `- \`${t}\` (${n})`))
507
+ return lines.join('\n')
508
+ }
509
+
510
+ const USAGE = 'usage: bun schedule.ts <requirements-dir> <plan.json> [--json <out.json>]'
511
+
512
+ const HELP = `${USAGE}
513
+
514
+ Reads every *.md in <requirements-dir> (tables whose header starts with | FR |)
515
+ and the plan, prints the estimate as Markdown, and with --json writes the full
516
+ report (slices, packages, every team schedule with its lane order).
517
+
518
+ plan.json fields:
519
+ daysPerWeek build days per engineer-week, usually 5
520
+ rates days per requirement by size: { fast, plan, floor } each { S, M, L }
521
+ team { engineers: [2, 3, 4], worktrees: [1, 2, 3], headline: { engineers, worktrees } }
522
+ slices [{ id, name, goal, phase, capabilities: [ids], gated?: true }]
523
+ gated slices are counted but never scheduled
524
+ foundations [{ id, slice, name, members: [requirement or capability ids], after?: [ids],
525
+ assumedDays?: { fast, plan, floor } }]
526
+ products optional, same shape: split a capability or give it a real wait (after);
527
+ unlisted requirements become one package per capability
528
+ dependsOn { foundation: [ids], product: [ids], layers: { <layer>: [ids] } }
529
+ every layer a requirement uses must be a key, even with []
530
+ aliases { "<shorthand used in Depends on>": [package ids] }, any case
531
+
532
+ It refuses a plan that misses or double-counts a requirement, reuses a package
533
+ id, names an unknown package or layer, has a dependency cycle, or lets
534
+ scheduled work depend on gated work, and names the cause. Fix the plan.`
535
+
536
+ if (import.meta.main) {
537
+ const args = process.argv.slice(2)
538
+ if (args.includes('--help') || args.includes('-h')) {
539
+ console.log(HELP)
540
+ process.exit(0)
541
+ }
542
+ const [dir, planPath, flag, jsonOut] = args
543
+ if (!dir || !planPath || (flag && flag !== '--json') || (flag && !jsonOut)) {
544
+ console.error(`${USAGE}\nRun with --help for the plan.json fields.`)
545
+ process.exit(2)
546
+ }
547
+ const plan: Plan = JSON.parse(readFileSync(planPath, 'utf8'))
548
+ const files = readdirSync(dir).filter((f) => f.endsWith('.md')).sort()
549
+ const reqs = files.flatMap((f) => parseRequirements(readFileSync(join(dir, f), 'utf8'), f))
550
+ const report = estimate(reqs, plan)
551
+ if (jsonOut) writeFileSync(jsonOut, JSON.stringify(report, null, 1))
552
+ console.log(toMarkdown(report, plan))
553
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "scope-estimate",
3
+ "version": "0.1.0",
4
+ "description": "Scopes and estimates a rebuild or respec of an existing system for a proposal: read-only discovery agents write cited inventories, a capability register gives every capability a status and a proposed disposition, area agents turn it into sized functional requirements, and a bundled Bun scheduler groups them into slices and contracts-first work packages and schedules them across engineers x worktrees at fast, planning and floor rates. The scheduler refuses a plan that misses or double-counts a requirement or has a dependency cycle, so every figure in the docs comes from one reproducible run. Also covers current and target architecture pages, proposal review, and a two-week calibration before committing to a date. Lighter than migrate: no store and no phase gates.",
5
+ "author": "iceinvein",
6
+ "type": "prompt",
7
+ "tools": ["claude"],
8
+ "files": { "prompt": "SKILL.md" },
9
+ "bundle": {
10
+ "include": ["README.md", "references", "scripts", "templates"],
11
+ "exclude": ["scripts/__tests__/"]
12
+ },
13
+ "install": {
14
+ "claude": {
15
+ "prompt": ".claude/skills/scope-estimate/SKILL.md",
16
+ "bundleRoot": ".claude/skills/scope-estimate"
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "daysPerWeek": 5,
3
+ "rates": {
4
+ "fast": { "S": 0.1, "M": 0.4, "L": 1.0 },
5
+ "plan": { "S": 0.25, "M": 0.75, "L": 2.0 },
6
+ "floor": { "S": 0.5, "M": 1.0, "L": 2.5 }
7
+ },
8
+ "team": {
9
+ "engineers": [2, 3, 4],
10
+ "worktrees": [1, 2, 3],
11
+ "headline": { "engineers": 2, "worktrees": 3 }
12
+ },
13
+ "slices": [
14
+ { "id": "F1", "phase": 0, "name": "Monorepo and local dev stack", "goal": "Any engineer or agent can clone, install and run every app locally.", "capabilities": ["FD-REPO"] },
15
+ { "id": "F3", "phase": 0, "name": "Contracts and API skeleton", "goal": "Schemas generate types and mocks; the API validates and errors the same way everywhere.", "capabilities": ["FD-CON", "FD-API"] },
16
+ { "id": "F4", "phase": 0, "name": "Core data model", "goal": "Migrations create every core entity.", "capabilities": ["FD-DB"] },
17
+ { "id": "P1", "phase": 1, "name": "Sites and permissions", "goal": "Every query is scoped to the caller's site and every route checks the role inline.", "capabilities": ["IA-12", "IA-13"] },
18
+ { "id": "D1", "phase": 2, "name": "Optional export", "goal": "Only if the client keeps it.", "capabilities": ["RE-05"], "gated": true }
19
+ ],
20
+ "foundations": [
21
+ { "id": "F1a", "slice": "F1", "name": "TypeScript workspace base", "members": ["FD-REPO.1", "FD-REPO.2"] },
22
+ { "id": "F1d", "slice": "F1", "name": "Local dev stack (no requirement yet)", "members": [], "assumedDays": { "fast": 1, "plan": 2, "floor": 3 } },
23
+ { "id": "F3a", "slice": "F3", "name": "Contract mechanism and mocks", "members": ["FD-CON"] },
24
+ { "id": "F3e", "slice": "F3", "name": "API skeleton", "members": ["FD-API"] },
25
+ { "id": "F4a", "slice": "F4", "name": "Migrations and core tables", "members": ["FD-DB"] }
26
+ ],
27
+ "dependsOn": {
28
+ "foundation": ["F1a"],
29
+ "product": ["F1d"],
30
+ "layers": { "api": ["F3a", "F3e"], "db": ["F4a"] }
31
+ },
32
+ "aliases": { "contracts": ["F3a"], "db": ["F4a"] }
33
+ }
@@ -21,8 +21,8 @@ has to be taken back.
21
21
  |---------|--------|-------|-----|
22
22
  | `bypass` | No code change: a question, an explanation, a read | none | nothing, just answer |
23
23
  | `fast` | Existing interfaces, one subsystem (a new flag on an existing command still counts as existing) | test-first, verify | "Fast channel, existing interfaces. Test first, then implement." |
24
- | `main` | Adds an interface, or crosses subsystems | + agree intent, review before merge | "Main channel, new interface. Agreeing the shape first." |
25
- | `deep` | Several subsystems, or a plan was asked for | + written design and plan | "Deep channel, several subsystems. Design before code." |
24
+ | `main` | Adds an interface, or crosses subsystems | + agree intent, hunt a checkable claim, review before merge | "Main channel, new interface. Agreeing the shape first." |
25
+ | `deep` | Several subsystems, or a plan was asked for | + written design and plan, hunt after the flip | "Deep channel, several subsystems. Design before code." |
26
26
 
27
27
  Two subsystems is `main`; the third is what makes it `deep`.
28
28
 
@@ -83,7 +83,21 @@ yourself wanting to skip the rule, or arguing that this one is the exception.
83
83
  `references/root-cause.md`
84
84
  - **Verify** before claiming. Run the command in this turn and read its
85
85
  output; the claim comes after that, never before. How sure you feel is not
86
- something anyone else can check. `references/verify.md`
86
+ something anyone else can check, so record the check: `status.sh verify --
87
+ <cmd>` notes the tree it passed on, or `--none "<reason>"` where no check
88
+ can cover the change. In `fast` and `main` a Stop hook refuses, once per
89
+ turn, a handback whose tree has moved since. `references/verify.md`
90
+ - **Hunt** in `main`, once green and before review: when the intent agreed in
91
+ the message states a checkable claim (a limit, a boundary, an invariant, a
92
+ stated result), run bug-hunter on the diff; otherwise say in one line there
93
+ was none and skip it. Fix each proven bug test-first; ambiguities go to your
94
+ partner at the handback. Record the check last, `status.sh verify --` after
95
+ the hunt and any fix, since the hunt's report lands in the tree. No
96
+ bug-hunter? Say so in the announcement and again at the merge.
97
+ `references/review.md`
98
+ - **Hunt** in `deep`, after the flip, once every task is done and before the
99
+ final review: bug-hunter on `<base>..HEAD` against the spec, each proven bug
100
+ one fix, test-first. `references/deep-channel.md`
87
101
  - **Review** before merge. Dispatch a reviewer with fresh context and put the
88
102
  diff on disk for it to read, so those bytes fill their context instead of
89
103
  yours. A session that dispatches only when asked has not refused you one, so