@gobing-ai/spur 0.3.95 → 0.3.96

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 (155) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/plugin-scripts.json +0 -54
  3. package/config/rules/boundary/sp-script-placement.yaml +19 -0
  4. package/config/rules/strict/runtime-boundaries.yaml +5 -0
  5. package/config/rules/structure/test-location.yaml +2 -0
  6. package/config/rules/typescript/no-syscall-emulation-in-boundary-mock.yaml +1 -1
  7. package/config/rules/typescript/output-boundaries.yaml +15 -2
  8. package/config/script-placement-baseline.json +51 -0
  9. package/config/workflows/feature-verification.yaml +1 -1
  10. package/config/workflows/history-anatomy.yaml +23 -11
  11. package/config/workflows/idea-pipeline.yaml +80 -85
  12. package/config/workflows/pr-review.yaml +18 -9
  13. package/config/workflows/task-pipeline.yaml +42 -95
  14. package/config/workflows/wrapup-pipeline.yaml +89 -37
  15. package/package.json +1 -1
  16. package/plugins/sp/README.md +8 -10
  17. package/plugins/sp/agents/super-reviewer.md +43 -18
  18. package/plugins/sp/commands/dev-fixgha.md +83 -0
  19. package/plugins/sp/commands/dev-gitmsg.md +8 -6
  20. package/plugins/sp/commands/dev-gtd.md +2 -2
  21. package/plugins/sp/commands/dev-idea.md +22 -12
  22. package/plugins/sp/commands/dev-plan.md +8 -9
  23. package/plugins/sp/commands/dev-review.md +22 -13
  24. package/plugins/sp/commands/dev-verifyall.md +1 -1
  25. package/plugins/sp/commands/spur-init.md +2 -2
  26. package/plugins/sp/lib/history-anatomy.generated.d.mts +112 -0
  27. package/plugins/sp/lib/history-anatomy.generated.mjs +686 -0
  28. package/plugins/sp/lib/idea-handoff.generated.mjs +5 -4
  29. package/plugins/sp/lib/inline-run.generated.d.mts +11 -0
  30. package/plugins/sp/lib/inline-run.generated.mjs +25 -8
  31. package/plugins/sp/lib/quality-gate.generated.d.mts +104 -0
  32. package/plugins/sp/lib/quality-gate.generated.mjs +438 -0
  33. package/plugins/sp/lib/residual-scan.generated.d.mts +62 -0
  34. package/plugins/sp/lib/residual-scan.generated.mjs +210 -0
  35. package/plugins/sp/lib/spur-bin.ts +36 -0
  36. package/plugins/sp/lib/step-profile.generated.d.mts +71 -0
  37. package/plugins/sp/lib/step-profile.generated.mjs +174 -0
  38. package/plugins/sp/plugin.json +1 -1
  39. package/plugins/sp/references/roles.md +1 -1
  40. package/plugins/sp/scripts/history-anatomy-cache.mjs +20 -19
  41. package/plugins/sp/scripts/history-anatomy-cache.ts +23 -928
  42. package/plugins/sp/scripts/inline-run-setup.mjs +85 -320
  43. package/plugins/sp/scripts/inline-run-setup.ts +104 -667
  44. package/plugins/sp/scripts/quality-gate.mjs +34 -19
  45. package/plugins/sp/scripts/quality-gate.ts +12 -658
  46. package/plugins/sp/scripts/residual-scan.mjs +135 -156
  47. package/plugins/sp/scripts/residual-scan.ts +102 -499
  48. package/plugins/sp/scripts/script-root.mjs +5 -1
  49. package/plugins/sp/scripts/script-root.ts +5 -1
  50. package/plugins/sp/scripts/workflow-step-profile.mjs +25 -17
  51. package/plugins/sp/scripts/workflow-step-profile.ts +21 -315
  52. package/plugins/sp/scripts/wrapup-drift-probe.mjs +8 -2
  53. package/plugins/sp/scripts/wrapup-drift-probe.ts +3 -2
  54. package/plugins/sp/scripts/wrapup-steps.mjs +10 -32
  55. package/plugins/sp/scripts/wrapup-steps.ts +8 -36
  56. package/plugins/sp/skills/code-improvement/SKILL.md +5 -4
  57. package/plugins/sp/skills/code-verification/SKILL.md +34 -9
  58. package/plugins/sp/skills/code-verification/references/verdict-schema.md +3 -3
  59. package/plugins/sp/skills/functional-review/SKILL.md +7 -4
  60. package/plugins/sp/skills/functional-review/references/verdict-schema.md +1 -1
  61. package/plugins/sp/skills/history-anatomy/references/modes.md +2 -1
  62. package/plugins/sp/skills/next-feature/references/handoff-routing.md +1 -1
  63. package/plugins/sp/skills/next-router/references/routing-table.md +1 -1
  64. package/plugins/sp/skills/spur-cli/SKILL.md +3 -3
  65. package/plugins/sp/skills/spur-cli/references/agent.md +10 -10
  66. package/plugins/sp/skills/spur-cli/references/features.md +17 -6
  67. package/plugins/sp/skills/spur-cli/references/init.md +17 -16
  68. package/plugins/sp/skills/spur-cli/references/self.md +3 -2
  69. package/plugins/sp/skills/spur-cli/references/serve.md +10 -10
  70. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +21 -6
  71. package/plugins/sp/skills/spur-cli/references/tasks.md +14 -8
  72. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +4 -3
  73. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +11 -9
  74. package/plugins/sp/skills/spur-dev/references/decision-brief.md +1 -1
  75. package/plugins/sp/skills/spur-dev/references/dev-operations.md +86 -70
  76. package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +3 -3
  77. package/plugins/sp/skills/spur-dev/references/execution-batch.md +77 -34
  78. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +5 -9
  79. package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +3 -3
  80. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +31 -21
  81. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +19 -19
  82. package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +4 -3
  83. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +29 -3
  84. package/plugins/sp/skills/spur-doctor/SKILL.md +1 -1
  85. package/plugins/sp/skills/sys-architecture/SKILL.md +3 -2
  86. package/spur.js +1495 -369
  87. package/web/_astro/{BoardApp.Cpxntzad.js → BoardApp.BIjMatT1.js} +1 -1
  88. package/web/_astro/{BoardApp.BxJuwD7I.js → BoardApp.GvjIe9Z6.js} +42 -42
  89. package/web/_astro/{TaskDetail.C5bW4WGV.js → TaskDetail.BZM3EAFx.js} +1 -1
  90. package/web/_astro/{arc.CyjRvNMY.js → arc.HkRiZnoI.js} +1 -1
  91. package/web/_astro/{architectureDiagram-3BPJPVTR.B4lWRzJA.js → architectureDiagram-3BPJPVTR.BDdK-tgZ.js} +1 -1
  92. package/web/_astro/{blockDiagram-GPEHLZMM.Be38USQ2.js → blockDiagram-GPEHLZMM.CZ9kOBxx.js} +1 -1
  93. package/web/_astro/{c4Diagram-AAUBKEIU.DT9Fj5Qx.js → c4Diagram-AAUBKEIU.DXK9qeRD.js} +1 -1
  94. package/web/_astro/channel.DlhX2MEt.js +1 -0
  95. package/web/_astro/{chunk-2J33WTMH.SeSBWLg5.js → chunk-2J33WTMH.BTD2WeX5.js} +1 -1
  96. package/web/_astro/{chunk-4BX2VUAB.DoO4VE14.js → chunk-4BX2VUAB.Ci6Qbcvk.js} +1 -1
  97. package/web/_astro/{chunk-55IACEB6.DrmTFA53.js → chunk-55IACEB6.yl3zsj7p.js} +1 -1
  98. package/web/_astro/{chunk-727SXJPM.Bi-TUb_V.js → chunk-727SXJPM.ClXZsfyR.js} +1 -1
  99. package/web/_astro/{chunk-AQP2D5EJ.B-xp8Uhi.js → chunk-AQP2D5EJ.BmKWZBcP.js} +1 -1
  100. package/web/_astro/{chunk-FMBD7UC4.DGWrDnUU.js → chunk-FMBD7UC4.By30fcb7.js} +1 -1
  101. package/web/_astro/{chunk-ND2GUHAM._BagvPDy.js → chunk-ND2GUHAM.24NmrD-k.js} +1 -1
  102. package/web/_astro/{chunk-QZHKN3VN.VOboYWQ0.js → chunk-QZHKN3VN.BN4sKdcS.js} +1 -1
  103. package/web/_astro/{classDiagram-4FO5ZUOK.D_apfHS7.js → classDiagram-4FO5ZUOK.CJpoMPb5.js} +1 -1
  104. package/web/_astro/{classDiagram-v2-Q7XG4LA2.D_apfHS7.js → classDiagram-v2-Q7XG4LA2.CJpoMPb5.js} +1 -1
  105. package/web/_astro/{cose-bilkent-S5V4N54A.DNLU_L-x.js → cose-bilkent-S5V4N54A.BraxQ2Nt.js} +1 -1
  106. package/web/_astro/{cynefin-OW5HDTMX.D8borhV-.js → cynefin-OW5HDTMX.gzoU73oL.js} +1 -1
  107. package/web/_astro/{dagre-BM42HDAG.Uc0lvjcV.js → dagre-BM42HDAG.wyDfWySU.js} +1 -1
  108. package/web/_astro/{diagram-2AECGRRQ.DDIxtDBo.js → diagram-2AECGRRQ.h2_EbMxV.js} +1 -1
  109. package/web/_astro/{diagram-5GNKFQAL.DVoDnD3H.js → diagram-5GNKFQAL.Db6dcs89.js} +1 -1
  110. package/web/_astro/{diagram-KO2AKTUF.CRubTJ-0.js → diagram-KO2AKTUF.NVwzxu_U.js} +1 -1
  111. package/web/_astro/{diagram-LMA3HP47.m9SeYcXQ.js → diagram-LMA3HP47.B-eNgDQk.js} +1 -1
  112. package/web/_astro/{diagram-OG6HWLK6.C6s2ZjV5.js → diagram-OG6HWLK6.Bsa8ZRbJ.js} +1 -1
  113. package/web/_astro/{erDiagram-TEJ5UH35.nSzr5KTj.js → erDiagram-TEJ5UH35.BospRE5Q.js} +1 -1
  114. package/web/_astro/{flowDiagram-I6XJVG4X.DrZcys16.js → flowDiagram-I6XJVG4X.DMzvHwAA.js} +1 -1
  115. package/web/_astro/{ganttDiagram-6RSMTGT7.Do4M1b-K.js → ganttDiagram-6RSMTGT7.Dmdxgd-M.js} +1 -1
  116. package/web/_astro/{gitGraphDiagram-PVQCEYII.BWc9oJ4l.js → gitGraphDiagram-PVQCEYII.CaqdO0rL.js} +1 -1
  117. package/web/_astro/index.EoZlzLK-.css +1 -0
  118. package/web/_astro/{infoDiagram-5YYISTIA.BIdOctFk.js → infoDiagram-5YYISTIA.DDVD9Wn7.js} +1 -1
  119. package/web/_astro/{ishikawaDiagram-YF4QCWOH.CV5SuG2t.js → ishikawaDiagram-YF4QCWOH.EKyCUFsL.js} +1 -1
  120. package/web/_astro/{journeyDiagram-JHISSGLW.CxE-rEJ-.js → journeyDiagram-JHISSGLW.By2-RkYB.js} +1 -1
  121. package/web/_astro/{kanban-definition-UN3LZRKU.BPRdKWs_.js → kanban-definition-UN3LZRKU.ar-WmlyR.js} +1 -1
  122. package/web/_astro/{linear.zRsuuDTE.js → linear.BnzMBgo_.js} +1 -1
  123. package/web/_astro/{mermaid.core.jAJTcMKc.js → mermaid.core.DSqeFu2Y.js} +4 -4
  124. package/web/_astro/{mindmap-definition-RKZ34NQL.BffJxfCr.js → mindmap-definition-RKZ34NQL.1XcF8Wh2.js} +1 -1
  125. package/web/_astro/{pieDiagram-4H26LBE5.CacwWaE3.js → pieDiagram-4H26LBE5.DnItHOdK.js} +1 -1
  126. package/web/_astro/{quadrantDiagram-W4KKPZXB.hXELD06-.js → quadrantDiagram-W4KKPZXB.BQQyrnBt.js} +1 -1
  127. package/web/_astro/{requirementDiagram-4Y6WPE33.DdEbPcqT.js → requirementDiagram-4Y6WPE33.Dmzxun82.js} +1 -1
  128. package/web/_astro/{sankeyDiagram-5OEKKPKP.DgaDocKv.js → sankeyDiagram-5OEKKPKP.BBIeRKYB.js} +1 -1
  129. package/web/_astro/{sequenceDiagram-3UESZ5HK.B0KScnAu.js → sequenceDiagram-3UESZ5HK.CUNVdBBf.js} +1 -1
  130. package/web/_astro/{stateDiagram-AJRCARHV.CV9M_WNc.js → stateDiagram-AJRCARHV.DlFf2sJm.js} +1 -1
  131. package/web/_astro/{stateDiagram-v2-BHNVJYJU.BOAo74Et.js → stateDiagram-v2-BHNVJYJU.DXR7I1H5.js} +1 -1
  132. package/web/_astro/{timeline-definition-PNZ67QCA.CqqepUMe.js → timeline-definition-PNZ67QCA.BcO-GyX2.js} +1 -1
  133. package/web/_astro/{vennDiagram-CIIHVFJN.DDqe4dOG.js → vennDiagram-CIIHVFJN.ChCECmmW.js} +1 -1
  134. package/web/_astro/{wardleyDiagram-YWT4CUSO.B3R9OPdO.js → wardleyDiagram-YWT4CUSO.CRaT12mD.js} +1 -1
  135. package/web/_astro/{xychartDiagram-2RQKCTM6.B3SvgXpQ.js → xychartDiagram-2RQKCTM6.B3lsHVpj.js} +1 -1
  136. package/web/index.html +2 -2
  137. package/plugins/sp/lib/artifact-digest.generated.d.mts +0 -7
  138. package/plugins/sp/lib/artifact-digest.generated.mjs +0 -48
  139. package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -301
  140. package/plugins/sp/scripts/feature-sync-bounded.ts +0 -481
  141. package/plugins/sp/scripts/idea-coverage-check.ts +0 -168
  142. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -298
  143. package/plugins/sp/scripts/record-feature-sync.mjs +0 -63
  144. package/plugins/sp/scripts/record-feature-sync.ts +0 -84
  145. package/plugins/sp/scripts/script-contract-check.ts +0 -506
  146. package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
  147. package/plugins/sp/scripts/surface-drift-inventory.ts +0 -989
  148. package/plugins/sp/scripts/task-evidence-precheck.ts +0 -189
  149. package/plugins/sp/scripts/task-size-precheck.ts +0 -212
  150. package/plugins/sp/scripts/transition-shim-check.ts +0 -238
  151. package/plugins/sp/scripts/validate-commands.ts +0 -689
  152. package/plugins/sp/scripts/validate-flag-contracts.ts +0 -890
  153. package/plugins/sp/scripts/verify-answer-lint.ts +0 -549
  154. package/web/_astro/channel.CI6N_tCg.js +0 -1
  155. package/web/_astro/index.CENnIEqT.css +0 -1
@@ -1,481 +0,0 @@
1
- /**
2
- * feature-sync-bounded — bounded retry-suppression wrapper for `feature sync` (task 0411).
3
- *
4
- * During a batch (`/sp:dev-runall`) or wrap-up (`/sp:dev-wrapall`), the per-task `record` step
5
- * and the wrap-up `feature-transition` step each invoke `spur feature sync <id> --json`. If the
6
- * feature is L4-gate-blocked, the identical blocked result repeats on every call with no
7
- * intervening input change — 4 redundant sync calls observed in the H9 dogfood.
8
- *
9
- * This wrapper classifies the structured `feature sync --json` result, persists a blocked-state
10
- * record, and suppresses a duplicate blocked attempt **until a relevant input changes**
11
- * (feature file content, linked-task statuses, or verdict artifact mtimes). Applied and no-op
12
- * results pass through unchanged (R5). No mandatory `--dry-run` is added (R4).
13
- *
14
- * Pure functions (`classifySyncResult`, `computeFingerprint`, `shouldSuppressBlocked`,
15
- * `decideBoundedSync`, `processSyncResult`, `serializeBlockedState`, `parseBlockedState`,
16
- * `parseBoundedSyncCliArgs`) are exported for unit testing; `runBoundedCli` does the I/O.
17
- *
18
- * Mirrors the batch-preflight.ts pattern (task 0279): pure logic + thin CLI entry, local types
19
- * to avoid importing service packages into the plugin tree.
20
- */
21
-
22
- import { spawnSync } from 'node:child_process';
23
- import { createHash } from 'node:crypto';
24
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
25
- import { dirname } from 'node:path';
26
- import { fileURLToPath } from 'node:url';
27
- import { getEnvVar } from '../lib/env';
28
-
29
- // ── Local types (match packages/app FeatureService shapes; no package import) ───────────
30
-
31
- export interface FeatureSyncProposal {
32
- featureId: string;
33
- from: string;
34
- to: string;
35
- reason: string;
36
- requiresConfirm?: boolean;
37
- gateBlocked?: boolean;
38
- gateFindings?: unknown[];
39
- hops?: string[];
40
- }
41
-
42
- export interface FeatureSyncResult {
43
- proposal: FeatureSyncProposal;
44
- applied: boolean;
45
- appliedHops: string[];
46
- }
47
-
48
- export type SyncClassification = 'applied' | 'no-op' | 'blocked';
49
-
50
- // ── Pure classification logic ────────────────────────────────────────────────────────────
51
-
52
- /**
53
- * Classify a structured feature-sync result.
54
- *
55
- * `gateBlocked` is checked FIRST: a gate-blocked proposal with a partial hop can report
56
- * `applied: true` (e.g. backlog→active applied, but the engine stopped before the gate-blocked
57
- * done transition). That must still classify as 'blocked' so the suppressed record is written
58
- * and identical retries are avoided.
59
- *
60
- * After that: `applied === true` → 'applied'; `from !== to` (and not applied) → 'blocked'
61
- * (covers requiresConfirm-deferred hops); otherwise 'no-op' (from === to).
62
- */
63
- export function classifySyncResult(result: FeatureSyncResult): SyncClassification {
64
- if (result.proposal.gateBlocked === true) return 'blocked';
65
- if (result.applied === true) return 'applied';
66
- if (result.proposal.from !== result.proposal.to) return 'blocked';
67
- return 'no-op';
68
- }
69
-
70
- // ── Input fingerprint ────────────────────────────────────────────────────────────────────
71
-
72
- export interface FingerprintInput {
73
- /** Hash of the feature file content (e.g. sha256 of the markdown body from `feature show`). */
74
- featureContentHash: string;
75
- /** Stable vector of `<wbs>:<status>` for all linked tasks, WBS-sorted. */
76
- taskStatusVector: string[];
77
- /** Stable vector of `<wbs>:<mtime>` for verdict artifacts that exist, WBS-sorted. */
78
- verdictMtimeVector: string[];
79
- }
80
-
81
- /**
82
- * Deterministic SHA-256 fingerprint over the three input signals that can invalidate a blocked
83
- * suppression: the feature file itself, the linked task statuses, and the verdict artifact
84
- * mtimes. Sliced to 32 hex chars for a compact, collision-safe key.
85
- */
86
- export function computeFingerprint(input: FingerprintInput): string {
87
- // Sort the vectors so the fingerprint is order-insensitive: the CLI may return tasks or
88
- // verdict files in any order, and only the *set* of statuses/mtimes matters for detecting a
89
- // genuine input change.
90
- const material = [
91
- input.featureContentHash,
92
- ...[...input.taskStatusVector].sort(),
93
- ...[...input.verdictMtimeVector].sort(),
94
- ].join('\n');
95
- return createHash('sha256').update(material).digest('hex').slice(0, 32);
96
- }
97
-
98
- // ── Blocked-state record (persisted) ─────────────────────────────────────────────────────
99
-
100
- export interface BlockedState {
101
- featureId: string;
102
- inputFingerprint: string;
103
- proposal: FeatureSyncProposal;
104
- classification: SyncClassification;
105
- result: FeatureSyncResult;
106
- persistedAt: string;
107
- }
108
-
109
- export const blockedStateFile = (featureId: string, runDir: string): string =>
110
- `${runDir.replace(/\/$/, '')}/feature-sync-blocked-${featureId}.json`;
111
-
112
- export function serializeBlockedState(state: BlockedState): string {
113
- return `${JSON.stringify(state)}\n`;
114
- }
115
-
116
- export function parseBlockedState(raw: string): BlockedState | null {
117
- const trimmed = raw.trim();
118
- if (trimmed.length === 0) return null;
119
- try {
120
- const parsed = JSON.parse(trimmed) as BlockedState;
121
- if (
122
- typeof parsed.featureId !== 'string' ||
123
- typeof parsed.inputFingerprint !== 'string' ||
124
- typeof parsed.proposal !== 'object' ||
125
- parsed.proposal === null
126
- ) {
127
- return null;
128
- }
129
- return parsed;
130
- } catch {
131
- return null;
132
- }
133
- }
134
-
135
- // ── Suppression decision ────────────────────────────────────────────────────────────────
136
-
137
- /**
138
- * Given a persisted blocked state and the current input fingerprint, decide whether the new
139
- * attempt should be suppressed (identical inputs, same blocked proposal) or allowed (inputs
140
- * changed, or no prior blocked record).
141
- *
142
- * Returns `suppress: true` when the prior blocked result should be replayed without invoking
143
- * `feature sync` again.
144
- */
145
- export function shouldSuppressBlocked(
146
- prior: BlockedState | null,
147
- currentFingerprint: string,
148
- ): { suppress: boolean; replay?: FeatureSyncResult } {
149
- if (!prior) return { suppress: false };
150
- if (prior.inputFingerprint === currentFingerprint) {
151
- return { suppress: true, replay: prior.result };
152
- }
153
- return { suppress: false };
154
- }
155
-
156
- // ── Orchestration decision (pure) ────────────────────────────────────────────────────────
157
-
158
- export type BoundedSyncOutcome = { kind: 'invoke' } | { kind: 'suppress'; replay: FeatureSyncResult };
159
-
160
- /**
161
- * Decide whether to actually invoke `feature sync` given the persisted blocked state and the
162
- * current input fingerprint. This is the pure heart of the retry-suppression policy (R1–R3):
163
- *
164
- * - No prior blocked state → invoke (fresh attempt).
165
- * - Prior blocked state + identical fingerprint → suppress and replay the prior result.
166
- * - Prior blocked state + changed fingerprint → invoke (inputs changed; R3 allows a new attempt).
167
- */
168
- export function decideBoundedSync(prior: BlockedState | null, currentFingerprint: string): BoundedSyncOutcome {
169
- const decision = shouldSuppressBlocked(prior, currentFingerprint);
170
- if (decision.suppress && decision.replay) {
171
- return { kind: 'suppress', replay: decision.replay };
172
- }
173
- return { kind: 'invoke' };
174
- }
175
-
176
- // ── Result processing (pure) ─────────────────────────────────────────────────────────────
177
-
178
- export interface ProcessSyncResultOutcome {
179
- classification: SyncClassification;
180
- /** The result to emit to stdout (replayed for suppressed, live for invoked). */
181
- emit: FeatureSyncResult;
182
- /** When classification === 'blocked', the state to persist. */
183
- persist?: BlockedState;
184
- /** Human-readable one-line annotation for the run report (may be empty). */
185
- annotation: string;
186
- }
187
-
188
- /**
189
- * Process a live or replayed feature-sync result: classify it, and — if blocked — produce the
190
- * blocked-state record to persist. Applied/no-op results clear any annotation and never persist.
191
- */
192
- export function processSyncResult(
193
- result: FeatureSyncResult,
194
- currentFingerprint: string,
195
- persistedAt: string,
196
- wasSuppressed: boolean,
197
- ): ProcessSyncResultOutcome {
198
- const classification = classifySyncResult(result);
199
-
200
- if (classification === 'blocked') {
201
- return {
202
- classification,
203
- emit: result,
204
- persist: {
205
- featureId: result.proposal.featureId,
206
- inputFingerprint: currentFingerprint,
207
- proposal: result.proposal,
208
- classification,
209
- result,
210
- persistedAt,
211
- },
212
- annotation: wasSuppressed
213
- ? `feature-sync-bounded: suppressed duplicate blocked sync for ${result.proposal.featureId} (inputs unchanged)`
214
- : `feature-sync-bounded: blocked proposal for ${result.proposal.featureId} — ${result.proposal.reason}`,
215
- };
216
- }
217
-
218
- return {
219
- classification,
220
- emit: result,
221
- annotation: '',
222
- };
223
- }
224
-
225
- // ── CLI ──────────────────────────────────────────────────────────────────────────────────
226
-
227
- export interface BoundedSyncCliArgs {
228
- featureId: string;
229
- spurBin: string;
230
- runDir: string;
231
- json: boolean;
232
- help: boolean;
233
- }
234
-
235
- export const BOUNDED_SYNC_CLI_USAGE = `Usage:
236
- bun plugins/sp/scripts/feature-sync-bounded.ts <feature-id> \\
237
- --spur-bin <spur|bun apps/cli/src/index.ts> \\
238
- [--run-dir .spur/run] [--json]
239
-
240
- Wraps 'spur feature sync <id> --json' with bounded retry-suppression: an identical
241
- blocked proposal is reported once and suppressed until feature file content, linked
242
- task statuses, or verdict artifact mtimes change. Applied and no-op results pass
243
- through unchanged.
244
-
245
- Exit: 0 = sync handled (applied / no-op / suppressed-blocked / live-blocked).`;
246
-
247
- /**
248
- * Resolve the spur CLI command in a monorepo-safe way:
249
- * --spur-bin > SPUR_BIN > monorepo-local CLI entry > PATH `spur`.
250
- * The plugin's own CI always passes an explicit --spur-bin; this fallback chain
251
- * keeps ad-hoc invocations from silently hitting a stale PATH install.
252
- */
253
- export function defaultSpurBin(): string {
254
- const fromEnv = getEnvVar('SPUR_BIN');
255
- if (fromEnv) return fromEnv;
256
- // scripts/ -> plugins/sp/ -> <repo>/apps/cli/src/index.ts (fileURLToPath — raw pathname breaks
257
- // on %-encoded paths, e.g. spaces in the checkout directory)
258
- const local = fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url));
259
- if (existsSync(local)) return `bun ${local}`;
260
- return 'spur';
261
- }
262
-
263
- export function parseBoundedSyncCliArgs(argv: string[]): BoundedSyncCliArgs {
264
- let featureId = '';
265
- let spurBin = defaultSpurBin();
266
- let runDir = '.spur/run';
267
- let json = false;
268
- let help = false;
269
-
270
- for (let i = 0; i < argv.length; i++) {
271
- const a = argv[i];
272
- if (a === undefined) continue;
273
- if (a === '--help' || a === '-h') help = true;
274
- else if (a === '--json') json = true;
275
- else if (a === '--spur-bin') spurBin = argv[++i] ?? spurBin;
276
- else if (a === '--run-dir') runDir = argv[++i] ?? runDir;
277
- else if (!a.startsWith('--') && featureId === '') featureId = a;
278
- }
279
- return { featureId, spurBin, runDir, json, help };
280
- }
281
-
282
- type SpawnResult = { stdout: string; stderr: string; exitCode: number; ok: boolean };
283
- function runSpurJson(spurBin: string, args: string[]): SpawnResult {
284
- const binParts = spurBin.split(/\s+/).filter(Boolean);
285
- const cmd = binParts[0] ?? 'spur';
286
- const cmdArgs = [...binParts.slice(1), ...args];
287
- const r = spawnSync(cmd, cmdArgs, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
288
- const decode = (b: unknown): string =>
289
- typeof b === 'string' ? b : Buffer.from((b as Uint8Array) ?? []).toString('utf8');
290
- return {
291
- stdout: typeof r.stdout === 'string' ? r.stdout : decode(r.stdout),
292
- stderr: typeof r.stderr === 'string' ? r.stderr : decode(r.stderr),
293
- exitCode: r.status ?? (r.error ? 1 : 0),
294
- ok: (r.status ?? (r.error ? 1 : 0)) === 0,
295
- };
296
- }
297
-
298
- function readFeatureContentHash(spurBin: string, featureId: string): string | null {
299
- const r = runSpurJson(spurBin, ['feature', 'show', featureId, '--json']);
300
- if (!r.ok) return null;
301
- try {
302
- const parsed = JSON.parse(r.stdout) as { content?: string };
303
- if (typeof parsed.content !== 'string') return null;
304
- return createHash('sha256').update(parsed.content).digest('hex');
305
- } catch {
306
- return null;
307
- }
308
- }
309
-
310
- function readTaskStatusVector(spurBin: string, featureId: string): string[] | null {
311
- const r = runSpurJson(spurBin, ['task', 'list', '--feature', featureId, '--json']);
312
- if (!r.ok) return null;
313
- try {
314
- const parsed = JSON.parse(r.stdout) as Array<{ wbs?: string; status?: string }>;
315
- return parsed
316
- .filter((t) => typeof t.wbs === 'string' && typeof t.status === 'string')
317
- .map((t) => `${t.wbs}:${t.status}`)
318
- .sort();
319
- } catch {
320
- return null;
321
- }
322
- }
323
-
324
- function readVerdictMtimeVector(runDir: string): string[] {
325
- // Verdict artifacts live in <runDir>/<wbs>-verdict.json. Missing files contribute nothing
326
- // (a task with no verdict yet is a stable "absent" signal captured by its absence).
327
- //
328
- // Read via node:fs rather than `ls` + `stat` subprocesses: BSD `stat -f %m` (macOS) and GNU
329
- // `stat -c %Y` (Linux) disagree, so the shell form silently yielded an empty mtime vector on
330
- // Linux servers — dropping the verdict signal from the fingerprint and making suppression
331
- // sticky across verdict changes (R3). statSync is portable and avoids a spawn per file.
332
- const dir = runDir.replace(/\/$/, '');
333
- let entries: string[];
334
- try {
335
- entries = readdirSync(dir).filter((f) => f.endsWith('-verdict.json'));
336
- } catch {
337
- return [];
338
- }
339
-
340
- const vector: string[] = [];
341
- for (const entry of entries) {
342
- try {
343
- const mtime = statSync(`${dir}/${entry}`).mtimeMs;
344
- vector.push(`${entry.replace('-verdict.json', '')}:${mtime}`);
345
- } catch {
346
- // Removed between readdir and stat — treat as absent.
347
- }
348
- }
349
- return vector.sort();
350
- }
351
-
352
- function writeBlockedState(state: BlockedState, path: string): void {
353
- try {
354
- // Create the run dir if absent: without it the write throws and suppression silently
355
- // degrades to "invoke every time" — the exact defect this wrapper exists to fix.
356
- mkdirSync(dirname(path), { recursive: true });
357
- writeFileSync(path, serializeBlockedState(state));
358
- } catch (err) {
359
- // Persistence stays best-effort (the live sync result is still correct), but a failure
360
- // must be visible: a silent degrade is indistinguishable from the bug.
361
- const reason = err instanceof Error ? err.message : String(err);
362
- process.stderr.write(`feature-sync-bounded: could not persist blocked state to ${path} — ${reason}\n`);
363
- }
364
- }
365
-
366
- function readBlockedState(path: string): BlockedState | null {
367
- try {
368
- if (!existsSync(path)) return null;
369
- const text = readFileSync(path, 'utf8');
370
- return parseBlockedState(text);
371
- } catch {
372
- return null;
373
- }
374
- }
375
-
376
- /**
377
- * Run the bounded-sync CLI. Does all I/O (subprocess calls, file read/write) but delegates
378
- * every decision to the pure functions above. Falls back to a direct `feature sync` invocation
379
- * if any pre-check call fails (R4: no mandatory dry-run, and pre-check failure must not block
380
- * the real sync).
381
- */
382
- export function runBoundedCli(argv: string[]): { exitCode: number; stdout: string; stderr: string } {
383
- const args = parseBoundedSyncCliArgs(argv);
384
- if (args.help) return { exitCode: 0, stdout: '', stderr: BOUNDED_SYNC_CLI_USAGE };
385
- if (!args.featureId) return { exitCode: 1, stdout: '', stderr: BOUNDED_SYNC_CLI_USAGE };
386
-
387
- const statePath = blockedStateFile(args.featureId, args.runDir);
388
- const prior = readBlockedState(statePath);
389
-
390
- // Pre-check input fingerprint. If any signal is unreadable, fall back to a direct live sync
391
- // (correctness over suppression).
392
- const featureContentHash = readFeatureContentHash(args.spurBin, args.featureId);
393
- const taskStatusVector = readTaskStatusVector(args.spurBin, args.featureId);
394
- if (featureContentHash === null || taskStatusVector === null) {
395
- return invokeLiveSync(args, statePath);
396
- }
397
- const verdictMtimeVector = readVerdictMtimeVector(args.runDir);
398
-
399
- const currentFingerprint = computeFingerprint({
400
- featureContentHash,
401
- taskStatusVector,
402
- verdictMtimeVector,
403
- });
404
-
405
- const decision = decideBoundedSync(prior, currentFingerprint);
406
-
407
- if (decision.kind === 'suppress') {
408
- const processed = processSyncResult(decision.replay, currentFingerprint, new Date().toISOString(), true);
409
- emitResult(processed.emit, processed.annotation, args.json);
410
- return { exitCode: 0, stdout: '', stderr: '' };
411
- }
412
-
413
- return invokeLiveSync(args, statePath, currentFingerprint);
414
- }
415
-
416
- function invokeLiveSync(
417
- args: BoundedSyncCliArgs,
418
- statePath: string,
419
- fingerprint?: string,
420
- ): { exitCode: number; stdout: string; stderr: string } {
421
- const r = runSpurJson(args.spurBin, ['feature', 'sync', args.featureId, '--json']);
422
- if (!r.ok) {
423
- // The underlying sync failed (non-zero exit). Surface its stderr verbatim; do not
424
- // fabricate a blocked classification from an error.
425
- return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr };
426
- }
427
-
428
- let result: FeatureSyncResult;
429
- try {
430
- result = JSON.parse(r.stdout) as FeatureSyncResult;
431
- } catch {
432
- // Unparseable sync output — surface it raw rather than guessing.
433
- return { exitCode: r.exitCode, stdout: r.stdout, stderr: '' };
434
- }
435
- // Shape guard (review finding 1): a parseable envelope that is not a FeatureSyncResult
436
- // (missing `proposal`) must fail loudly with a message, not a TypeError in
437
- // classifySyncResult. Mirrors the validation the read side already performs.
438
- if (!result || typeof result !== 'object' || !result.proposal) {
439
- return {
440
- exitCode: r.exitCode,
441
- stdout: r.stdout,
442
- stderr: 'unrecognized feature sync envelope: missing proposal',
443
- };
444
- }
445
-
446
- // If we have no fingerprint (pre-check fallback path), recompute minimal signals so we can
447
- // still persist a blocked record. Missing signals yield an empty-string fingerprint, which
448
- // still suppresses identical retries within the same batch (the inputs haven't changed from
449
- // our view either). A real input change the next time will still invalidate because the
450
- // pre-check will succeed and produce a real fingerprint.
451
- const currentFingerprint =
452
- fingerprint ??
453
- computeFingerprint({
454
- featureContentHash: readFeatureContentHash(args.spurBin, args.featureId) ?? '',
455
- taskStatusVector: readTaskStatusVector(args.spurBin, args.featureId) ?? [],
456
- verdictMtimeVector: readVerdictMtimeVector(args.runDir),
457
- });
458
-
459
- const processed = processSyncResult(result, currentFingerprint, new Date().toISOString(), false);
460
- if (processed.persist) writeBlockedState(processed.persist, statePath);
461
- emitResult(processed.emit, processed.annotation, args.json);
462
- return { exitCode: 0, stdout: '', stderr: '' };
463
- }
464
-
465
- function emitResult(result: FeatureSyncResult, annotation: string, json: boolean): void {
466
- if (json) {
467
- process.stdout.write(`${JSON.stringify(result)}\n`);
468
- } else {
469
- process.stdout.write(
470
- `${result.proposal.featureId}: ${result.proposal.from} → ${result.proposal.to} (applied=${result.applied})\n`,
471
- );
472
- }
473
- if (annotation.length > 0) process.stderr.write(`${annotation}\n`);
474
- }
475
-
476
- if (import.meta.main) {
477
- const { exitCode, stdout, stderr } = runBoundedCli(process.argv.slice(2));
478
- if (stdout) process.stdout.write(stdout);
479
- if (stderr) process.stderr.write(`${stderr}\n`);
480
- process.exit(exitCode);
481
- }
@@ -1,168 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * idea-coverage-check — requirement-inventory ↔ AC coverage gate for the idea pipeline
4
- * (task 0887 R4).
5
- *
6
- * Cross-checks the `## Requirement inventory` section of the idea-evaluation report
7
- * (R3) against the `# covers: I<n>, ...` comment lines of the generated acceptance
8
- * criteria (R4): every inventory item that is not explicitly `[deferred: ...]` must be
9
- * covered by at least one scenario. An `[unclear: ...]` marker does not exempt an item —
10
- * it still demands coverage or an explicit deferral.
11
- *
12
- * Writes PASS/FAIL to `.spur/run/<run-id>-idea-coverage.status` and always exits 0
13
- * (soft action, task 0769 pattern): the ac-generate guards in idea-pipeline.yaml read
14
- * the status file, so a missing or failing checker fails closed through the guard, not
15
- * through the exit code.
16
- *
17
- * Ships with the plugin to arbitrary projects, so it stays node-builtin-only —
18
- * no workspace imports.
19
- *
20
- * Usage:
21
- * bun plugins/sp/scripts/idea-coverage-check.ts --run-id <id> --report <path>
22
- * --ac <path> [--out <path>]
23
- */
24
-
25
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
- import { dirname, join } from 'node:path';
27
-
28
- interface ParsedArgs {
29
- runId: string;
30
- reportPath: string;
31
- acPath: string;
32
- outPath: string;
33
- }
34
-
35
- /** `- **I3** — ask text` / `- I3. ask text` — the R3 inventory item form. */
36
- const INVENTORY_ITEM_RE = /^\s*[-*]\s+\**I(\d+)\**\s*[.:—-]?\s+(.*)$/;
37
-
38
- /** `# covers: I1, I3` — the R4 scenario coverage comment. */
39
- const COVERS_RE = /^\s*#\s*covers:\s*(.+)$/i;
40
-
41
- /** Scenario header — the anchor a covers-comment attaches to. */
42
- const SCENARIO_RE = /^\s*Scenario(?:\s+Outline)?:/;
43
-
44
- function parseArgs(argv: string[]): ParsedArgs {
45
- let runId = '';
46
- let reportPath = '';
47
- let acPath = '';
48
- let outPath = '';
49
- for (let i = 0; i < argv.length; i++) {
50
- const value = argv[i + 1];
51
- switch (argv[i]) {
52
- case '--run-id':
53
- runId = value ?? '';
54
- i++;
55
- break;
56
- case '--report':
57
- reportPath = value ?? '';
58
- i++;
59
- break;
60
- case '--ac':
61
- acPath = value ?? '';
62
- i++;
63
- break;
64
- case '--out':
65
- outPath = value ?? '';
66
- i++;
67
- break;
68
- }
69
- }
70
- if (runId === '' || reportPath === '' || acPath === '') {
71
- process.stderr.write(
72
- 'usage: idea-coverage-check.ts --run-id <id> --report <path> --ac <path> [--out <path>]\n',
73
- );
74
- process.exit(2);
75
- }
76
- return {
77
- runId,
78
- reportPath,
79
- acPath,
80
- outPath: outPath !== '' ? outPath : join('.spur', 'run', `${runId}-idea-coverage.status`),
81
- };
82
- }
83
-
84
- /**
85
- * Inventory ids from the report's `## Requirement inventory` section, split into
86
- * covered-owing (no `[deferred:` marker) and exempt (deferred) ids. An `[unclear:`
87
- * marker is informational — the item still owes coverage.
88
- */
89
- function parseInventory(report: string): { owing: Set<string>; deferred: Set<string>; hasSection: boolean } {
90
- const lines = report.split('\n');
91
- const start = lines.findIndex((line) => /^#{1,6}\s*Requirement inventory\s*$/i.test(line));
92
- if (start === -1) return { owing: new Set(), deferred: new Set(), hasSection: false };
93
- const owing = new Set<string>();
94
- const deferred = new Set<string>();
95
- for (let i = start + 1; i < lines.length; i++) {
96
- if (/^#{1,6}\s/.test(lines[i] ?? '')) break;
97
- const match = INVENTORY_ITEM_RE.exec(lines[i] ?? '');
98
- if (match === null) continue;
99
- const id = `I${match[1]}`;
100
- if (/\[\s*deferred\s*:/i.test(match[2] ?? '')) deferred.add(id);
101
- else owing.add(id);
102
- }
103
- return { owing, deferred, hasSection: true };
104
- }
105
-
106
- /** Coverage map from the AC content: scenario-count per `I<n>` id. */
107
- function parseCoverage(ac: string): Map<string, number> {
108
- const covered = new Map<string, number>();
109
- let inScenario = false;
110
- for (const line of ac.split('\n')) {
111
- if (SCENARIO_RE.test(line)) {
112
- inScenario = true;
113
- continue;
114
- }
115
- if (!inScenario) continue;
116
- const match = COVERS_RE.exec(line);
117
- if (match === null) continue;
118
- for (const raw of (match[1] ?? '').split(',')) {
119
- const id = raw.trim().toUpperCase();
120
- if (/^I\d+$/.test(id)) covered.set(id, (covered.get(id) ?? 0) + 1);
121
- }
122
- }
123
- return covered;
124
- }
125
-
126
- function writeStatus(outPath: string, verdict: 'PASS' | 'FAIL', runId: string, detail: string): void {
127
- mkdirSync(dirname(outPath), { recursive: true });
128
- writeFileSync(outPath, `${verdict}\n`);
129
- // Sibling reason file: the feature-check HITL prompt surfaces WHY next to the bare
130
- // status letter, without guards having to parse a multi-line status file.
131
- writeFileSync(`${outPath}.reason`, `${verdict} run=${runId} ${detail}\n`);
132
- }
133
-
134
- function main(): void {
135
- const args = parseArgs(process.argv.slice(2));
136
-
137
- if (!existsSync(args.reportPath) || !existsSync(args.acPath)) {
138
- const detail = `missing input (report=${existsSync(args.reportPath) ? 'ok' : 'absent'}, ac=${existsSync(args.acPath) ? 'ok' : 'absent'})`;
139
- writeStatus(args.outPath, 'FAIL', args.runId, detail);
140
- process.stdout.write(`idea-coverage-check FAIL run=${args.runId} ${detail}\n`);
141
- return;
142
- }
143
-
144
- const inventory = parseInventory(readFileSync(args.reportPath, 'utf8'));
145
- const covered = parseCoverage(readFileSync(args.acPath, 'utf8'));
146
-
147
- if (!inventory.hasSection || inventory.owing.size + inventory.deferred.size === 0) {
148
- const detail = `no Requirement inventory items in ${args.reportPath}`;
149
- writeStatus(args.outPath, 'FAIL', args.runId, detail);
150
- process.stdout.write(`idea-coverage-check FAIL run=${args.runId} ${detail}\n`);
151
- return;
152
- }
153
-
154
- const uncovered = [...inventory.owing].filter((id) => (covered.get(id) ?? 0) === 0).sort();
155
- const shape = `inventory=${inventory.owing.size + inventory.deferred.size} (deferred=${inventory.deferred.size})`;
156
- if (uncovered.length > 0) {
157
- const detail = `${shape} covered=${inventory.owing.size - uncovered.length} uncovered=${uncovered.join(',')}`;
158
- writeStatus(args.outPath, 'FAIL', args.runId, detail);
159
- process.stdout.write(`idea-coverage-check FAIL run=${args.runId} ${detail}\n`);
160
- return;
161
- }
162
-
163
- const detail = `${shape} all covered`;
164
- writeStatus(args.outPath, 'PASS', args.runId, detail);
165
- process.stdout.write(`idea-coverage-check PASS run=${args.runId} ${detail}\n`);
166
- }
167
-
168
- main();