@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,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
+ }