@gobing-ai/knowledge-kit 0.0.13 → 0.0.14

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 (109) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +20 -0
  3. package/package.json +1 -1
  4. package/plugins/generations/content-gen/dist/index.js +20 -0
  5. package/plugins/generations/core-facts-gen/dist/index.js +20 -0
  6. package/plugins/generations/daily-article-gen/dist/index.js +29 -1
  7. package/plugins/generations/daily-article-gen/src/index.ts +13 -1
  8. package/plugins/generations/dailynews-gen/dist/index.js +20 -0
  9. package/plugins/generations/episode-plan-gen/dist/index.js +24 -0
  10. package/plugins/generations/episode-plan-gen/src/index.ts +11 -0
  11. package/plugins/generations/image-gen/dist/index.js +2296 -53
  12. package/plugins/generations/image-gen/src/providers/agnes.ts +110 -0
  13. package/plugins/generations/image-gen/src/providers/azure.ts +153 -0
  14. package/plugins/generations/image-gen/src/providers/codex-cli.ts +170 -0
  15. package/plugins/generations/image-gen/src/providers/dashscope.ts +485 -0
  16. package/plugins/generations/image-gen/src/providers/google.ts +268 -0
  17. package/plugins/generations/image-gen/src/providers/huggingface.ts +59 -0
  18. package/plugins/generations/image-gen/src/providers/jimeng.ts +259 -0
  19. package/plugins/generations/image-gen/src/providers/minimax.ts +171 -0
  20. package/plugins/generations/image-gen/src/providers/openai.ts +319 -0
  21. package/plugins/generations/image-gen/src/providers/openrouter.ts +257 -0
  22. package/plugins/generations/image-gen/src/providers/refs.ts +24 -0
  23. package/plugins/generations/image-gen/src/providers/replicate.ts +279 -0
  24. package/plugins/generations/image-gen/src/providers/seedream.ts +1 -1
  25. package/plugins/generations/image-gen/src/providers/types.ts +52 -51
  26. package/plugins/generations/image-gen/src/providers/zai.ts +237 -0
  27. package/plugins/generations/news-report-gen/dist/index.js +22193 -0
  28. package/plugins/generations/news-report-gen/package.json +17 -0
  29. package/plugins/generations/news-report-gen/plugin.json +7 -0
  30. package/plugins/generations/news-report-gen/src/index.ts +308 -0
  31. package/plugins/generations/news-report-gen/tsconfig.json +4 -0
  32. package/plugins/generations/omni-voice-gen/Makefile +14 -0
  33. package/plugins/generations/omni-voice-gen/README.md +112 -0
  34. package/plugins/generations/omni-voice-gen/bin/omni-voice-gen +2 -0
  35. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +2 -0
  36. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +6 -0
  37. package/plugins/generations/omni-voice-gen/plugin.json +6 -0
  38. package/plugins/generations/omni-voice-gen/profiles.json +12 -0
  39. package/plugins/generations/omni-voice-gen/pyproject.toml +25 -0
  40. package/plugins/generations/omni-voice-gen/scripts/coverage_gate.py +74 -0
  41. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__init__.py +1 -0
  42. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__main__.py +39 -0
  43. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/audio.py +190 -0
  44. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/backend.py +150 -0
  45. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/contract.py +76 -0
  46. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/mp3.py +60 -0
  47. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +289 -0
  48. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/profiles.py +100 -0
  49. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +234 -0
  50. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/voicescript.py +352 -0
  51. package/plugins/generations/omni-voice-gen/uv.lock +3510 -0
  52. package/plugins/generations/voice-gen/dist/index.js +30 -2
  53. package/plugins/generations/voice-gen/src/index.ts +16 -1
  54. package/plugins/generations/voice-gen/src/voicebox-client.ts +3 -1
  55. package/plugins/ingestions/aihot-ingest/dist/index.js +20 -0
  56. package/plugins/ingestions/horizon-ingest/dist/index.js +20 -0
  57. package/plugins/ingestions/last30days-ingest/dist/index.js +20 -0
  58. package/plugins/ingestions/web-search/dist/index.js +20 -0
  59. package/plugins/kk/commands/image-extract.md +40 -0
  60. package/plugins/kk/commands/image-generate.md +18 -1
  61. package/plugins/kk/plugin.json +1 -1
  62. package/plugins/kk/skills/image-authoring/SKILL.md +10 -3
  63. package/plugins/kk/skills/image-authoring/references/format-drafting.md +57 -0
  64. package/plugins/kk/skills/image-authoring/references/style-extraction.md +14 -9
  65. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +130 -30
  66. package/plugins/publishings/emdash-pub/dist/index.js +20 -0
  67. package/plugins/publishings/podcast-pub/dist/index.js +57 -3
  68. package/plugins/publishings/podcast-pub/src/index.ts +18 -2
  69. package/plugins/publishings/podcast-pub/src/show-notes.ts +56 -9
  70. package/plugins/publishings/qiita-pub/dist/index.js +20 -0
  71. package/plugins/publishings/surfdash-pub/dist/index.js +96 -6
  72. package/plugins/publishings/surfdash-pub/src/index.ts +109 -9
  73. package/plugins/publishings/zenn-pub/dist/index.js +20 -0
  74. package/plugins/sp/scripts/batch-preflight.mjs +346 -0
  75. package/plugins/sp/scripts/batch-preflight.ts +459 -0
  76. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +615 -0
  77. package/plugins/sp/scripts/daily-summary/daily-summary.ts +846 -0
  78. package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
  79. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +223 -0
  80. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +367 -0
  81. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +132 -0
  82. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +169 -0
  83. package/plugins/sp/scripts/feature-dev-precheck.mjs +171 -0
  84. package/plugins/sp/scripts/feature-dev-precheck.ts +238 -0
  85. package/plugins/sp/scripts/feature-sync-bounded.mjs +285 -0
  86. package/plugins/sp/scripts/feature-sync-bounded.ts +478 -0
  87. package/plugins/sp/scripts/history-anatomy-cache.mjs +902 -0
  88. package/plugins/sp/scripts/history-anatomy-cache.ts +1028 -0
  89. package/plugins/sp/scripts/idea-handoff.mjs +22 -0
  90. package/plugins/sp/scripts/idea-handoff.ts +44 -0
  91. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +185 -0
  92. package/plugins/sp/scripts/inline-run-setup.ts +198 -0
  93. package/plugins/sp/scripts/pr-reviewing.mjs +769 -0
  94. package/plugins/sp/scripts/pr-reviewing.ts +925 -0
  95. package/plugins/sp/scripts/quality-gate.mjs +179 -0
  96. package/plugins/sp/scripts/quality-gate.ts +217 -0
  97. package/plugins/sp/scripts/script-contract-check.ts +319 -0
  98. package/plugins/sp/scripts/stage-registry-adapter.ts +1533 -0
  99. package/plugins/sp/scripts/surface-drift-inventory.ts +929 -0
  100. package/plugins/sp/scripts/task-evidence-precheck.ts +181 -0
  101. package/plugins/sp/scripts/task-size-precheck.ts +175 -0
  102. package/plugins/sp/scripts/transition-shim-check.ts +238 -0
  103. package/plugins/sp/scripts/validate-commands.ts +689 -0
  104. package/plugins/sp/scripts/validate-flag-contracts.ts +878 -0
  105. package/plugins/sp/scripts/verify-answer-lint.ts +530 -0
  106. package/plugins/sp/scripts/workflow-step-profile.mjs +316 -0
  107. package/plugins/sp/scripts/workflow-step-profile.ts +456 -0
  108. package/plugins/sp/scripts/wrapup-steps.mjs +373 -0
  109. package/plugins/sp/scripts/wrapup-steps.ts +466 -0
@@ -0,0 +1,1533 @@
1
+ /**
2
+ * stage-registry-adapter — dev-next golden-path adapter over the canonical
3
+ * stage registry (feature O, spec ticket 0282/0283, task 0307).
4
+ *
5
+ * Provides a programmatic bridge between the dev-next status-aware facade and
6
+ * the stage-registry schema. Defines the actual registered stage records,
7
+ * implements the TABLE A/B/C resolution algorithm, and exports a pure-function
8
+ * resolution API that any agent or CLI can call.
9
+ *
10
+ * Self-contained: no @gobing-ai/spur-domain dependency (plugins/sp is outside
11
+ * the workspace). Types are defined inline, mirroring the domain schema.
12
+ *
13
+ * Stage floor tiers read from Layer 1 (`references/roles.md`, 0538 R4 /
14
+ * 0348 Follow-up C): the stage → role → tier table is the single pointer for
15
+ * floor tiers; this file no longer hardcodes a floor value. Regex-parsed
16
+ * (no `yaml` dependency — the plugin installs into foreign repos; same
17
+ * discipline as the parity tests).
18
+ *
19
+ * CLI usage:
20
+ * bun plugins/sp/scripts/stage-registry-adapter.ts --wbs 0307 [--dry-run]
21
+ * bun plugins/sp/scripts/stage-registry-adapter.ts --wbs 0307 --auto
22
+ * bun plugins/sp/scripts/stage-registry-adapter.ts --feature O
23
+ * bun plugins/sp/scripts/stage-registry-adapter.ts --list-stages
24
+ * bun plugins/sp/scripts/stage-registry-adapter.ts --help
25
+ */
26
+
27
+ import { execSync } from 'node:child_process';
28
+ import { existsSync, readFileSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
+
31
+ // ─── Inline type definitions (mirrors packages/domain/src/stage-registry/) ─
32
+
33
+ export type SchemaVersion = { major: number; minor: number };
34
+ export const CURRENT_SCHEMA_VERSION: SchemaVersion = { major: 1, minor: 3 };
35
+ export const AUTHORITY_LANES = ['registry', 'workflow', 'skill', 'cli', 'adapter'] as const;
36
+ export type AuthorityLane = (typeof AUTHORITY_LANES)[number];
37
+ // Mirrors packages/domain/src/stage-registry/schema.ts MUTATION_CLASSES. Pinned by
38
+ // tests/stage-registry-parity.test.ts — the plugin cannot import the domain package
39
+ // (it installs into foreign repos), so the copy is guarded rather than eliminated.
40
+ export const MUTATION_CLASSES = [
41
+ 'none',
42
+ 'corpus',
43
+ 'code',
44
+ 'tests',
45
+ 'verdict',
46
+ 'learnings',
47
+ 'driver',
48
+ 'irreversible',
49
+ ] as const;
50
+ export type MutationClass = (typeof MUTATION_CLASSES)[number];
51
+ export const EXECUTION_KINDS = ['inline', 'subprocess', 'deterministic', 'hitl', 'irreversible'] as const;
52
+ export type ExecutionKind = (typeof EXECUTION_KINDS)[number];
53
+ export const ARTIFACT_DIRECTIONS = ['input', 'output'] as const;
54
+ export type ArtifactDirection = (typeof ARTIFACT_DIRECTIONS)[number];
55
+ export const CONTEXT_LAYER_NAMES = [
56
+ 'harness-policy',
57
+ 'project-authority',
58
+ 'stage-contract',
59
+ 'task-state',
60
+ 'indexed-evidence',
61
+ 'run-state',
62
+ 'tool-observations',
63
+ ] as const;
64
+ export type ContextLayerName = (typeof CONTEXT_LAYER_NAMES)[number];
65
+
66
+ export interface StageArtifact {
67
+ kind: string;
68
+ direction: ArtifactDirection;
69
+ /** Exact artifact identity (F92 0593 R2): task-section name or artifact file basename. */
70
+ identity?: string;
71
+ description?: string;
72
+ required?: boolean;
73
+ }
74
+
75
+ export interface StageGate {
76
+ name: string;
77
+ timing: 'pre' | 'post' | 'transition';
78
+ min_verdict?: 'pass' | 'partial' | 'fail';
79
+ description?: string;
80
+ }
81
+
82
+ export interface StageRetryPolicy {
83
+ max_attempts: number;
84
+ terminal_stop: 'block' | 'escalate' | 'fail';
85
+ timeout_seconds?: number;
86
+ }
87
+
88
+ /** Capability tiers (0343): cheap | standard | capable-1 | capable-2 | capable-3. */
89
+ export type AdapterCapabilityTier = 'cheap' | 'standard' | 'capable-1' | 'capable-2' | 'capable-3';
90
+
91
+ /**
92
+ * Objective escalation triggers (task 0405). Self-contained mirror of the
93
+ * domain `objectiveEscalationTriggerSchema` in
94
+ * `packages/domain/src/stage-registry/schema.ts` — the authority. Update both
95
+ * together; `resource-exhaustion` covers rate-limit / quota / token-budget
96
+ * failures as one class.
97
+ */
98
+ export type ObjectiveEscalationTrigger =
99
+ | 'gate-fail'
100
+ | 'timeout'
101
+ | 'insufficient-evidence'
102
+ | 'retry-exhausted'
103
+ | 'resource-exhaustion';
104
+
105
+ export interface StageModelPolicy {
106
+ min_tier: AdapterCapabilityTier;
107
+ fallback: Array<{
108
+ tier: AdapterCapabilityTier;
109
+ trigger: ObjectiveEscalationTrigger;
110
+ }>;
111
+ override_key?: string;
112
+ }
113
+
114
+ export interface StageContextLayer {
115
+ layer: ContextLayerName;
116
+ required: true;
117
+ }
118
+
119
+ export interface StageEvent {
120
+ name: string;
121
+ description?: string;
122
+ }
123
+
124
+ export interface ExecutionVariantInline {
125
+ kind: 'inline';
126
+ current_agent_allowed: true;
127
+ may_reuse_captured_layers?: boolean;
128
+ }
129
+
130
+ export interface ExecutionVariantSubprocess {
131
+ kind: 'subprocess';
132
+ current_agent_allowed: false;
133
+ via: 'spur-agent-run';
134
+ }
135
+
136
+ export interface ExecutionVariantDeterministic {
137
+ kind: 'deterministic';
138
+ current_agent_allowed: false;
139
+ executor: 'cli' | 'script';
140
+ }
141
+
142
+ export interface ExecutionVariantHitl {
143
+ kind: 'hitl';
144
+ current_agent_allowed: true;
145
+ gate_timing: 'pre' | 'post' | 'both';
146
+ }
147
+
148
+ export interface ExecutionVariantIrreversible {
149
+ kind: 'irreversible';
150
+ requires_operator_intent: true;
151
+ current_agent_allowed: boolean;
152
+ rollback_disclaimer: string;
153
+ }
154
+
155
+ export type ExecutionVariant =
156
+ | ExecutionVariantInline
157
+ | ExecutionVariantSubprocess
158
+ | ExecutionVariantDeterministic
159
+ | ExecutionVariantHitl
160
+ | ExecutionVariantIrreversible;
161
+
162
+ export interface StageRecord {
163
+ schema_version: SchemaVersion;
164
+ id: string;
165
+ aliases?: string[];
166
+ description: string;
167
+ artifacts: StageArtifact[];
168
+ reasoning_skill: string;
169
+ required_references?: string[];
170
+ gates?: StageGate[];
171
+ mutation_class: MutationClass;
172
+ retry: StageRetryPolicy;
173
+ model_policy: StageModelPolicy;
174
+ context_layers?: StageContextLayer[];
175
+ observability?: StageEvent[];
176
+ execution: ExecutionVariant;
177
+ }
178
+
179
+ export type TaskStatus = 'backlog' | 'todo' | 'wip' | 'testing' | 'blocked' | 'done' | 'cancelled' | string;
180
+ export type FeatureStatus = 'backlog' | 'active' | 'verifying' | 'blocked' | 'done' | 'cancelled' | string;
181
+ export const TASK_STATUSES = ['backlog', 'todo', 'wip', 'testing', 'blocked', 'done', 'cancelled'] as const;
182
+ export const FEATURE_STATUSES = ['backlog', 'active', 'verifying', 'blocked', 'done', 'cancelled'] as const;
183
+
184
+ // ─── Resolution result types ────────────────────────────────────────────
185
+
186
+ export interface StageResolution {
187
+ stage: StageRecord | null;
188
+ target: string;
189
+ status: string;
190
+ tableRow: string | null;
191
+ reason: string;
192
+ reasonKind: 'dispatch' | 'blocked' | 'multi-candidate' | 'no-route' | 'error' | 'usage';
193
+ requiresConfirmation: boolean;
194
+ chain: boolean;
195
+ dispatchCommand: string | null;
196
+ blocker?: string;
197
+ nextObservableOutcome?: string;
198
+ candidates?: Array<{ command: string; reason: string }>;
199
+ }
200
+ export interface TaskSignal {
201
+ wbs: string;
202
+ status: TaskStatus;
203
+ dependencies?: Array<{ wbs: string; status: TaskStatus }>;
204
+ feature_id?: string | null;
205
+ hasCheckpoint?: boolean;
206
+ }
207
+
208
+ /**
209
+ * Result of routing-checkpoint inspection (task 0711 R3).
210
+ *
211
+ * `usable` feeds `TaskSignal.hasCheckpoint`: only a checkpoint that parses as
212
+ * the canonical frontmatter contract, matches the task (owner identity), is
213
+ * non-terminal, and is not commit-drifted may route A4 (wip + checkpoint →
214
+ * continue). Anything else is reported with its reason and IGNORED — routing
215
+ * falls through to the non-checkpoint row (A5); it is never silently trusted.
216
+ *
217
+ * Lean inline mirror of `packages/app/src/workflow/checkpoint-contract.ts`
218
+ * (the plugin installs into foreign repos and cannot import workspace
219
+ * packages — same discipline as the domain-type mirrors above); the parity
220
+ * test pins the two together.
221
+ */
222
+ export interface RoutingCheckpointInspection {
223
+ usable: boolean;
224
+ reason?: string;
225
+ }
226
+
227
+ export interface RoutingCheckpointOptions {
228
+ /** Current repository HEAD; when provided, a mismatched/absent `source_commit` is drift. */
229
+ headCommit?: string;
230
+ /** Existence probe for referenced artifacts; default `existsSync`. */
231
+ artifactExists?: (path: string) => boolean;
232
+ }
233
+
234
+ /**
235
+ * Inspect `.spur/memory/sessions/<wbs>-checkpoint.md` for resume routing
236
+ * (task 0711 R3). A missing file yields `{ usable: false, reason: 'absent' }`
237
+ * — the normal case, indistinguishable for routing from a rejected one.
238
+ */
239
+ export function inspectRoutingCheckpoint(
240
+ sessionsDir: string,
241
+ wbs: string,
242
+ opts: RoutingCheckpointOptions = {},
243
+ ): RoutingCheckpointInspection {
244
+ let raw: string;
245
+ try {
246
+ raw = readFileSync(join(sessionsDir, `${wbs}-checkpoint.md`), 'utf-8');
247
+ } catch {
248
+ return { usable: false, reason: 'absent' };
249
+ }
250
+ if (!raw.startsWith('---')) return { usable: false, reason: 'malformed: missing frontmatter' };
251
+ const end = raw.indexOf('\n---', 3);
252
+ if (end < 0) return { usable: false, reason: 'malformed: unterminated frontmatter' };
253
+
254
+ const scalars = new Map<string, string>();
255
+ const artifacts: string[] = [];
256
+ let inArtifacts = false;
257
+ for (const rawLine of raw.slice(4, end).split('\n')) {
258
+ const line = rawLine.trim();
259
+ if (line.startsWith('- ')) {
260
+ if (inArtifacts) artifacts.push(line.slice(2).trim().replace(/^"|"$/g, ''));
261
+ continue;
262
+ }
263
+ inArtifacts = false;
264
+ const sep = line.indexOf(':');
265
+ if (sep <= 0) continue;
266
+ const key = line.slice(0, sep).trim();
267
+ const value = line
268
+ .slice(sep + 1)
269
+ .trim()
270
+ .replace(/^"|"$/g, '');
271
+ if (key === 'artifacts') {
272
+ inArtifacts = true;
273
+ if (value !== '') {
274
+ artifacts.push(
275
+ ...value
276
+ .split(',')
277
+ .map((p) => p.trim().replace(/^"|"$/g, ''))
278
+ .filter((p) => p !== ''),
279
+ );
280
+ }
281
+ continue;
282
+ }
283
+ scalars.set(key, value);
284
+ }
285
+
286
+ if (scalars.get('schema_version') !== '1') return { usable: false, reason: 'malformed: schema_version' };
287
+ if (scalars.get('task_wbs') !== wbs) {
288
+ return { usable: false, reason: `owner-mismatch: task_wbs=${scalars.get('task_wbs')} != ${wbs}` };
289
+ }
290
+ const status = scalars.get('status') ?? '';
291
+ if (['done', 'failed', 'cancelled', 'skipped'].includes(status)) {
292
+ return { usable: false, reason: `terminal: status=${status}` };
293
+ }
294
+ const head = opts.headCommit;
295
+ if (head !== undefined) {
296
+ const commit = scalars.get('source_commit') ?? '';
297
+ if (commit === '' || commit !== head) {
298
+ return { usable: false, reason: `commit-drift: checkpoint@${commit.slice(0, 12) || 'none'} != HEAD` };
299
+ }
300
+ }
301
+ const probe = opts.artifactExists ?? existsSync;
302
+ for (const artifact of artifacts) {
303
+ const p = artifact.trim().replace(/^"|"$/g, '');
304
+ if (p !== '' && !probe(p)) return { usable: false, reason: `missing-artifact: ${p}` };
305
+ }
306
+ return { usable: true };
307
+ }
308
+ export interface FeatureSignal {
309
+ id: string;
310
+ status: FeatureStatus;
311
+ tasks?: TaskSignal[];
312
+ }
313
+
314
+ export interface ResolutionInput {
315
+ target: string;
316
+ wbs?: string;
317
+ feature?: FeatureSignal;
318
+ task?: TaskSignal;
319
+ dryRun?: boolean;
320
+ once?: boolean;
321
+ auto?: boolean;
322
+ fullMode?: boolean;
323
+ }
324
+
325
+ export interface StageLookupEntry {
326
+ stage_id: string;
327
+ command: string;
328
+ skill: string;
329
+ }
330
+
331
+ // ─── Registry of all canonical stage records ────────────────────────────
332
+
333
+ const inlineInline = (reuse?: boolean): ExecutionVariantInline => ({
334
+ kind: 'inline',
335
+ current_agent_allowed: true,
336
+ may_reuse_captured_layers: reuse,
337
+ });
338
+
339
+ const inlineDeterministic = (executor: 'cli' | 'script' = 'cli'): ExecutionVariantDeterministic => ({
340
+ kind: 'deterministic',
341
+ current_agent_allowed: false,
342
+ executor,
343
+ });
344
+
345
+ const inlineHitl = (timing: 'pre' | 'post' | 'both'): ExecutionVariantHitl => ({
346
+ kind: 'hitl',
347
+ current_agent_allowed: true,
348
+ gate_timing: timing,
349
+ });
350
+ const defaultRetry: StageRetryPolicy = { max_attempts: 3, terminal_stop: 'block', timeout_seconds: 300 };
351
+
352
+ export const TIER_ORDER: AdapterCapabilityTier[] = ['cheap', 'standard', 'capable-1', 'capable-2', 'capable-3'];
353
+
354
+ /**
355
+ * Layer-1 stage → floor tier map (0538 R4 / 0348 Follow-up C): derived from
356
+ * `references/roles.md` (role → tier → stages). This is the single pointer for
357
+ * floor tiers; a stage's `model_policy.min_tier` never hardcodes a value here.
358
+ * Stages Layer 1 does not fold keep the local `standard` floor. A missing or
359
+ * unreadable roles.md degrades every floor to `standard` (the pre-reconcile
360
+ * default) rather than failing the routing adapter.
361
+ */
362
+ export const STAGE_FLOOR_TIER: ReadonlyMap<string, AdapterCapabilityTier> = (() => {
363
+ const map = new Map<string, AdapterCapabilityTier>();
364
+ try {
365
+ const source = readFileSync(join(import.meta.dir, '..', 'references', 'roles.md'), 'utf8');
366
+ const roleRe = /id: ([a-z-]+)[\s\S]*?tier: ([a-z0-9-]+)[\s\S]*?stages: \[([^\]]*)\]/g;
367
+ for (const m of source.matchAll(roleRe)) {
368
+ const tier = m[2] as AdapterCapabilityTier;
369
+ for (const stage of (m[3] ?? '').split(',')) {
370
+ const id = stage.trim();
371
+ if (id !== '') map.set(id, tier);
372
+ }
373
+ }
374
+ } catch {
375
+ // roles.md unreachable — every floor falls back to `standard` below.
376
+ }
377
+ return map;
378
+ })();
379
+
380
+ /**
381
+ * Build a stage model policy whose floor reads from Layer 1. A declared fallback
382
+ * at or below the floor (review/refine/brainstorm floors rose with roles.md)
383
+ * escalates to the next tier above the floor instead of degrading.
384
+ */
385
+ function policy(stage: string, fallback: AdapterCapabilityTier[] = []): StageModelPolicy {
386
+ const floor = STAGE_FLOOR_TIER.get(stage) ?? 'standard';
387
+ const kept = fallback.filter((t) => TIER_ORDER.indexOf(t) > TIER_ORDER.indexOf(floor));
388
+ if (kept.length > 0) return { min_tier: floor, fallback: kept.map((tier) => ({ tier, trigger: 'gate-fail' })) };
389
+ if (fallback.length === 0) return { min_tier: floor, fallback: [] };
390
+ // Escalate one tier above the floor; a floor already at the top of the
391
+ // vocabulary keeps itself as the bound (no higher tier exists to escalate to).
392
+ const next = TIER_ORDER[TIER_ORDER.indexOf(floor) + 1] ?? floor;
393
+ return { min_tier: floor, fallback: [{ tier: next, trigger: 'gate-fail' }] };
394
+ }
395
+
396
+ const layer = (name: ContextLayerName): StageContextLayer => ({ layer: name, required: true });
397
+ const event = (name: string, description?: string): StageEvent => ({ name, description });
398
+
399
+ /**
400
+ * Complete set of registered canonical stage records.
401
+ * Maps one-to-one with /sp:dev-* operations from dev-operations.md.
402
+ */
403
+ export const REGISTERED_STAGES: StageRecord[] = [
404
+ {
405
+ schema_version: CURRENT_SCHEMA_VERSION,
406
+ id: 'refine',
407
+ aliases: [],
408
+ description: 'dev-refine: Q&A refinement, section filling, AC tightening',
409
+ artifacts: [
410
+ { kind: 'task-section', direction: 'input', description: 'Background/Requirements sections' },
411
+ { kind: 'task-section', direction: 'output', description: 'Q&A/Design/Plan/AC sections' },
412
+ ],
413
+ reasoning_skill: 'sp:spur-dev',
414
+ required_references: ['references/dev-operations.md', 'spur-dev/references/decision-brief.md'],
415
+ gates: [],
416
+ mutation_class: 'corpus',
417
+ retry: defaultRetry,
418
+ model_policy: policy('refine', ['capable-2']),
419
+ context_layers: [layer('project-authority'), layer('task-state'), layer('stage-contract')],
420
+ observability: [event('stage-started'), event('feature-created'), event('batch-created')],
421
+ execution: inlineInline(true),
422
+ },
423
+ {
424
+ schema_version: CURRENT_SCHEMA_VERSION,
425
+ id: 'plan',
426
+ aliases: ['dev-plan'],
427
+ description: 'dev-plan: feature intake -> AC generation -> decomposition -> batch-create',
428
+ artifacts: [
429
+ { kind: 'feature-frontmatter', direction: 'input', required: true },
430
+ { kind: 'task-batch', direction: 'output', required: true },
431
+ ],
432
+ reasoning_skill: 'sp:spur-dev',
433
+ gates: [
434
+ { name: 'feature-check', timing: 'post', min_verdict: 'pass' },
435
+ { name: 'batch-create', timing: 'post', min_verdict: 'pass' },
436
+ ],
437
+ mutation_class: 'corpus',
438
+ retry: defaultRetry,
439
+ model_policy: policy('plan', ['capable-3']),
440
+ context_layers: [layer('project-authority'), layer('task-state'), layer('stage-contract')],
441
+ observability: [event('stage-started'), event('feature-created'), event('batch-created')],
442
+ execution: inlineInline(true),
443
+ },
444
+ {
445
+ schema_version: CURRENT_SCHEMA_VERSION,
446
+ id: 'implement',
447
+ description: 'dev-run --mode implement: code edits in worktree',
448
+ artifacts: [
449
+ { kind: 'worktree-diff', direction: 'output', required: true },
450
+ { kind: 'task-section', direction: 'output', required: true, identity: 'Solution' },
451
+ ],
452
+ reasoning_skill: 'sp:code-implementation',
453
+ gates: [],
454
+ mutation_class: 'code',
455
+ retry: defaultRetry,
456
+ model_policy: policy('implement', ['capable-1']),
457
+ context_layers: [layer('task-state'), layer('run-state')],
458
+ observability: [event('stage-started'), event('code-modified')],
459
+ execution: inlineInline(),
460
+ },
461
+ {
462
+ schema_version: CURRENT_SCHEMA_VERSION,
463
+ // Adaptive-routing stage for *coverage gap-fill* (router C3/C5 → /sp:dev-unit).
464
+ // Distinct from task-pipeline hops `test` / `test-fix` / `test-recheck`, which are
465
+ // the project quality gate (shell qualityGateCmd + bounded /sp:dev-fixall).
466
+ id: 'test',
467
+ description: 'coverage gap-fill via /sp:dev-unit (C3/C5); pipeline quality gate is test/test-fix/test-recheck',
468
+ aliases: ['unit', 'coverage'],
469
+ artifacts: [
470
+ { kind: 'test-file', direction: 'output', required: true },
471
+ { kind: 'coverage-report', direction: 'output', required: false },
472
+ ],
473
+ reasoning_skill: 'sp:code-testing',
474
+ gates: [],
475
+ mutation_class: 'tests',
476
+ retry: defaultRetry,
477
+ model_policy: policy('test', ['capable-1']),
478
+ context_layers: [layer('task-state'), layer('run-state')],
479
+ observability: [event('stage-started'), event('gate-passed'), event('coverage-measured')],
480
+ execution: inlineInline(),
481
+ },
482
+ {
483
+ schema_version: CURRENT_SCHEMA_VERSION,
484
+ // Pipeline quality-gate family (task-pipeline test hop). Adaptive routing for
485
+ // red lint/type/suite gates uses fixall (TABLE C2 already points here).
486
+ id: 'quality-gate',
487
+ description: 'task-pipeline quality gate: shell qualityGateCmd + bounded /sp:dev-fixall',
488
+ aliases: ['test-fix', 'test-recheck', 'gate'],
489
+ artifacts: [{ kind: 'coverage-report', direction: 'output', required: false }],
490
+ reasoning_skill: 'sp:spur-dev',
491
+ gates: [
492
+ { name: 'project-quality-gate', timing: 'post', min_verdict: 'pass', description: 'qualityGateCmd exit 0' },
493
+ ],
494
+ mutation_class: 'code',
495
+ retry: { max_attempts: 2, terminal_stop: 'block', timeout_seconds: 600 },
496
+ model_policy: policy('quality-gate', ['capable-1']),
497
+ context_layers: [layer('task-state'), layer('run-state')],
498
+ observability: [event('stage-started'), event('gate-passed')],
499
+ execution: inlineInline(),
500
+ },
501
+
502
+ {
503
+ schema_version: CURRENT_SCHEMA_VERSION,
504
+ id: 'verify',
505
+ description: 'dev-verify: SECUA review + requirements traceability',
506
+ artifacts: [
507
+ { kind: 'worktree-diff', direction: 'input', required: true },
508
+ { kind: 'verdict-artifact', direction: 'output', required: true, identity: '<wbs>-verdict.json' },
509
+ ],
510
+ reasoning_skill: 'sp:code-verification',
511
+ gates: [
512
+ { name: 'verdict-artifact', timing: 'post', min_verdict: 'pass' },
513
+ { name: 'strict-core', timing: 'post', min_verdict: 'pass', description: 'L3 core findings must pass' },
514
+ ],
515
+ mutation_class: 'verdict',
516
+ retry: { max_attempts: 2, terminal_stop: 'escalate', timeout_seconds: 600 },
517
+ model_policy: policy('verify'),
518
+ context_layers: [layer('task-state'), layer('run-state'), layer('indexed-evidence')],
519
+ observability: [event('stage-started'), event('verdict-emitted'), event('gate-passed')],
520
+ execution: inlineInline(),
521
+ },
522
+ {
523
+ schema_version: CURRENT_SCHEMA_VERSION,
524
+ id: 'wrap',
525
+ aliases: ['dev-wrap'],
526
+ description: 'dev-wrap: learnings/doc-sync/feature transition',
527
+ artifacts: [{ kind: 'learning-entry', direction: 'output' }],
528
+ reasoning_skill: 'sp:spur-dev',
529
+ gates: [
530
+ {
531
+ name: 'task-check',
532
+ timing: 'pre',
533
+ min_verdict: 'pass',
534
+ description: 'Task check must PASS before close',
535
+ },
536
+ ],
537
+ mutation_class: 'learnings',
538
+ retry: { max_attempts: 2, terminal_stop: 'block', timeout_seconds: 180 },
539
+ model_policy: policy('wrap', ['capable-1']),
540
+ context_layers: [layer('task-state'), layer('indexed-evidence')],
541
+ observability: [event('stage-started'), event('learnings-written'), event('doc-synced')],
542
+ execution: inlineHitl('both'),
543
+ },
544
+ {
545
+ schema_version: CURRENT_SCHEMA_VERSION,
546
+ id: 'review',
547
+ description: 'dev-review: multi-dimensional code review (functional/SECUA/architecture)',
548
+ artifacts: [{ kind: 'review-findings', direction: 'output', required: true, identity: 'Review' }],
549
+ reasoning_skill: 'sp:code-verification',
550
+ gates: [],
551
+ mutation_class: 'verdict',
552
+ retry: { max_attempts: 2, terminal_stop: 'block', timeout_seconds: 300 },
553
+ model_policy: policy('review', ['capable-1']),
554
+ context_layers: [layer('task-state'), layer('run-state')],
555
+ observability: [event('stage-started'), event('findings-produced')],
556
+ execution: inlineInline(),
557
+ },
558
+ {
559
+ schema_version: CURRENT_SCHEMA_VERSION,
560
+ id: 'record',
561
+ aliases: ['dev-record'],
562
+ description: 'record: deterministic Testing write-back from the verdict artifact; bare-Review fallback only',
563
+ artifacts: [
564
+ { kind: 'verdict-artifact', direction: 'input', required: true, identity: '<wbs>-verdict.json' },
565
+ { kind: 'task-section', direction: 'output', required: true, identity: 'Testing' },
566
+ {
567
+ kind: 'task-section',
568
+ direction: 'output',
569
+ required: false,
570
+ identity: 'Review',
571
+ description:
572
+ 'fallback-only: backfills Review only when the section is bare; never overwrites authored Review',
573
+ },
574
+ ],
575
+ reasoning_skill: 'inline',
576
+ gates: [],
577
+ mutation_class: 'corpus',
578
+ retry: { max_attempts: 1, terminal_stop: 'block', timeout_seconds: 120 },
579
+ model_policy: policy('record'),
580
+ context_layers: [layer('task-state')],
581
+ observability: [event('stage-started')],
582
+ execution: inlineDeterministic('cli'),
583
+ },
584
+ {
585
+ schema_version: CURRENT_SCHEMA_VERSION,
586
+ id: 'dogfood',
587
+ description: 'dev-dogfood: end-to-end driver test of a skill/command/CLI',
588
+ artifacts: [
589
+ { kind: 'dogfood-report', direction: 'output', required: true },
590
+ { kind: 'monitor-ledger', direction: 'output' },
591
+ ],
592
+ reasoning_skill: 'sp:dogfood-testing',
593
+ required_references: ['references/monitor-ledger.md', 'references/report-template.md'],
594
+ gates: [],
595
+ mutation_class: 'driver',
596
+ retry: { max_attempts: 3, terminal_stop: 'block', timeout_seconds: 600 },
597
+ model_policy: policy('dogfood'),
598
+ context_layers: [layer('task-state'), layer('run-state')],
599
+ observability: [event('stage-started'), event('gate-passed'), event('report-emitted')],
600
+ execution: inlineInline(),
601
+ },
602
+ {
603
+ schema_version: CURRENT_SCHEMA_VERSION,
604
+ id: 'handover',
605
+ description: 'dev-handover: structured handover document when blocked',
606
+ artifacts: [{ kind: 'handover-doc', direction: 'output', required: true }],
607
+ reasoning_skill: 'inline',
608
+ gates: [],
609
+ mutation_class: 'corpus',
610
+ retry: { max_attempts: 1, terminal_stop: 'block', timeout_seconds: 120 },
611
+ model_policy: policy('handover', ['capable-1']),
612
+ context_layers: [layer('task-state')],
613
+ observability: [event('stage-started')],
614
+ execution: inlineDeterministic('cli'),
615
+ },
616
+ {
617
+ schema_version: CURRENT_SCHEMA_VERSION,
618
+ id: 'fixall',
619
+ description: 'dev-fixall: lint + type + test fix loop',
620
+ artifacts: [
621
+ { kind: 'lint-report', direction: 'output' },
622
+ { kind: 'test-report', direction: 'output' },
623
+ ],
624
+ reasoning_skill: 'inline',
625
+ gates: [],
626
+ mutation_class: 'code',
627
+ retry: { max_attempts: 1, terminal_stop: 'block', timeout_seconds: 120 },
628
+ model_policy: policy('fixall', ['capable-1']),
629
+ context_layers: [layer('task-state')],
630
+ observability: [event('stage-started')],
631
+ execution: inlineDeterministic('script'),
632
+ },
633
+ {
634
+ schema_version: CURRENT_SCHEMA_VERSION,
635
+ id: 'brainstorm',
636
+ description: 'dev-brainstorm: structured ideation with trade-off analysis',
637
+ artifacts: [{ kind: 'brainstorm-outline', direction: 'output', required: true }],
638
+ reasoning_skill: 'sp:brainstorm',
639
+ gates: [],
640
+ mutation_class: 'corpus',
641
+ retry: { max_attempts: 2, terminal_stop: 'block', timeout_seconds: 300 },
642
+ model_policy: policy('brainstorm'),
643
+ context_layers: [layer('project-authority'), layer('task-state')],
644
+ observability: [event('stage-started')],
645
+ execution: inlineInline(),
646
+ },
647
+ {
648
+ schema_version: CURRENT_SCHEMA_VERSION,
649
+ id: 'changelog',
650
+ description: 'dev-changelog: generate changelog from git commits',
651
+ artifacts: [{ kind: 'changelog-entry', direction: 'output' }],
652
+ reasoning_skill: 'inline',
653
+ gates: [],
654
+ mutation_class: 'none',
655
+ retry: { max_attempts: 1, terminal_stop: 'block', timeout_seconds: 60 },
656
+ model_policy: policy('changelog'),
657
+ context_layers: [],
658
+ observability: [event('stage-started')],
659
+ execution: inlineDeterministic('cli'),
660
+ },
661
+ ];
662
+
663
+ // ─── Stage lookup ────────────────────────────────────────────────────────
664
+
665
+ export const STAGE_BY_ID = new Map<string, StageRecord>(REGISTERED_STAGES.map((s) => [s.id, s]));
666
+ for (const s of REGISTERED_STAGES) {
667
+ for (const a of s.aliases ?? []) {
668
+ STAGE_BY_ID.set(a, s);
669
+ }
670
+ }
671
+
672
+ export function getStage(id: string): StageRecord | undefined {
673
+ return STAGE_BY_ID.get(id);
674
+ }
675
+
676
+ export function listStages(): StageLookupEntry[] {
677
+ const COMMAND_BY_ID: Record<string, string> = {
678
+ refine: '/sp:dev-refine',
679
+ plan: '/sp:dev-plan',
680
+ implement: '/sp:dev-run --mode implement',
681
+ // Coverage gap-fill competency (not the pipeline quality-gate hops).
682
+ test: '/sp:dev-unit',
683
+ // Pipeline test/test-fix/test-recheck family — shell gate + fixall.
684
+ 'quality-gate': '/sp:dev-fixall',
685
+ verify: '/sp:dev-verify',
686
+ wrap: '/sp:dev-wrap',
687
+ record: 'spur task record',
688
+ review: '/sp:dev-review',
689
+ dogfood: '/sp:dev-dogfood',
690
+ handover: 'inline (dev-handover)',
691
+ fixall: 'inline (dev-fixall)',
692
+ brainstorm: '/sp:dev-brainstorm',
693
+ changelog: 'inline (dev-changelog)',
694
+ };
695
+ return REGISTERED_STAGES.map((s) => ({
696
+ stage_id: s.id,
697
+ command: COMMAND_BY_ID[s.id] ?? `/sp:dev-${s.id}`,
698
+ skill: s.reasoning_skill,
699
+ }));
700
+ }
701
+
702
+ // ─── TABLE A: task status → dispatch (routing-table.md §1) ──────────────
703
+
704
+ interface TableARow {
705
+ condition: (input: ResolutionInput) => boolean;
706
+ dispatch: string | null;
707
+ rowId: string;
708
+ chain: boolean;
709
+ stop: boolean;
710
+ stopReason?: string;
711
+ probe: boolean;
712
+ requiresConfirmation: boolean;
713
+ }
714
+
715
+ function statusEquals(s: string): (input: ResolutionInput) => boolean {
716
+ return (input) => input.task?.status === s;
717
+ }
718
+
719
+ function statusAndDepsSatisfied(s: string): (input: ResolutionInput) => boolean {
720
+ return (input) => {
721
+ if (input.task?.status !== s) return false;
722
+ const deps = input.task?.dependencies ?? [];
723
+ if (deps.length === 0) return true;
724
+ return deps.every((d) => d.status === 'done');
725
+ };
726
+ }
727
+
728
+ // Exported for tests/routing-table-parity.test.ts (C2): the markdown routing table
729
+ // and these rows are two live representations of one contract and must be pinned.
730
+ export const TABLE_A: TableARow[] = [
731
+ // A1 — backlog → refine
732
+ {
733
+ condition: statusEquals('backlog'),
734
+ dispatch: '/sp:dev-refine {wbs} --auto --next',
735
+ rowId: 'A1',
736
+ chain: true,
737
+ stop: false,
738
+ probe: true,
739
+ requiresConfirmation: false,
740
+ },
741
+ // A2 — todo with unmet deps → STOP
742
+ {
743
+ condition: (input) => {
744
+ if (input.task?.status !== 'todo') return false;
745
+ const deps = input.task?.dependencies ?? [];
746
+ return deps.length > 0 && deps.some((d) => d.status !== 'done');
747
+ },
748
+ dispatch: null,
749
+ rowId: 'A2',
750
+ chain: false,
751
+ stop: true,
752
+ stopReason: 'blocked by open dependencies',
753
+ probe: false,
754
+ requiresConfirmation: false,
755
+ },
756
+ // A3 — todo with satisfied deps → run --mode implement --next.
757
+ // The explicit mode is load-bearing (bug-742, routing-table.md §1 A3): without
758
+ // it the pipeline step can recursively launch full mode, and the dispatch also
759
+ // fails to match the implement pattern in STAGE_BY_DISPATCH, resolving to a null
760
+ // stage record. Pinned against routing-table.md by tests/routing-table-parity.test.ts.
761
+ {
762
+ condition: statusAndDepsSatisfied('todo'),
763
+ dispatch: '/sp:dev-run {wbs} --mode implement --auto --next',
764
+ rowId: 'A3',
765
+ chain: true,
766
+ stop: false,
767
+ probe: true,
768
+ requiresConfirmation: false,
769
+ },
770
+ // A4 — wip with checkpoint → continue
771
+ {
772
+ condition: (input) => input.task?.status === 'wip' && input.task?.hasCheckpoint === true,
773
+ dispatch: '/sp:dev-run {wbs} --continue',
774
+ rowId: 'A4',
775
+ chain: false,
776
+ stop: false,
777
+ probe: false,
778
+ requiresConfirmation: false,
779
+ },
780
+ // A5 — wip no checkpoint → implement --next
781
+ {
782
+ condition: statusEquals('wip'),
783
+ dispatch: '/sp:dev-run {wbs} --mode implement --auto --next',
784
+ rowId: 'A5',
785
+ chain: true,
786
+ stop: false,
787
+ probe: true,
788
+ requiresConfirmation: false,
789
+ },
790
+ // A6 — testing → verify
791
+ {
792
+ condition: statusEquals('testing'),
793
+ dispatch: '/sp:dev-verify {wbs} --auto --next',
794
+ rowId: 'A6',
795
+ chain: true,
796
+ stop: false,
797
+ probe: true,
798
+ requiresConfirmation: false,
799
+ },
800
+ // A7 — blocked → handover
801
+ {
802
+ condition: statusEquals('blocked'),
803
+ dispatch: '/sp:dev-handover <blocker>',
804
+ rowId: 'A7',
805
+ chain: false,
806
+ stop: true,
807
+ stopReason: 'blocked — handover document needed',
808
+ probe: false,
809
+ requiresConfirmation: true,
810
+ },
811
+ // A8 — done → wrap
812
+ {
813
+ condition: statusEquals('done'),
814
+ dispatch: '/sp:dev-wrap {wbs}',
815
+ rowId: 'A8',
816
+ chain: false,
817
+ stop: false,
818
+ probe: false,
819
+ requiresConfirmation: true,
820
+ },
821
+ // A9 — cancelled → STOP
822
+ {
823
+ condition: statusEquals('cancelled'),
824
+ dispatch: null,
825
+ rowId: 'A9',
826
+ chain: false,
827
+ stop: true,
828
+ stopReason: 'cancelled — nothing to advance',
829
+ probe: false,
830
+ requiresConfirmation: false,
831
+ },
832
+ ];
833
+
834
+ // ─── TABLE B: feature-level routing (routing-table.md §2) ────────────────
835
+
836
+ interface TableBRow {
837
+ condition: (input: ResolutionInput) => boolean;
838
+ dispatch: ((input: ResolutionInput) => string | null) | null;
839
+ rowId: string;
840
+ chain: boolean;
841
+ stop: boolean;
842
+ stopReason?: string;
843
+ requiresConfirmation: boolean;
844
+ }
845
+
846
+ /** Exported for tests/routing-table-parity.test.ts (C2). */
847
+ export const TABLE_B: TableBRow[] = [
848
+ // B0 — unknown feature
849
+ {
850
+ condition: (input) => input.feature == null,
851
+ dispatch: null,
852
+ rowId: 'B0',
853
+ chain: false,
854
+ stop: true,
855
+ stopReason: 'unknown feature id',
856
+ requiresConfirmation: false,
857
+ },
858
+ // B1 — cancelled
859
+ {
860
+ condition: (input) => input.feature?.status === 'cancelled',
861
+ dispatch: null,
862
+ rowId: 'B1',
863
+ chain: false,
864
+ stop: true,
865
+ stopReason: 'feature cancelled',
866
+ requiresConfirmation: false,
867
+ },
868
+ // B2 — done
869
+ {
870
+ condition: (input) => input.feature?.status === 'done',
871
+ dispatch: null,
872
+ rowId: 'B2',
873
+ chain: false,
874
+ stop: true,
875
+ stopReason: 'feature already done',
876
+ requiresConfirmation: false,
877
+ },
878
+ // B3 — frontier task exists → recurse TABLE A
879
+ {
880
+ condition: (input) => {
881
+ const tasks = input.feature?.tasks ?? [];
882
+ return tasks.some((t) => ['backlog', 'todo', 'wip', 'testing', 'blocked'].includes(t.status));
883
+ },
884
+ dispatch: null, // Handled specially by resolveStage — picks frontier task
885
+ rowId: 'B3',
886
+ chain: false,
887
+ stop: false,
888
+ requiresConfirmation: false,
889
+ },
890
+ // B4 — no frontier, feature backlog, AC invalid
891
+ {
892
+ condition: (input) => {
893
+ if (input.feature?.status !== 'backlog') return false;
894
+ const open = (input.feature?.tasks ?? []).filter((t) =>
895
+ ['backlog', 'todo', 'wip', 'testing', 'blocked'].includes(t.status),
896
+ );
897
+ return open.length === 0;
898
+ },
899
+ dispatch: null, // stop row — resolveFeature returns before invoking dispatch
900
+ rowId: 'B4',
901
+ chain: false,
902
+ stop: true,
903
+ stopReason: 'feature needs description and AC decomposition',
904
+ requiresConfirmation: true,
905
+ },
906
+ // B5 — no frontier, valid AC but zero tasks
907
+ {
908
+ condition: (input) => {
909
+ if (
910
+ input.feature?.status === 'blocked' ||
911
+ input.feature?.status === 'cancelled' ||
912
+ input.feature?.status === 'done'
913
+ )
914
+ return false;
915
+ const tasks = input.feature?.tasks ?? [];
916
+ const open = tasks.filter((t) => !['done', 'cancelled'].includes(t.status));
917
+ return open.length === 0 && tasks.length === 0;
918
+ },
919
+ dispatch: null, // stop row — resolveFeature returns before invoking dispatch
920
+ rowId: 'B5',
921
+ chain: false,
922
+ stop: true,
923
+ stopReason: 'no tasks created yet — run dev-plan to decompose',
924
+ requiresConfirmation: true,
925
+ },
926
+ // B6 — all tasks done, feature active/verifying → wrapall
927
+ {
928
+ condition: (input) => {
929
+ if (!input.feature) return false;
930
+ if (!['active', 'verifying'].includes(input.feature.status)) return false;
931
+ const tasks = input.feature?.tasks ?? [];
932
+ return tasks.length > 0 && tasks.every((t) => ['done', 'cancelled'].includes(t.status));
933
+ },
934
+ dispatch: (input) => `/sp:dev-wrapall --feature ${input.feature?.id ?? ''}`,
935
+ rowId: 'B6',
936
+ chain: false,
937
+ stop: false,
938
+ requiresConfirmation: true,
939
+ },
940
+ // B7 — mixed cancelled/done only
941
+ {
942
+ condition: (input) => {
943
+ if (!input.feature) return false;
944
+ const tasks = input.feature?.tasks ?? [];
945
+ if (tasks.length === 0) return false;
946
+ return tasks.every((t) => ['done', 'cancelled'].includes(t.status));
947
+ },
948
+ dispatch: null,
949
+ rowId: 'B7',
950
+ chain: false,
951
+ stop: true,
952
+ stopReason: 'all tasks are done or cancelled — no action needed',
953
+ requiresConfirmation: false,
954
+ },
955
+ // B8 — blocked
956
+ {
957
+ condition: (input) => input.feature?.status === 'blocked',
958
+ dispatch: null,
959
+ rowId: 'B8',
960
+ chain: false,
961
+ stop: true,
962
+ stopReason: 'feature is blocked — resolve blocker first',
963
+ requiresConfirmation: false,
964
+ },
965
+ ];
966
+
967
+ // ─── TABLE C light-gate short-circuit (routing-table.md §3) ─────────────
968
+
969
+ // Note: no `condition` field — TABLE C rows encode external runtime checks
970
+ // (lint failures, test failures, rule findings) that this pure adapter cannot
971
+ // evaluate. Rows are only consumed via C_REDIRECT_TABLE lookup, never matched.
972
+ interface TableCRow {
973
+ redirectDispatch: string;
974
+ rowId: string;
975
+ probeRows: string[];
976
+ }
977
+
978
+ const TABLE_C: TableCRow[] = [
979
+ {
980
+ // C1 — spur task check L3 findings: external check, not evaluable here
981
+ redirectDispatch: '/sp:dev-refine {wbs} --auto',
982
+ rowId: 'C1',
983
+ probeRows: ['A1', 'A3', 'A5'],
984
+ },
985
+ {
986
+ // C2 — lint failures: requires runtime check
987
+ redirectDispatch: '/sp:dev-fixall',
988
+ rowId: 'C2',
989
+ probeRows: ['A3', 'A5'],
990
+ },
991
+ {
992
+ // C3 — test failures: requires runtime check
993
+ redirectDispatch: '/sp:dev-unit {wbs} --auto',
994
+ rowId: 'C3',
995
+ probeRows: ['A5', 'A6'],
996
+ },
997
+ {
998
+ // C4 — rule findings: requires spur rule run
999
+ // SAFETY: sentinel, not a string — this row never dispatches; null is the
1000
+ // intentional HITL-stop placeholder that formatStageResult renders as no-command.
1001
+ redirectDispatch: null as unknown as string, // HITL stop
1002
+ rowId: 'C4',
1003
+ probeRows: ['A3', 'A5', 'A6'],
1004
+ },
1005
+ {
1006
+ // C5 — verdict FAIL pointing at coverage
1007
+ redirectDispatch: '/sp:dev-unit {wbs}',
1008
+ rowId: 'C5',
1009
+ probeRows: ['A6'],
1010
+ },
1011
+ ];
1012
+
1013
+ // ─── Table C helpers ────────────────────────────────────────────────────
1014
+
1015
+ const C_REDIRECT_TABLE: Record<string, TableCRow> = {};
1016
+ for (const row of TABLE_C) {
1017
+ for (const pr of row.probeRows) {
1018
+ C_REDIRECT_TABLE[pr] = row;
1019
+ }
1020
+ }
1021
+
1022
+ export function getTableCRedirect(rowId: string): TableCRow | undefined {
1023
+ return C_REDIRECT_TABLE[rowId];
1024
+ }
1025
+
1026
+ // ─── Core resolution ────────────────────────────────────────────────────
1027
+
1028
+ /**
1029
+ * Pick the best frontier task from a feature's task list (TABLE B3 algorithm).
1030
+ */
1031
+ export function pickFrontierTask(tasks: TaskSignal[]): TaskSignal | null {
1032
+ const OPEN_STATUSES: Record<string, number> = {
1033
+ todo: 0,
1034
+ backlog: 1,
1035
+ wip: 2,
1036
+ testing: 3,
1037
+ blocked: 4,
1038
+ };
1039
+ const candidates = tasks.filter((t) => {
1040
+ const rank = OPEN_STATUSES[t.status];
1041
+ if (rank === undefined) return false;
1042
+ // Exclude tasks with blocked-by-dep (not all dependencies done)
1043
+ const deps = t.dependencies ?? [];
1044
+ if (deps.length > 0 && deps.some((d) => d.status !== 'done')) return false;
1045
+ return true;
1046
+ });
1047
+ if (candidates.length === 0) return null;
1048
+ candidates.sort((a, b) => {
1049
+ const rankA = OPEN_STATUSES[a.status] ?? 99;
1050
+ const rankB = OPEN_STATUSES[b.status] ?? 99;
1051
+ if (rankA !== rankB) return rankA - rankB;
1052
+ return a.wbs.localeCompare(b.wbs);
1053
+ });
1054
+ return candidates[0] ?? null;
1055
+ }
1056
+
1057
+ /**
1058
+ * Build the unmet dependencies list for the A2 block message.
1059
+ */
1060
+ export function unmetDependencies(task: TaskSignal): string[] {
1061
+ return (task.dependencies ?? []).filter((d) => d.status !== 'done').map((d) => d.wbs);
1062
+ }
1063
+
1064
+ /**
1065
+ * Resolve a task WBS or feature id to the next canonical stage.
1066
+ *
1067
+ * Implements the TABLE A/B/C algorithm from routing-table.md.
1068
+ * Returns a StageResolution describing the selected stage (or stop reason).
1069
+ */
1070
+ export function resolveStage(input: ResolutionInput): StageResolution {
1071
+ // Feature mode
1072
+ if (input.feature != null) {
1073
+ return resolveFeature(input);
1074
+ }
1075
+
1076
+ // Task mode — TABLE A
1077
+ if (input.task != null) {
1078
+ return resolveTask(input);
1079
+ }
1080
+
1081
+ return {
1082
+ stage: null,
1083
+ target: input.target,
1084
+ status: 'unknown',
1085
+ tableRow: null,
1086
+ reason: 'no target resolved — pass a task WBS or feature id',
1087
+ reasonKind: 'usage',
1088
+ requiresConfirmation: false,
1089
+ chain: false,
1090
+ dispatchCommand: null,
1091
+ };
1092
+ }
1093
+
1094
+ function resolveTask(input: ResolutionInput): StageResolution {
1095
+ if (input.task == null) throw new Error('resolveTask called without task');
1096
+ const task = input.task;
1097
+ const wbs = task.wbs;
1098
+
1099
+ // Apply TABLE A rows in order (A4 fires before A5 for wip+checkpoint;
1100
+ // A5 catches wip without checkpoint since A4's condition is narrower)
1101
+ for (const row of TABLE_A) {
1102
+ if (!row.condition(input)) continue;
1103
+
1104
+ // Build the dispatch command
1105
+ let dispatchCmd: string | null = row.dispatch;
1106
+ if (dispatchCmd) {
1107
+ dispatchCmd = dispatchCmd.replace(/\{wbs\}/g, wbs);
1108
+ }
1109
+
1110
+ // Apply flag forwarding
1111
+ if (input.once && dispatchCmd) {
1112
+ dispatchCmd = dispatchCmd.replace(/ --next/g, '');
1113
+ }
1114
+ if (input.auto && dispatchCmd && !dispatchCmd.includes('--auto')) {
1115
+ dispatchCmd += ' --auto';
1116
+ }
1117
+ if (input.fullMode && dispatchCmd) {
1118
+ if (dispatchCmd.includes('--mode implement') || dispatchCmd.includes('--next')) {
1119
+ dispatchCmd = `/sp:dev-run ${wbs} --mode full`;
1120
+ }
1121
+ }
1122
+
1123
+ // Map dispatch command → canonical stage id, first match wins.
1124
+ //
1125
+ // Patterns, not substrings: the run routes carry the WBS between the verb
1126
+ // and the flag (`/sp:dev-run 0104 --mode implement`), so the old
1127
+ // `'run --mode'` substring key could never match and every A3/A4/A5
1128
+ // dispatch resolved to a null stage record — silently dropping the
1129
+ // implement stage's gates, retry and model policy from the resolution.
1130
+ const STAGE_BY_DISPATCH: Array<{ pattern: RegExp; stage: string }> = [
1131
+ { pattern: /dev-refine/, stage: 'refine' },
1132
+ { pattern: /dev-verify/, stage: 'verify' },
1133
+ { pattern: /dev-wrap/, stage: 'wrap' },
1134
+ { pattern: /dev-handover/, stage: 'handover' },
1135
+ { pattern: /dev-run\b[^\n]*--mode/, stage: 'implement' },
1136
+ { pattern: /dev-run\b[^\n]*--continue/, stage: 'implement' },
1137
+ { pattern: /dev-unit/, stage: 'test' },
1138
+ { pattern: /dev-fixall/, stage: 'fixall' },
1139
+ // Pipeline quality-gate hops share the fixall repair surface.
1140
+ { pattern: /test-fix|test-recheck|quality-gate/, stage: 'quality-gate' },
1141
+ ];
1142
+ let stageId: string | undefined;
1143
+ if (dispatchCmd) {
1144
+ for (const { pattern, stage: sid } of STAGE_BY_DISPATCH) {
1145
+ if (pattern.test(dispatchCmd)) {
1146
+ stageId = sid;
1147
+ break;
1148
+ }
1149
+ }
1150
+ }
1151
+ const stage = stageId ? (getStage(stageId) ?? null) : null;
1152
+
1153
+ if (row.stop) {
1154
+ const stopReason = row.stopReason ?? 'no route';
1155
+ return {
1156
+ stage: null,
1157
+ target: wbs,
1158
+ status: task.status,
1159
+ tableRow: row.rowId,
1160
+ reason: stopReason,
1161
+ reasonKind: row.rowId === 'A2' ? 'blocked' : 'no-route',
1162
+ requiresConfirmation: row.requiresConfirmation,
1163
+ chain: row.chain,
1164
+ dispatchCommand: null,
1165
+ blocker: row.rowId === 'A2' ? `unmet deps: ${unmetDependencies(task).join(', ')}` : undefined,
1166
+ nextObservableOutcome:
1167
+ row.rowId === 'A2'
1168
+ ? 'resolve open dependencies'
1169
+ : row.rowId === 'A7'
1170
+ ? 'handover document created'
1171
+ : row.rowId === 'A9'
1172
+ ? 'no action needed'
1173
+ : undefined,
1174
+ };
1175
+ }
1176
+
1177
+ const OUTCOME_BY_ROW: Record<string, string> = {
1178
+ A1: 'refined task with filled sections',
1179
+ A3: 'code changes implemented',
1180
+ A4: 'implementation resumed from checkpoint',
1181
+ A5: 'implementation continued',
1182
+ A6: 'verification verdict (PASS/PARTIAL/FAIL)',
1183
+ A8: 'learnings recorded, doc synced',
1184
+ };
1185
+
1186
+ const REASON_BY_ROW: Record<string, string> = {
1187
+ A1: 'backlog — needs refinement',
1188
+ A3: 'todo — ready to implement',
1189
+ A4: 'wip — resume from checkpoint',
1190
+ A5: 'wip — continue implementing',
1191
+ A6: 'testing — verify results',
1192
+ A8: 'done — wrap up',
1193
+ };
1194
+
1195
+ return {
1196
+ stage,
1197
+ target: wbs,
1198
+ status: task.status,
1199
+ tableRow: row.rowId,
1200
+ reason: REASON_BY_ROW[row.rowId] ?? 'dispatch',
1201
+ reasonKind: 'dispatch',
1202
+ requiresConfirmation: row.requiresConfirmation,
1203
+ chain: row.chain,
1204
+ dispatchCommand: dispatchCmd,
1205
+ nextObservableOutcome: OUTCOME_BY_ROW[row.rowId],
1206
+ };
1207
+ }
1208
+
1209
+ // No row matched
1210
+ return {
1211
+ stage: null,
1212
+ target: wbs,
1213
+ status: task.status,
1214
+ tableRow: null,
1215
+ reason: `no route for ${wbs} (status=${task.status})`,
1216
+ reasonKind: 'no-route',
1217
+ requiresConfirmation: false,
1218
+ chain: false,
1219
+ dispatchCommand: null,
1220
+ };
1221
+ }
1222
+ function resolveFeature(input: ResolutionInput): StageResolution {
1223
+ if (input.feature == null) throw new Error('resolveFeature called without feature');
1224
+ const feature = input.feature;
1225
+
1226
+ for (const row of TABLE_B) {
1227
+ if (!row.condition(input)) continue;
1228
+
1229
+ // B3: frontier task → recurse TABLE A on the picked task
1230
+ if (row.rowId === 'B3') {
1231
+ const frontier = pickFrontierTask(feature.tasks ?? []);
1232
+ if (!frontier) {
1233
+ return {
1234
+ stage: null,
1235
+ target: feature.id,
1236
+ status: feature.status,
1237
+ tableRow: 'B3',
1238
+ reason: 'no frontier task found after condition matched — inconsistency',
1239
+ reasonKind: 'error',
1240
+ requiresConfirmation: false,
1241
+ chain: false,
1242
+ dispatchCommand: null,
1243
+ };
1244
+ }
1245
+ const taskInput: ResolutionInput = {
1246
+ target: frontier.wbs,
1247
+ task: frontier,
1248
+ feature: input.feature,
1249
+ dryRun: input.dryRun,
1250
+ once: input.once,
1251
+ auto: input.auto,
1252
+ fullMode: input.fullMode,
1253
+ };
1254
+ return resolveTask(taskInput);
1255
+ }
1256
+
1257
+ if (row.stop) {
1258
+ return {
1259
+ stage: null,
1260
+ target: feature.id,
1261
+ status: feature.status,
1262
+ tableRow: row.rowId,
1263
+ reason: row.stopReason ?? 'stop',
1264
+ reasonKind: 'blocked',
1265
+ requiresConfirmation: row.requiresConfirmation,
1266
+ chain: false,
1267
+ dispatchCommand: null,
1268
+ blocker: row.stopReason,
1269
+ };
1270
+ }
1271
+
1272
+ const dispatchCmd = row.dispatch?.(input);
1273
+ const stage = dispatchCmd?.includes('wrapall') ? getStage('wrap') : null;
1274
+
1275
+ return {
1276
+ stage: stage ?? null,
1277
+ target: feature.id,
1278
+ status: feature.status,
1279
+ tableRow: row.rowId,
1280
+ reason: row.rowId === 'B6' ? 'all tasks done — wrap up feature' : 'feature route',
1281
+ reasonKind: 'dispatch',
1282
+ requiresConfirmation: row.requiresConfirmation,
1283
+ chain: false,
1284
+ dispatchCommand: dispatchCmd,
1285
+ nextObservableOutcome: row.rowId === 'B6' ? 'feature transition or wrap' : undefined,
1286
+ };
1287
+ }
1288
+
1289
+ return {
1290
+ stage: null,
1291
+ target: feature.id,
1292
+ status: feature.status,
1293
+ tableRow: null,
1294
+ reason: `no route for feature ${feature.id}`,
1295
+ reasonKind: 'no-route',
1296
+ requiresConfirmation: false,
1297
+ chain: false,
1298
+ dispatchCommand: null,
1299
+ };
1300
+ }
1301
+
1302
+ // ─── Utility: display stage help ────────────────────────────────────────
1303
+
1304
+ export function renderHelp(): string {
1305
+ const lines: string[] = [
1306
+ 'stage-registry-adapter — dev-next golden-path adapter',
1307
+ '',
1308
+ 'Usage:',
1309
+ ' bun plugins/sp/scripts/stage-registry-adapter.ts \\',
1310
+ ' --wbs <wbs> [--dry-run] [--auto] [--once] [--full]',
1311
+ ' bun plugins/sp/scripts/stage-registry-adapter.ts \\',
1312
+ ' --feature <id> [--dry-run] [--auto]',
1313
+ ' bun plugins/sp/scripts/stage-registry-adapter.ts --list-stages',
1314
+ ' bun plugins/sp/scripts/stage-registry-adapter.ts --help',
1315
+ '',
1316
+ 'Options:',
1317
+ ' --wbs <wbs> Task WBS to resolve (digits)',
1318
+ ' --feature <id> Feature id to resolve (e.g. O)',
1319
+ ' --dry-run Print plan without dispatching',
1320
+ ' --auto Forward --auto to dispatched commands',
1321
+ ' --once Suppress --next chain',
1322
+ ' --full Use --mode full for implement',
1323
+ ' --list-stages List all registered stages',
1324
+ ' --help Show this message',
1325
+ '',
1326
+ 'Registered stages:',
1327
+ ];
1328
+ for (const s of REGISTERED_STAGES) {
1329
+ const aliases = s.aliases?.length ? ` (${s.aliases.join(', ')})` : '';
1330
+ lines.push(` ${s.id}${aliases} — ${s.description}`);
1331
+ }
1332
+ return lines.join('\n');
1333
+ }
1334
+
1335
+ // ─── CLI entry point ────────────────────────────────────────────────────
1336
+
1337
+ export interface CliArgs {
1338
+ wbs?: string;
1339
+ feature?: string;
1340
+ taskStatus?: string;
1341
+ dryRun: boolean;
1342
+ auto: boolean;
1343
+ once: boolean;
1344
+ full: boolean;
1345
+ listStages: boolean;
1346
+ help: boolean;
1347
+ }
1348
+
1349
+ /** Result of a CLI invocation. */
1350
+ export interface CliResult {
1351
+ exitCode: number;
1352
+ stdout: string;
1353
+ stderr: string;
1354
+ }
1355
+
1356
+ export function parseCliArgs(argv: string[]): CliArgs {
1357
+ const args = argv;
1358
+ let wbs: string | undefined;
1359
+ let feature: string | undefined;
1360
+ let taskStatus: string | undefined;
1361
+ let dryRun = false;
1362
+ let auto = false;
1363
+ let once = false;
1364
+ let full = false;
1365
+ let listStages = false;
1366
+ let help = false;
1367
+ for (let i = 0; i < args.length; i++) {
1368
+ const arg = args[i];
1369
+ if (arg == null) continue;
1370
+ if (arg === '--wbs') {
1371
+ wbs = args[++i];
1372
+ } else if (arg === '--feature') {
1373
+ feature = args[++i];
1374
+ } else if (arg === '--task-status') {
1375
+ taskStatus = args[++i];
1376
+ } else if (arg === '--dry-run') {
1377
+ dryRun = true;
1378
+ } else if (arg === '--auto') {
1379
+ auto = true;
1380
+ } else if (arg === '--once') {
1381
+ once = true;
1382
+ } else if (arg === '--full') {
1383
+ full = true;
1384
+ } else if (arg === '--list-stages') {
1385
+ listStages = true;
1386
+ } else if (arg === '--help') {
1387
+ help = true;
1388
+ }
1389
+ }
1390
+
1391
+ return { wbs, feature, taskStatus, dryRun, auto, once, full, listStages, help };
1392
+ }
1393
+
1394
+ /**
1395
+ * Format a resolved stage result as human-readable output lines.
1396
+ * Exported for testing — separates presentation from I/O.
1397
+ */
1398
+ export function formatStageResult(result: StageResolution): string[] {
1399
+ const lines = [
1400
+ `dev-next: ${result.reasonKind === 'dispatch' ? 'dispatch' : result.reasonKind}`,
1401
+ ` target: ${result.target} status=${result.status} table=${result.tableRow ?? '\u2014'}`,
1402
+ ` reason: ${result.reason}`,
1403
+ ];
1404
+ if (result.blocker) {
1405
+ lines.push(` blocker: ${result.blocker}`);
1406
+ }
1407
+ if (result.dispatchCommand) {
1408
+ lines.push(` dispatch: ${result.dispatchCommand}`);
1409
+ }
1410
+ if (result.chain) {
1411
+ lines.push(' chain: yes');
1412
+ }
1413
+ if (result.requiresConfirmation) {
1414
+ lines.push(' confirmation: required');
1415
+ }
1416
+ if (result.nextObservableOutcome) {
1417
+ lines.push(` next outcome: ${result.nextObservableOutcome}`);
1418
+ }
1419
+ return lines;
1420
+ }
1421
+
1422
+ /** Current repository HEAD, best-effort — null outside a work tree or without git. */
1423
+ function currentHeadCommit(): string | null {
1424
+ try {
1425
+ const out = execSync('git rev-parse HEAD', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
1426
+ const commit = out.trim();
1427
+ return commit.length > 0 ? commit : null;
1428
+ } catch {
1429
+ return null;
1430
+ }
1431
+ }
1432
+
1433
+ export function runCli(argv: string[], opts?: { resolve?: (input: ResolutionInput) => StageResolution }): CliResult {
1434
+ const parsed = parseCliArgs(argv);
1435
+ const resolve = opts?.resolve ?? resolveStage;
1436
+
1437
+ if (parsed.help) {
1438
+ return { exitCode: 0, stdout: renderHelp(), stderr: '' };
1439
+ }
1440
+
1441
+ if (parsed.listStages) {
1442
+ const stagesList = listStages();
1443
+ const stageLines = stagesList.map((s) => `${s.stage_id}\t${s.command}\t${s.skill}`);
1444
+ return { exitCode: 0, stdout: `${stageLines.join('\n')}\n`, stderr: '' };
1445
+ }
1446
+
1447
+ if (!parsed.wbs && !parsed.feature) {
1448
+ return {
1449
+ exitCode: 1,
1450
+ stdout: '',
1451
+ stderr: `error: specify --wbs <wbs> or --feature <id>\n\n${renderHelp()}`,
1452
+ };
1453
+ }
1454
+
1455
+ // Routing checkpoint (0711 R3): a canonical checkpoint at
1456
+ // .spur/memory/sessions/<wbs>-checkpoint.md that survives inspection (owner,
1457
+ // non-terminal, at HEAD, artifacts present) enables the A4 resume row for wip
1458
+ // tasks; anything else is REPORTED and ignored — routing falls through to the
1459
+ // non-checkpoint row. Best-effort and fail-open: git/fs failures leave the
1460
+ // checkpoint unconsidered.
1461
+ let checkpointNote: string | undefined;
1462
+ let hasCheckpoint: boolean | undefined;
1463
+ if (parsed.wbs) {
1464
+ try {
1465
+ const inspection = inspectRoutingCheckpoint(
1466
+ join(process.cwd(), '.spur', 'memory', 'sessions'),
1467
+ parsed.wbs,
1468
+ { headCommit: currentHeadCommit() ?? undefined },
1469
+ );
1470
+ hasCheckpoint = inspection.usable;
1471
+ if (!inspection.usable && inspection.reason !== 'absent') {
1472
+ checkpointNote = `note: routing checkpoint ignored — ${inspection.reason}`;
1473
+ }
1474
+ } catch {
1475
+ hasCheckpoint = undefined;
1476
+ }
1477
+ }
1478
+
1479
+ // Build resolution input from CLI args (note: no live corpus access in CLI mode)
1480
+ const input: ResolutionInput = {
1481
+ target: parsed.wbs ?? parsed.feature ?? '',
1482
+ wbs: parsed.wbs,
1483
+ dryRun: parsed.dryRun,
1484
+ once: parsed.once,
1485
+ auto: parsed.auto,
1486
+ fullMode: parsed.full,
1487
+ task: parsed.wbs
1488
+ ? { wbs: parsed.wbs, status: parsed.taskStatus ?? 'unknown', dependencies: [], hasCheckpoint }
1489
+ : undefined,
1490
+ feature: parsed.feature ? { id: parsed.feature, status: 'active', tasks: [] } : undefined,
1491
+ };
1492
+
1493
+ try {
1494
+ const result = resolve(input);
1495
+ const lines = formatStageResult(result);
1496
+ if (checkpointNote) lines.push(checkpointNote);
1497
+ return { exitCode: result.reasonKind === 'dispatch' ? 0 : 2, stdout: `${lines.join('\n')}\n`, stderr: '' };
1498
+ } catch (e) {
1499
+ return { exitCode: 1, stdout: '', stderr: `error: ${(e as Error).message}` };
1500
+ }
1501
+ }
1502
+
1503
+ /**
1504
+ * Entry-point boot — runs the CLI using process.argv. Extracted so tests can
1505
+ * call it directly without spawning a subprocess (which would not contribute
1506
+ * to the test isolate's V8 coverage counters).
1507
+ *
1508
+ * No `import.meta.main` guard here; the test calls this directly with
1509
+ * `process.exit` swapped for a spy. Production entry point is
1510
+ * `plugins/sp/scripts/main.ts` which calls this when `import.meta.main`.
1511
+ */
1512
+ export function bootMain(
1513
+ argv: string[] = process.argv,
1514
+ opts?: {
1515
+ run?: (a: string[]) => CliResult;
1516
+ exit?: (code: number) => void;
1517
+ stdout?: { write: (data: string) => void };
1518
+ stderr?: { write: (data: string) => void };
1519
+ },
1520
+ ): void {
1521
+ const cliRunner = opts?.run ?? runCli;
1522
+ const doExit = opts?.exit ?? process.exit;
1523
+ const stdout = opts?.stdout ?? process.stdout;
1524
+ const stderr = opts?.stderr ?? process.stderr;
1525
+ const result = cliRunner(argv);
1526
+ if (result.stdout) stdout.write(result.stdout);
1527
+ if (result.stderr) stderr.write(result.stderr);
1528
+ doExit(result.exitCode);
1529
+ }
1530
+
1531
+ if (import.meta.main) {
1532
+ bootMain();
1533
+ }