@gobing-ai/spur 0.3.40 → 0.3.42
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.
- package/.claude-plugin/marketplace.json +15 -0
- package/README.md +15 -1
- package/config/templates/AGENTS.md +5 -3
- package/config/workflows/task-pipeline.yaml +103 -12
- package/package.json +15 -12
- package/plugins/README.md +655 -0
- package/plugins/sp/README.md +621 -0
- package/plugins/sp/agents/expert-spur.md +109 -0
- package/plugins/sp/agents/super-coder.md +126 -0
- package/plugins/sp/agents/super-planner.md +283 -0
- package/plugins/sp/agents/super-reviewer.md +154 -0
- package/plugins/sp/commands/dev-arch.md +30 -0
- package/plugins/sp/commands/dev-brainstorm.md +40 -0
- package/plugins/sp/commands/dev-changelog.md +28 -0
- package/plugins/sp/commands/dev-daily.md +29 -0
- package/plugins/sp/commands/dev-debug.md +29 -0
- package/plugins/sp/commands/dev-dogfood.md +33 -0
- package/plugins/sp/commands/dev-featurechange.md +130 -0
- package/plugins/sp/commands/dev-find-conflict.md +51 -0
- package/plugins/sp/commands/dev-find-issue.md +76 -0
- package/plugins/sp/commands/dev-find-next.md +60 -0
- package/plugins/sp/commands/dev-fixall.md +33 -0
- package/plugins/sp/commands/dev-gitmsg.md +28 -0
- package/plugins/sp/commands/dev-gtd.md +97 -0
- package/plugins/sp/commands/dev-handover.md +26 -0
- package/plugins/sp/commands/dev-idea.md +50 -0
- package/plugins/sp/commands/dev-next.md +31 -0
- package/plugins/sp/commands/dev-parallel.md +30 -0
- package/plugins/sp/commands/dev-plan.md +46 -0
- package/plugins/sp/commands/dev-refine.md +48 -0
- package/plugins/sp/commands/dev-refineall.md +65 -0
- package/plugins/sp/commands/dev-refresh.md +32 -0
- package/plugins/sp/commands/dev-reverse.md +32 -0
- package/plugins/sp/commands/dev-review.md +31 -0
- package/plugins/sp/commands/dev-run.md +63 -0
- package/plugins/sp/commands/dev-runall.md +80 -0
- package/plugins/sp/commands/dev-simplify.md +39 -0
- package/plugins/sp/commands/dev-unit.md +29 -0
- package/plugins/sp/commands/dev-verify.md +48 -0
- package/plugins/sp/commands/dev-verifyall.md +72 -0
- package/plugins/sp/commands/dev-wrap.md +30 -0
- package/plugins/sp/commands/dev-wrapall.md +33 -0
- package/plugins/sp/commands/rule-add.md +18 -0
- package/plugins/sp/commands/rule-refine.md +18 -0
- package/plugins/sp/commands/rule-scan.md +18 -0
- package/plugins/sp/commands/spur-init.md +22 -0
- package/plugins/sp/commands/workflow-add.md +18 -0
- package/plugins/sp/commands/workflow-refine.md +18 -0
- package/plugins/sp/hooks/agent-hint.ts +60 -0
- package/plugins/sp/hooks/careful-guard.ts +147 -0
- package/plugins/sp/hooks/context-post-tool.ts +325 -0
- package/plugins/sp/hooks/context-session-start.ts +182 -0
- package/plugins/sp/hooks/context-session-stop.ts +95 -0
- package/plugins/sp/hooks/hooks.json +51 -0
- package/plugins/sp/hooks/pi/guard-extension.ts +341 -0
- package/plugins/sp/hooks/task-write-guard.ts +84 -0
- package/plugins/sp/plugin.json +8 -0
- package/plugins/sp/scripts/batch-preflight.ts +204 -0
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +606 -0
- package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +363 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +168 -0
- package/plugins/sp/scripts/feature-sync-bounded.ts +451 -0
- package/plugins/sp/scripts/stage-registry-adapter.ts +1341 -0
- package/plugins/sp/scripts/task-size-precheck.ts +202 -0
- package/plugins/sp/scripts/validate-commands.ts +651 -0
- package/plugins/sp/scripts/validate-flag-contracts.ts +875 -0
- package/plugins/sp/skills/brainstorm/SKILL.md +361 -0
- package/plugins/sp/skills/brainstorm/agents/openai.yaml +14 -0
- package/plugins/sp/skills/brainstorm/examples/ideation-example.md +209 -0
- package/plugins/sp/skills/brainstorm/metadata.openclaw +7 -0
- package/plugins/sp/skills/brainstorm/references/grilling-interview.md +68 -0
- package/plugins/sp/skills/brainstorm/references/workflows.md +255 -0
- package/plugins/sp/skills/branch-workflow/SKILL.md +109 -0
- package/plugins/sp/skills/branch-workflow/references/branch-lifecycle.md +69 -0
- package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +100 -0
- package/plugins/sp/skills/code-implementation/SKILL.md +176 -0
- package/plugins/sp/skills/code-implementation/references/debugging.md +64 -0
- package/plugins/sp/skills/code-implementation/references/implementation-patterns.md +113 -0
- package/plugins/sp/skills/code-improvement/SKILL.md +231 -0
- package/plugins/sp/skills/code-improvement/references/deepening-signals.md +205 -0
- package/plugins/sp/skills/code-review/SKILL.md +112 -0
- package/plugins/sp/skills/code-review/references/receiving-code-review.md +70 -0
- package/plugins/sp/skills/code-review/references/review-lenses.md +117 -0
- package/plugins/sp/skills/code-review/references/self-review-checklist.md +53 -0
- package/plugins/sp/skills/code-review/references/verification-before-completion.md +68 -0
- package/plugins/sp/skills/code-simplification/SKILL.md +225 -0
- package/plugins/sp/skills/code-testing/SKILL.md +108 -0
- package/plugins/sp/skills/code-testing/references/stacks/bun-ts.md +165 -0
- package/plugins/sp/skills/code-testing/references/stacks/go.md +88 -0
- package/plugins/sp/skills/code-testing/references/stacks/python.md +117 -0
- package/plugins/sp/skills/code-testing/references/test-loop-breaker.md +37 -0
- package/plugins/sp/skills/code-testing/references/test-output-discipline.md +52 -0
- package/plugins/sp/skills/code-testing/references/unit-testing.md +212 -0
- package/plugins/sp/skills/code-verification/SKILL.md +535 -0
- package/plugins/sp/skills/code-verification/references/code-improvement.md +39 -0
- package/plugins/sp/skills/code-verification/references/secu-review.md +94 -0
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +141 -0
- package/plugins/sp/skills/conflict-finding/SKILL.md +254 -0
- package/plugins/sp/skills/conflict-finding/references/authority-resolution.md +258 -0
- package/plugins/sp/skills/conflict-finding/references/comparison-protocol.md +277 -0
- package/plugins/sp/skills/conflict-finding/references/finding-contract.md +346 -0
- package/plugins/sp/skills/conflict-finding/references/remediation-routing.md +276 -0
- package/plugins/sp/skills/daily-summary/SKILL.md +171 -0
- package/plugins/sp/skills/daily-summary/agents/openai.yaml +13 -0
- package/plugins/sp/skills/doc-evolve/SKILL.md +175 -0
- package/plugins/sp/skills/doc-evolve/references/operations.md +93 -0
- package/plugins/sp/skills/dogfood-testing/SKILL.md +608 -0
- package/plugins/sp/skills/dogfood-testing/references/monitor-ledger.md +211 -0
- package/plugins/sp/skills/dogfood-testing/references/report-template.md +351 -0
- package/plugins/sp/skills/doubt-driven-development/SKILL.md +136 -0
- package/plugins/sp/skills/functional-review/SKILL.md +364 -0
- package/plugins/sp/skills/functional-review/references/verdict-schema.md +133 -0
- package/plugins/sp/skills/indexed-context/SKILL.md +164 -0
- package/plugins/sp/skills/issue-finding/SKILL.md +424 -0
- package/plugins/sp/skills/issue-finding/agents/openai.yaml +15 -0
- package/plugins/sp/skills/issue-finding/examples/expected-findings.json +27 -0
- package/plugins/sp/skills/issue-finding/examples/session-test-loop.jsonl +23 -0
- package/plugins/sp/skills/issue-finding/references/session-formats.md +121 -0
- package/plugins/sp/skills/next-feature/SKILL.md +125 -0
- package/plugins/sp/skills/next-feature/references/handoff-routing.md +92 -0
- package/plugins/sp/skills/next-feature/references/proposal-contract.md +49 -0
- package/plugins/sp/skills/next-feature/references/ranking-rubric.md +52 -0
- package/plugins/sp/skills/next-feature/references/signal-derivation.md +67 -0
- package/plugins/sp/skills/next-router/SKILL.md +181 -0
- package/plugins/sp/skills/next-router/references/messages.md +90 -0
- package/plugins/sp/skills/next-router/references/routing-table.md +216 -0
- package/plugins/sp/skills/parallel-execution/SKILL.md +148 -0
- package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +100 -0
- package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +101 -0
- package/plugins/sp/skills/parallel-execution/references/result-synthesis.md +94 -0
- package/plugins/sp/skills/reverse-engineering/SKILL.md +375 -0
- package/plugins/sp/skills/reverse-engineering/agents/openai.yaml +18 -0
- package/plugins/sp/skills/source-driven-development/SKILL.md +124 -0
- package/plugins/sp/skills/spec-decomposition/SKILL.md +117 -0
- package/plugins/sp/skills/spec-decomposition/references/decomposition.md +539 -0
- package/plugins/sp/skills/spur-cli/SKILL.md +133 -0
- package/plugins/sp/skills/spur-cli/references/agent.md +179 -0
- package/plugins/sp/skills/spur-cli/references/features/acceptance-criteria.md +82 -0
- package/plugins/sp/skills/spur-cli/references/features/hierarchy-mece.md +177 -0
- package/plugins/sp/skills/spur-cli/references/features/roadmap-priority.md +84 -0
- package/plugins/sp/skills/spur-cli/references/features/verbs.md +122 -0
- package/plugins/sp/skills/spur-cli/references/features.md +234 -0
- package/plugins/sp/skills/spur-cli/references/init.md +119 -0
- package/plugins/sp/skills/spur-cli/references/message.md +98 -0
- package/plugins/sp/skills/spur-cli/references/rules/authoring-rules.md +212 -0
- package/plugins/sp/skills/spur-cli/references/rules/fine-tuning.md +148 -0
- package/plugins/sp/skills/spur-cli/references/rules/operations.md +228 -0
- package/plugins/sp/skills/spur-cli/references/rules/validation-and-extension.md +129 -0
- package/plugins/sp/skills/spur-cli/references/rules.md +209 -0
- package/plugins/sp/skills/spur-cli/references/serve.md +58 -0
- package/plugins/sp/skills/spur-cli/references/tasks/l3-guard-cheatsheet.md +98 -0
- package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +95 -0
- package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +361 -0
- package/plugins/sp/skills/spur-cli/references/tasks.md +278 -0
- package/plugins/sp/skills/spur-cli/references/team.md +133 -0
- package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +226 -0
- package/plugins/sp/skills/spur-cli/references/workflows/operations.md +211 -0
- package/plugins/sp/skills/spur-cli/references/workflows/validation-and-extension.md +129 -0
- package/plugins/sp/skills/spur-cli/references/workflows.md +355 -0
- package/plugins/sp/skills/spur-dev/SKILL.md +224 -0
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +196 -0
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +664 -0
- package/plugins/sp/skills/spur-dev/references/decision-brief.md +80 -0
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +546 -0
- package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +164 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +727 -0
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +350 -0
- package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +191 -0
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +417 -0
- package/plugins/sp/skills/spur-dev/references/gate-checklists.md +171 -0
- package/plugins/sp/skills/spur-dev/references/glossary.md +95 -0
- package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +80 -0
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +88 -0
- package/plugins/sp/skills/spur-dev/references/planning-workflow.md +302 -0
- package/plugins/sp/skills/spur-dev/references/product-planning.md +206 -0
- package/plugins/sp/skills/spur-dev/references/section-batching.md +24 -0
- package/plugins/sp/skills/sys-architecture/SKILL.md +155 -0
- package/plugins/sp/skills/sys-architecture/references/decision-method.md +137 -0
- package/plugins/sp/skills/sys-architecture/references/upkeep-survey.md +75 -0
- package/plugins/sp/skills/sys-debugging/SKILL.md +228 -0
- package/plugins/sp/skills/sys-debugging/references/debugging-protocol.md +62 -0
- package/plugins/sp/skills/test-driven-development/SKILL.md +248 -0
- package/plugins/sp/skills/wayfinder/SKILL.md +292 -0
- package/spur.js +6093 -5180
|
@@ -0,0 +1,451 @@
|
|
|
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 { createHash } from 'node:crypto';
|
|
23
|
+
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { dirname } from 'node:path';
|
|
25
|
+
|
|
26
|
+
// ── Local types (match packages/app FeatureService shapes; no package import) ───────────
|
|
27
|
+
|
|
28
|
+
export interface FeatureSyncProposal {
|
|
29
|
+
featureId: string;
|
|
30
|
+
from: string;
|
|
31
|
+
to: string;
|
|
32
|
+
reason: string;
|
|
33
|
+
requiresConfirm?: boolean;
|
|
34
|
+
gateBlocked?: boolean;
|
|
35
|
+
gateFindings?: unknown[];
|
|
36
|
+
hops?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FeatureSyncResult {
|
|
40
|
+
proposal: FeatureSyncProposal;
|
|
41
|
+
applied: boolean;
|
|
42
|
+
appliedHops: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type SyncClassification = 'applied' | 'no-op' | 'blocked';
|
|
46
|
+
|
|
47
|
+
// ── Pure classification logic ────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Classify a structured feature-sync result.
|
|
51
|
+
*
|
|
52
|
+
* `gateBlocked` is checked FIRST: a gate-blocked proposal with a partial hop can report
|
|
53
|
+
* `applied: true` (e.g. backlog→active applied, but the engine stopped before the gate-blocked
|
|
54
|
+
* done transition). That must still classify as 'blocked' so the suppressed record is written
|
|
55
|
+
* and identical retries are avoided.
|
|
56
|
+
*
|
|
57
|
+
* After that: `applied === true` → 'applied'; `from !== to` (and not applied) → 'blocked'
|
|
58
|
+
* (covers requiresConfirm-deferred hops); otherwise 'no-op' (from === to).
|
|
59
|
+
*/
|
|
60
|
+
export function classifySyncResult(result: FeatureSyncResult): SyncClassification {
|
|
61
|
+
if (result.proposal.gateBlocked === true) return 'blocked';
|
|
62
|
+
if (result.applied === true) return 'applied';
|
|
63
|
+
if (result.proposal.from !== result.proposal.to) return 'blocked';
|
|
64
|
+
return 'no-op';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Input fingerprint ────────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export interface FingerprintInput {
|
|
70
|
+
/** Hash of the feature file content (e.g. sha256 of the markdown body from `feature show`). */
|
|
71
|
+
featureContentHash: string;
|
|
72
|
+
/** Stable vector of `<wbs>:<status>` for all linked tasks, WBS-sorted. */
|
|
73
|
+
taskStatusVector: string[];
|
|
74
|
+
/** Stable vector of `<wbs>:<mtime>` for verdict artifacts that exist, WBS-sorted. */
|
|
75
|
+
verdictMtimeVector: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Deterministic SHA-256 fingerprint over the three input signals that can invalidate a blocked
|
|
80
|
+
* suppression: the feature file itself, the linked task statuses, and the verdict artifact
|
|
81
|
+
* mtimes. Sliced to 32 hex chars for a compact, collision-safe key.
|
|
82
|
+
*/
|
|
83
|
+
export function computeFingerprint(input: FingerprintInput): string {
|
|
84
|
+
// Sort the vectors so the fingerprint is order-insensitive: the CLI may return tasks or
|
|
85
|
+
// verdict files in any order, and only the *set* of statuses/mtimes matters for detecting a
|
|
86
|
+
// genuine input change.
|
|
87
|
+
const material = [
|
|
88
|
+
input.featureContentHash,
|
|
89
|
+
...[...input.taskStatusVector].sort(),
|
|
90
|
+
...[...input.verdictMtimeVector].sort(),
|
|
91
|
+
].join('\n');
|
|
92
|
+
return createHash('sha256').update(material).digest('hex').slice(0, 32);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Blocked-state record (persisted) ─────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export interface BlockedState {
|
|
98
|
+
featureId: string;
|
|
99
|
+
inputFingerprint: string;
|
|
100
|
+
proposal: FeatureSyncProposal;
|
|
101
|
+
classification: SyncClassification;
|
|
102
|
+
result: FeatureSyncResult;
|
|
103
|
+
persistedAt: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const blockedStateFile = (featureId: string, runDir: string): string =>
|
|
107
|
+
`${runDir.replace(/\/$/, '')}/feature-sync-blocked-${featureId}.json`;
|
|
108
|
+
|
|
109
|
+
export function serializeBlockedState(state: BlockedState): string {
|
|
110
|
+
return `${JSON.stringify(state)}\n`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseBlockedState(raw: string): BlockedState | null {
|
|
114
|
+
const trimmed = raw.trim();
|
|
115
|
+
if (trimmed.length === 0) return null;
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(trimmed) as BlockedState;
|
|
118
|
+
if (
|
|
119
|
+
typeof parsed.featureId !== 'string' ||
|
|
120
|
+
typeof parsed.inputFingerprint !== 'string' ||
|
|
121
|
+
typeof parsed.proposal !== 'object' ||
|
|
122
|
+
parsed.proposal === null
|
|
123
|
+
) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return parsed;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Suppression decision ────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Given a persisted blocked state and the current input fingerprint, decide whether the new
|
|
136
|
+
* attempt should be suppressed (identical inputs, same blocked proposal) or allowed (inputs
|
|
137
|
+
* changed, or no prior blocked record).
|
|
138
|
+
*
|
|
139
|
+
* Returns `suppress: true` when the prior blocked result should be replayed without invoking
|
|
140
|
+
* `feature sync` again.
|
|
141
|
+
*/
|
|
142
|
+
export function shouldSuppressBlocked(
|
|
143
|
+
prior: BlockedState | null,
|
|
144
|
+
currentFingerprint: string,
|
|
145
|
+
): { suppress: boolean; replay?: FeatureSyncResult } {
|
|
146
|
+
if (!prior) return { suppress: false };
|
|
147
|
+
if (prior.inputFingerprint === currentFingerprint) {
|
|
148
|
+
return { suppress: true, replay: prior.result };
|
|
149
|
+
}
|
|
150
|
+
return { suppress: false };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Orchestration decision (pure) ────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
export type BoundedSyncOutcome = { kind: 'invoke' } | { kind: 'suppress'; replay: FeatureSyncResult };
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Decide whether to actually invoke `feature sync` given the persisted blocked state and the
|
|
159
|
+
* current input fingerprint. This is the pure heart of the retry-suppression policy (R1–R3):
|
|
160
|
+
*
|
|
161
|
+
* - No prior blocked state → invoke (fresh attempt).
|
|
162
|
+
* - Prior blocked state + identical fingerprint → suppress and replay the prior result.
|
|
163
|
+
* - Prior blocked state + changed fingerprint → invoke (inputs changed; R3 allows a new attempt).
|
|
164
|
+
*/
|
|
165
|
+
export function decideBoundedSync(prior: BlockedState | null, currentFingerprint: string): BoundedSyncOutcome {
|
|
166
|
+
const decision = shouldSuppressBlocked(prior, currentFingerprint);
|
|
167
|
+
if (decision.suppress && decision.replay) {
|
|
168
|
+
return { kind: 'suppress', replay: decision.replay };
|
|
169
|
+
}
|
|
170
|
+
return { kind: 'invoke' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Result processing (pure) ─────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
export interface ProcessSyncResultOutcome {
|
|
176
|
+
classification: SyncClassification;
|
|
177
|
+
/** The result to emit to stdout (replayed for suppressed, live for invoked). */
|
|
178
|
+
emit: FeatureSyncResult;
|
|
179
|
+
/** When classification === 'blocked', the state to persist. */
|
|
180
|
+
persist?: BlockedState;
|
|
181
|
+
/** Human-readable one-line annotation for the run report (may be empty). */
|
|
182
|
+
annotation: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Process a live or replayed feature-sync result: classify it, and — if blocked — produce the
|
|
187
|
+
* blocked-state record to persist. Applied/no-op results clear any annotation and never persist.
|
|
188
|
+
*/
|
|
189
|
+
export function processSyncResult(
|
|
190
|
+
result: FeatureSyncResult,
|
|
191
|
+
currentFingerprint: string,
|
|
192
|
+
persistedAt: string,
|
|
193
|
+
wasSuppressed: boolean,
|
|
194
|
+
): ProcessSyncResultOutcome {
|
|
195
|
+
const classification = classifySyncResult(result);
|
|
196
|
+
|
|
197
|
+
if (classification === 'blocked') {
|
|
198
|
+
return {
|
|
199
|
+
classification,
|
|
200
|
+
emit: result,
|
|
201
|
+
persist: {
|
|
202
|
+
featureId: result.proposal.featureId,
|
|
203
|
+
inputFingerprint: currentFingerprint,
|
|
204
|
+
proposal: result.proposal,
|
|
205
|
+
classification,
|
|
206
|
+
result,
|
|
207
|
+
persistedAt,
|
|
208
|
+
},
|
|
209
|
+
annotation: wasSuppressed
|
|
210
|
+
? `feature-sync-bounded: suppressed duplicate blocked sync for ${result.proposal.featureId} (inputs unchanged)`
|
|
211
|
+
: `feature-sync-bounded: blocked proposal for ${result.proposal.featureId} — ${result.proposal.reason}`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
classification,
|
|
217
|
+
emit: result,
|
|
218
|
+
annotation: '',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
export interface BoundedSyncCliArgs {
|
|
225
|
+
featureId: string;
|
|
226
|
+
spurBin: string;
|
|
227
|
+
runDir: string;
|
|
228
|
+
json: boolean;
|
|
229
|
+
help: boolean;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export const BOUNDED_SYNC_CLI_USAGE = `Usage:
|
|
233
|
+
bun plugins/sp/scripts/feature-sync-bounded.ts <feature-id> \\
|
|
234
|
+
--spur-bin <spur|bun apps/cli/src/index.ts> \\
|
|
235
|
+
[--run-dir .spur/run] [--json]
|
|
236
|
+
|
|
237
|
+
Wraps 'spur feature sync <id> --json' with bounded retry-suppression: an identical
|
|
238
|
+
blocked proposal is reported once and suppressed until feature file content, linked
|
|
239
|
+
task statuses, or verdict artifact mtimes change. Applied and no-op results pass
|
|
240
|
+
through unchanged.
|
|
241
|
+
|
|
242
|
+
Exit: 0 = sync handled (applied / no-op / suppressed-blocked / live-blocked).`;
|
|
243
|
+
|
|
244
|
+
export function parseBoundedSyncCliArgs(argv: string[]): BoundedSyncCliArgs {
|
|
245
|
+
let featureId = '';
|
|
246
|
+
let spurBin = 'spur';
|
|
247
|
+
let runDir = '.spur/run';
|
|
248
|
+
let json = false;
|
|
249
|
+
let help = false;
|
|
250
|
+
|
|
251
|
+
for (let i = 0; i < argv.length; i++) {
|
|
252
|
+
const a = argv[i];
|
|
253
|
+
if (a === '--help' || a === '-h') help = true;
|
|
254
|
+
else if (a === '--json') json = true;
|
|
255
|
+
else if (a === '--spur-bin') spurBin = argv[++i] ?? spurBin;
|
|
256
|
+
else if (a === '--run-dir') runDir = argv[++i] ?? runDir;
|
|
257
|
+
else if (!a.startsWith('--') && featureId === '') featureId = a;
|
|
258
|
+
}
|
|
259
|
+
return { featureId, spurBin, runDir, json, help };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
type SpawnResult = { stdout: string; stderr: string; exitCode: number; ok: boolean };
|
|
263
|
+
function runSpurJson(spurBin: string, args: string[]): SpawnResult {
|
|
264
|
+
const binParts = spurBin.split(/\s+/).filter(Boolean);
|
|
265
|
+
const cmd = binParts[0] ?? 'spur';
|
|
266
|
+
const cmdArgs = [...binParts.slice(1), ...args];
|
|
267
|
+
const r = Bun.spawnSync({ cmd: [cmd, ...cmdArgs], stdio: ['ignore', 'pipe', 'pipe'] });
|
|
268
|
+
const decode = (b: unknown): string =>
|
|
269
|
+
typeof b === 'string' ? b : Buffer.from((b as Uint8Array) ?? []).toString('utf8');
|
|
270
|
+
return {
|
|
271
|
+
stdout: decode(r.stdout),
|
|
272
|
+
stderr: decode(r.stderr),
|
|
273
|
+
exitCode: r.exitCode ?? 0,
|
|
274
|
+
ok: (r.exitCode ?? 0) === 0,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function readFeatureContentHash(spurBin: string, featureId: string): string | null {
|
|
279
|
+
const r = runSpurJson(spurBin, ['feature', 'show', featureId, '--json']);
|
|
280
|
+
if (!r.ok) return null;
|
|
281
|
+
try {
|
|
282
|
+
const parsed = JSON.parse(r.stdout) as { content?: string };
|
|
283
|
+
if (typeof parsed.content !== 'string') return null;
|
|
284
|
+
return createHash('sha256').update(parsed.content).digest('hex');
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function readTaskStatusVector(spurBin: string, featureId: string): string[] | null {
|
|
291
|
+
const r = runSpurJson(spurBin, ['task', 'list', '--feature', featureId, '--json']);
|
|
292
|
+
if (!r.ok) return null;
|
|
293
|
+
try {
|
|
294
|
+
const parsed = JSON.parse(r.stdout) as Array<{ wbs?: string; status?: string }>;
|
|
295
|
+
return parsed
|
|
296
|
+
.filter((t) => typeof t.wbs === 'string' && typeof t.status === 'string')
|
|
297
|
+
.map((t) => `${t.wbs}:${t.status}`)
|
|
298
|
+
.sort();
|
|
299
|
+
} catch {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function readVerdictMtimeVector(runDir: string): string[] {
|
|
305
|
+
// Verdict artifacts live in <runDir>/<wbs>-verdict.json. Missing files contribute nothing
|
|
306
|
+
// (a task with no verdict yet is a stable "absent" signal captured by its absence).
|
|
307
|
+
//
|
|
308
|
+
// Read via node:fs rather than `ls` + `stat` subprocesses: BSD `stat -f %m` (macOS) and GNU
|
|
309
|
+
// `stat -c %Y` (Linux) disagree, so the shell form silently yielded an empty mtime vector on
|
|
310
|
+
// Linux servers — dropping the verdict signal from the fingerprint and making suppression
|
|
311
|
+
// sticky across verdict changes (R3). statSync is portable and avoids a spawn per file.
|
|
312
|
+
const dir = runDir.replace(/\/$/, '');
|
|
313
|
+
let entries: string[];
|
|
314
|
+
try {
|
|
315
|
+
entries = readdirSync(dir).filter((f) => f.endsWith('-verdict.json'));
|
|
316
|
+
} catch {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const vector: string[] = [];
|
|
321
|
+
for (const entry of entries) {
|
|
322
|
+
try {
|
|
323
|
+
const mtime = statSync(`${dir}/${entry}`).mtimeMs;
|
|
324
|
+
vector.push(`${entry.replace('-verdict.json', '')}:${mtime}`);
|
|
325
|
+
} catch {
|
|
326
|
+
// Removed between readdir and stat — treat as absent.
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return vector.sort();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function writeBlockedState(state: BlockedState, path: string): void {
|
|
333
|
+
try {
|
|
334
|
+
// Create the run dir if absent: without it the write throws and suppression silently
|
|
335
|
+
// degrades to "invoke every time" — the exact defect this wrapper exists to fix.
|
|
336
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
337
|
+
writeFileSync(path, serializeBlockedState(state));
|
|
338
|
+
} catch (err) {
|
|
339
|
+
// Persistence stays best-effort (the live sync result is still correct), but a failure
|
|
340
|
+
// must be visible: a silent degrade is indistinguishable from the bug.
|
|
341
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
342
|
+
process.stderr.write(`feature-sync-bounded: could not persist blocked state to ${path} — ${reason}\n`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function readBlockedState(path: string): BlockedState | null {
|
|
347
|
+
try {
|
|
348
|
+
const f = Bun.file(path);
|
|
349
|
+
const text = f.size > 0 ? readFileSync(path, 'utf8') : '';
|
|
350
|
+
return parseBlockedState(text);
|
|
351
|
+
} catch {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Run the bounded-sync CLI. Does all I/O (subprocess calls, file read/write) but delegates
|
|
358
|
+
* every decision to the pure functions above. Falls back to a direct `feature sync` invocation
|
|
359
|
+
* if any pre-check call fails (R4: no mandatory dry-run, and pre-check failure must not block
|
|
360
|
+
* the real sync).
|
|
361
|
+
*/
|
|
362
|
+
export function runBoundedCli(argv: string[]): { exitCode: number; stdout: string; stderr: string } {
|
|
363
|
+
const args = parseBoundedSyncCliArgs(argv);
|
|
364
|
+
if (args.help) return { exitCode: 0, stdout: '', stderr: BOUNDED_SYNC_CLI_USAGE };
|
|
365
|
+
if (!args.featureId) return { exitCode: 1, stdout: '', stderr: BOUNDED_SYNC_CLI_USAGE };
|
|
366
|
+
|
|
367
|
+
const statePath = blockedStateFile(args.featureId, args.runDir);
|
|
368
|
+
const prior = readBlockedState(statePath);
|
|
369
|
+
|
|
370
|
+
// Pre-check input fingerprint. If any signal is unreadable, fall back to a direct live sync
|
|
371
|
+
// (correctness over suppression).
|
|
372
|
+
const featureContentHash = readFeatureContentHash(args.spurBin, args.featureId);
|
|
373
|
+
const taskStatusVector = readTaskStatusVector(args.spurBin, args.featureId);
|
|
374
|
+
if (featureContentHash === null || taskStatusVector === null) {
|
|
375
|
+
return invokeLiveSync(args, statePath);
|
|
376
|
+
}
|
|
377
|
+
const verdictMtimeVector = readVerdictMtimeVector(args.runDir);
|
|
378
|
+
|
|
379
|
+
const currentFingerprint = computeFingerprint({
|
|
380
|
+
featureContentHash,
|
|
381
|
+
taskStatusVector,
|
|
382
|
+
verdictMtimeVector,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const decision = decideBoundedSync(prior, currentFingerprint);
|
|
386
|
+
|
|
387
|
+
if (decision.kind === 'suppress') {
|
|
388
|
+
const processed = processSyncResult(decision.replay, currentFingerprint, new Date().toISOString(), true);
|
|
389
|
+
emitResult(processed.emit, processed.annotation, args.json);
|
|
390
|
+
return { exitCode: 0, stdout: '', stderr: '' };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return invokeLiveSync(args, statePath, currentFingerprint);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function invokeLiveSync(
|
|
397
|
+
args: BoundedSyncCliArgs,
|
|
398
|
+
statePath: string,
|
|
399
|
+
fingerprint?: string,
|
|
400
|
+
): { exitCode: number; stdout: string; stderr: string } {
|
|
401
|
+
const r = runSpurJson(args.spurBin, ['feature', 'sync', args.featureId, '--json']);
|
|
402
|
+
if (!r.ok) {
|
|
403
|
+
// The underlying sync failed (non-zero exit). Surface its stderr verbatim; do not
|
|
404
|
+
// fabricate a blocked classification from an error.
|
|
405
|
+
return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let result: FeatureSyncResult;
|
|
409
|
+
try {
|
|
410
|
+
result = JSON.parse(r.stdout) as FeatureSyncResult;
|
|
411
|
+
} catch {
|
|
412
|
+
// Unparseable sync output — surface it raw rather than guessing.
|
|
413
|
+
return { exitCode: r.exitCode, stdout: r.stdout, stderr: '' };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// If we have no fingerprint (pre-check fallback path), recompute minimal signals so we can
|
|
417
|
+
// still persist a blocked record. Missing signals yield an empty-string fingerprint, which
|
|
418
|
+
// still suppresses identical retries within the same batch (the inputs haven't changed from
|
|
419
|
+
// our view either). A real input change the next time will still invalidate because the
|
|
420
|
+
// pre-check will succeed and produce a real fingerprint.
|
|
421
|
+
const currentFingerprint =
|
|
422
|
+
fingerprint ??
|
|
423
|
+
computeFingerprint({
|
|
424
|
+
featureContentHash: readFeatureContentHash(args.spurBin, args.featureId) ?? '',
|
|
425
|
+
taskStatusVector: readTaskStatusVector(args.spurBin, args.featureId) ?? [],
|
|
426
|
+
verdictMtimeVector: readVerdictMtimeVector(args.runDir),
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const processed = processSyncResult(result, currentFingerprint, new Date().toISOString(), false);
|
|
430
|
+
if (processed.persist) writeBlockedState(processed.persist, statePath);
|
|
431
|
+
emitResult(processed.emit, processed.annotation, args.json);
|
|
432
|
+
return { exitCode: 0, stdout: '', stderr: '' };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function emitResult(result: FeatureSyncResult, annotation: string, json: boolean): void {
|
|
436
|
+
if (json) {
|
|
437
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
438
|
+
} else {
|
|
439
|
+
process.stdout.write(
|
|
440
|
+
`${result.proposal.featureId}: ${result.proposal.from} → ${result.proposal.to} (applied=${result.applied})\n`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
if (annotation.length > 0) process.stderr.write(`${annotation}\n`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (import.meta.main) {
|
|
447
|
+
const { exitCode, stdout, stderr } = runBoundedCli(Bun.argv.slice(2));
|
|
448
|
+
if (stdout) process.stdout.write(stdout);
|
|
449
|
+
if (stderr) process.stderr.write(`${stderr}\n`);
|
|
450
|
+
process.exit(exitCode);
|
|
451
|
+
}
|