@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,478 @@
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
+
28
+ // ── Local types (match packages/app FeatureService shapes; no package import) ───────────
29
+
30
+ export interface FeatureSyncProposal {
31
+ featureId: string;
32
+ from: string;
33
+ to: string;
34
+ reason: string;
35
+ requiresConfirm?: boolean;
36
+ gateBlocked?: boolean;
37
+ gateFindings?: unknown[];
38
+ hops?: string[];
39
+ }
40
+
41
+ export interface FeatureSyncResult {
42
+ proposal: FeatureSyncProposal;
43
+ applied: boolean;
44
+ appliedHops: string[];
45
+ }
46
+
47
+ export type SyncClassification = 'applied' | 'no-op' | 'blocked';
48
+
49
+ // ── Pure classification logic ────────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Classify a structured feature-sync result.
53
+ *
54
+ * `gateBlocked` is checked FIRST: a gate-blocked proposal with a partial hop can report
55
+ * `applied: true` (e.g. backlog→active applied, but the engine stopped before the gate-blocked
56
+ * done transition). That must still classify as 'blocked' so the suppressed record is written
57
+ * and identical retries are avoided.
58
+ *
59
+ * After that: `applied === true` → 'applied'; `from !== to` (and not applied) → 'blocked'
60
+ * (covers requiresConfirm-deferred hops); otherwise 'no-op' (from === to).
61
+ */
62
+ export function classifySyncResult(result: FeatureSyncResult): SyncClassification {
63
+ if (result.proposal.gateBlocked === true) return 'blocked';
64
+ if (result.applied === true) return 'applied';
65
+ if (result.proposal.from !== result.proposal.to) return 'blocked';
66
+ return 'no-op';
67
+ }
68
+
69
+ // ── Input fingerprint ────────────────────────────────────────────────────────────────────
70
+
71
+ export interface FingerprintInput {
72
+ /** Hash of the feature file content (e.g. sha256 of the markdown body from `feature show`). */
73
+ featureContentHash: string;
74
+ /** Stable vector of `<wbs>:<status>` for all linked tasks, WBS-sorted. */
75
+ taskStatusVector: string[];
76
+ /** Stable vector of `<wbs>:<mtime>` for verdict artifacts that exist, WBS-sorted. */
77
+ verdictMtimeVector: string[];
78
+ }
79
+
80
+ /**
81
+ * Deterministic SHA-256 fingerprint over the three input signals that can invalidate a blocked
82
+ * suppression: the feature file itself, the linked task statuses, and the verdict artifact
83
+ * mtimes. Sliced to 32 hex chars for a compact, collision-safe key.
84
+ */
85
+ export function computeFingerprint(input: FingerprintInput): string {
86
+ // Sort the vectors so the fingerprint is order-insensitive: the CLI may return tasks or
87
+ // verdict files in any order, and only the *set* of statuses/mtimes matters for detecting a
88
+ // genuine input change.
89
+ const material = [
90
+ input.featureContentHash,
91
+ ...[...input.taskStatusVector].sort(),
92
+ ...[...input.verdictMtimeVector].sort(),
93
+ ].join('\n');
94
+ return createHash('sha256').update(material).digest('hex').slice(0, 32);
95
+ }
96
+
97
+ // ── Blocked-state record (persisted) ─────────────────────────────────────────────────────
98
+
99
+ export interface BlockedState {
100
+ featureId: string;
101
+ inputFingerprint: string;
102
+ proposal: FeatureSyncProposal;
103
+ classification: SyncClassification;
104
+ result: FeatureSyncResult;
105
+ persistedAt: string;
106
+ }
107
+
108
+ export const blockedStateFile = (featureId: string, runDir: string): string =>
109
+ `${runDir.replace(/\/$/, '')}/feature-sync-blocked-${featureId}.json`;
110
+
111
+ export function serializeBlockedState(state: BlockedState): string {
112
+ return `${JSON.stringify(state)}\n`;
113
+ }
114
+
115
+ export function parseBlockedState(raw: string): BlockedState | null {
116
+ const trimmed = raw.trim();
117
+ if (trimmed.length === 0) return null;
118
+ try {
119
+ const parsed = JSON.parse(trimmed) as BlockedState;
120
+ if (
121
+ typeof parsed.featureId !== 'string' ||
122
+ typeof parsed.inputFingerprint !== 'string' ||
123
+ typeof parsed.proposal !== 'object' ||
124
+ parsed.proposal === null
125
+ ) {
126
+ return null;
127
+ }
128
+ return parsed;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ // ── Suppression decision ────────────────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Given a persisted blocked state and the current input fingerprint, decide whether the new
138
+ * attempt should be suppressed (identical inputs, same blocked proposal) or allowed (inputs
139
+ * changed, or no prior blocked record).
140
+ *
141
+ * Returns `suppress: true` when the prior blocked result should be replayed without invoking
142
+ * `feature sync` again.
143
+ */
144
+ export function shouldSuppressBlocked(
145
+ prior: BlockedState | null,
146
+ currentFingerprint: string,
147
+ ): { suppress: boolean; replay?: FeatureSyncResult } {
148
+ if (!prior) return { suppress: false };
149
+ if (prior.inputFingerprint === currentFingerprint) {
150
+ return { suppress: true, replay: prior.result };
151
+ }
152
+ return { suppress: false };
153
+ }
154
+
155
+ // ── Orchestration decision (pure) ────────────────────────────────────────────────────────
156
+
157
+ export type BoundedSyncOutcome = { kind: 'invoke' } | { kind: 'suppress'; replay: FeatureSyncResult };
158
+
159
+ /**
160
+ * Decide whether to actually invoke `feature sync` given the persisted blocked state and the
161
+ * current input fingerprint. This is the pure heart of the retry-suppression policy (R1–R3):
162
+ *
163
+ * - No prior blocked state → invoke (fresh attempt).
164
+ * - Prior blocked state + identical fingerprint → suppress and replay the prior result.
165
+ * - Prior blocked state + changed fingerprint → invoke (inputs changed; R3 allows a new attempt).
166
+ */
167
+ export function decideBoundedSync(prior: BlockedState | null, currentFingerprint: string): BoundedSyncOutcome {
168
+ const decision = shouldSuppressBlocked(prior, currentFingerprint);
169
+ if (decision.suppress && decision.replay) {
170
+ return { kind: 'suppress', replay: decision.replay };
171
+ }
172
+ return { kind: 'invoke' };
173
+ }
174
+
175
+ // ── Result processing (pure) ─────────────────────────────────────────────────────────────
176
+
177
+ export interface ProcessSyncResultOutcome {
178
+ classification: SyncClassification;
179
+ /** The result to emit to stdout (replayed for suppressed, live for invoked). */
180
+ emit: FeatureSyncResult;
181
+ /** When classification === 'blocked', the state to persist. */
182
+ persist?: BlockedState;
183
+ /** Human-readable one-line annotation for the run report (may be empty). */
184
+ annotation: string;
185
+ }
186
+
187
+ /**
188
+ * Process a live or replayed feature-sync result: classify it, and — if blocked — produce the
189
+ * blocked-state record to persist. Applied/no-op results clear any annotation and never persist.
190
+ */
191
+ export function processSyncResult(
192
+ result: FeatureSyncResult,
193
+ currentFingerprint: string,
194
+ persistedAt: string,
195
+ wasSuppressed: boolean,
196
+ ): ProcessSyncResultOutcome {
197
+ const classification = classifySyncResult(result);
198
+
199
+ if (classification === 'blocked') {
200
+ return {
201
+ classification,
202
+ emit: result,
203
+ persist: {
204
+ featureId: result.proposal.featureId,
205
+ inputFingerprint: currentFingerprint,
206
+ proposal: result.proposal,
207
+ classification,
208
+ result,
209
+ persistedAt,
210
+ },
211
+ annotation: wasSuppressed
212
+ ? `feature-sync-bounded: suppressed duplicate blocked sync for ${result.proposal.featureId} (inputs unchanged)`
213
+ : `feature-sync-bounded: blocked proposal for ${result.proposal.featureId} — ${result.proposal.reason}`,
214
+ };
215
+ }
216
+
217
+ return {
218
+ classification,
219
+ emit: result,
220
+ annotation: '',
221
+ };
222
+ }
223
+
224
+ // ── CLI ──────────────────────────────────────────────────────────────────────────────────
225
+
226
+ export interface BoundedSyncCliArgs {
227
+ featureId: string;
228
+ spurBin: string;
229
+ runDir: string;
230
+ json: boolean;
231
+ help: boolean;
232
+ }
233
+
234
+ export const BOUNDED_SYNC_CLI_USAGE = `Usage:
235
+ bun plugins/sp/scripts/feature-sync-bounded.ts <feature-id> \\
236
+ --spur-bin <spur|bun apps/cli/src/index.ts> \\
237
+ [--run-dir .spur/run] [--json]
238
+
239
+ Wraps 'spur feature sync <id> --json' with bounded retry-suppression: an identical
240
+ blocked proposal is reported once and suppressed until feature file content, linked
241
+ task statuses, or verdict artifact mtimes change. Applied and no-op results pass
242
+ through unchanged.
243
+
244
+ Exit: 0 = sync handled (applied / no-op / suppressed-blocked / live-blocked).`;
245
+
246
+ /**
247
+ * Resolve the spur CLI command in a monorepo-safe way:
248
+ * --spur-bin > SPUR_BIN > monorepo-local CLI entry > PATH `spur`.
249
+ * The plugin's own CI always passes an explicit --spur-bin; this fallback chain
250
+ * keeps ad-hoc invocations from silently hitting a stale PATH install.
251
+ */
252
+ export function defaultSpurBin(): string {
253
+ if (process.env.SPUR_BIN) return process.env.SPUR_BIN;
254
+ // scripts/ -> plugins/sp/ -> <repo>/apps/cli/src/index.ts (fileURLToPath — raw pathname breaks
255
+ // on %-encoded paths, e.g. spaces in the checkout directory)
256
+ const local = fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url));
257
+ if (existsSync(local)) return `bun ${local}`;
258
+ return 'spur';
259
+ }
260
+
261
+ export function parseBoundedSyncCliArgs(argv: string[]): BoundedSyncCliArgs {
262
+ let featureId = '';
263
+ let spurBin = defaultSpurBin();
264
+ let runDir = '.spur/run';
265
+ let json = false;
266
+ let help = false;
267
+
268
+ for (let i = 0; i < argv.length; i++) {
269
+ const a = argv[i];
270
+ if (a === '--help' || a === '-h') help = true;
271
+ else if (a === '--json') json = true;
272
+ else if (a === '--spur-bin') spurBin = argv[++i] ?? spurBin;
273
+ else if (a === '--run-dir') runDir = argv[++i] ?? runDir;
274
+ else if (!a.startsWith('--') && featureId === '') featureId = a;
275
+ }
276
+ return { featureId, spurBin, runDir, json, help };
277
+ }
278
+
279
+ type SpawnResult = { stdout: string; stderr: string; exitCode: number; ok: boolean };
280
+ function runSpurJson(spurBin: string, args: string[]): SpawnResult {
281
+ const binParts = spurBin.split(/\s+/).filter(Boolean);
282
+ const cmd = binParts[0] ?? 'spur';
283
+ const cmdArgs = [...binParts.slice(1), ...args];
284
+ const r = spawnSync(cmd, cmdArgs, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
285
+ const decode = (b: unknown): string =>
286
+ typeof b === 'string' ? b : Buffer.from((b as Uint8Array) ?? []).toString('utf8');
287
+ return {
288
+ stdout: typeof r.stdout === 'string' ? r.stdout : decode(r.stdout),
289
+ stderr: typeof r.stderr === 'string' ? r.stderr : decode(r.stderr),
290
+ exitCode: r.status ?? (r.error ? 1 : 0),
291
+ ok: (r.status ?? (r.error ? 1 : 0)) === 0,
292
+ };
293
+ }
294
+
295
+ function readFeatureContentHash(spurBin: string, featureId: string): string | null {
296
+ const r = runSpurJson(spurBin, ['feature', 'show', featureId, '--json']);
297
+ if (!r.ok) return null;
298
+ try {
299
+ const parsed = JSON.parse(r.stdout) as { content?: string };
300
+ if (typeof parsed.content !== 'string') return null;
301
+ return createHash('sha256').update(parsed.content).digest('hex');
302
+ } catch {
303
+ return null;
304
+ }
305
+ }
306
+
307
+ function readTaskStatusVector(spurBin: string, featureId: string): string[] | null {
308
+ const r = runSpurJson(spurBin, ['task', 'list', '--feature', featureId, '--json']);
309
+ if (!r.ok) return null;
310
+ try {
311
+ const parsed = JSON.parse(r.stdout) as Array<{ wbs?: string; status?: string }>;
312
+ return parsed
313
+ .filter((t) => typeof t.wbs === 'string' && typeof t.status === 'string')
314
+ .map((t) => `${t.wbs}:${t.status}`)
315
+ .sort();
316
+ } catch {
317
+ return null;
318
+ }
319
+ }
320
+
321
+ function readVerdictMtimeVector(runDir: string): string[] {
322
+ // Verdict artifacts live in <runDir>/<wbs>-verdict.json. Missing files contribute nothing
323
+ // (a task with no verdict yet is a stable "absent" signal captured by its absence).
324
+ //
325
+ // Read via node:fs rather than `ls` + `stat` subprocesses: BSD `stat -f %m` (macOS) and GNU
326
+ // `stat -c %Y` (Linux) disagree, so the shell form silently yielded an empty mtime vector on
327
+ // Linux servers — dropping the verdict signal from the fingerprint and making suppression
328
+ // sticky across verdict changes (R3). statSync is portable and avoids a spawn per file.
329
+ const dir = runDir.replace(/\/$/, '');
330
+ let entries: string[];
331
+ try {
332
+ entries = readdirSync(dir).filter((f) => f.endsWith('-verdict.json'));
333
+ } catch {
334
+ return [];
335
+ }
336
+
337
+ const vector: string[] = [];
338
+ for (const entry of entries) {
339
+ try {
340
+ const mtime = statSync(`${dir}/${entry}`).mtimeMs;
341
+ vector.push(`${entry.replace('-verdict.json', '')}:${mtime}`);
342
+ } catch {
343
+ // Removed between readdir and stat — treat as absent.
344
+ }
345
+ }
346
+ return vector.sort();
347
+ }
348
+
349
+ function writeBlockedState(state: BlockedState, path: string): void {
350
+ try {
351
+ // Create the run dir if absent: without it the write throws and suppression silently
352
+ // degrades to "invoke every time" — the exact defect this wrapper exists to fix.
353
+ mkdirSync(dirname(path), { recursive: true });
354
+ writeFileSync(path, serializeBlockedState(state));
355
+ } catch (err) {
356
+ // Persistence stays best-effort (the live sync result is still correct), but a failure
357
+ // must be visible: a silent degrade is indistinguishable from the bug.
358
+ const reason = err instanceof Error ? err.message : String(err);
359
+ process.stderr.write(`feature-sync-bounded: could not persist blocked state to ${path} — ${reason}\n`);
360
+ }
361
+ }
362
+
363
+ function readBlockedState(path: string): BlockedState | null {
364
+ try {
365
+ if (!existsSync(path)) return null;
366
+ const text = readFileSync(path, 'utf8');
367
+ return parseBlockedState(text);
368
+ } catch {
369
+ return null;
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Run the bounded-sync CLI. Does all I/O (subprocess calls, file read/write) but delegates
375
+ * every decision to the pure functions above. Falls back to a direct `feature sync` invocation
376
+ * if any pre-check call fails (R4: no mandatory dry-run, and pre-check failure must not block
377
+ * the real sync).
378
+ */
379
+ export function runBoundedCli(argv: string[]): { exitCode: number; stdout: string; stderr: string } {
380
+ const args = parseBoundedSyncCliArgs(argv);
381
+ if (args.help) return { exitCode: 0, stdout: '', stderr: BOUNDED_SYNC_CLI_USAGE };
382
+ if (!args.featureId) return { exitCode: 1, stdout: '', stderr: BOUNDED_SYNC_CLI_USAGE };
383
+
384
+ const statePath = blockedStateFile(args.featureId, args.runDir);
385
+ const prior = readBlockedState(statePath);
386
+
387
+ // Pre-check input fingerprint. If any signal is unreadable, fall back to a direct live sync
388
+ // (correctness over suppression).
389
+ const featureContentHash = readFeatureContentHash(args.spurBin, args.featureId);
390
+ const taskStatusVector = readTaskStatusVector(args.spurBin, args.featureId);
391
+ if (featureContentHash === null || taskStatusVector === null) {
392
+ return invokeLiveSync(args, statePath);
393
+ }
394
+ const verdictMtimeVector = readVerdictMtimeVector(args.runDir);
395
+
396
+ const currentFingerprint = computeFingerprint({
397
+ featureContentHash,
398
+ taskStatusVector,
399
+ verdictMtimeVector,
400
+ });
401
+
402
+ const decision = decideBoundedSync(prior, currentFingerprint);
403
+
404
+ if (decision.kind === 'suppress') {
405
+ const processed = processSyncResult(decision.replay, currentFingerprint, new Date().toISOString(), true);
406
+ emitResult(processed.emit, processed.annotation, args.json);
407
+ return { exitCode: 0, stdout: '', stderr: '' };
408
+ }
409
+
410
+ return invokeLiveSync(args, statePath, currentFingerprint);
411
+ }
412
+
413
+ function invokeLiveSync(
414
+ args: BoundedSyncCliArgs,
415
+ statePath: string,
416
+ fingerprint?: string,
417
+ ): { exitCode: number; stdout: string; stderr: string } {
418
+ const r = runSpurJson(args.spurBin, ['feature', 'sync', args.featureId, '--json']);
419
+ if (!r.ok) {
420
+ // The underlying sync failed (non-zero exit). Surface its stderr verbatim; do not
421
+ // fabricate a blocked classification from an error.
422
+ return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr };
423
+ }
424
+
425
+ let result: FeatureSyncResult;
426
+ try {
427
+ result = JSON.parse(r.stdout) as FeatureSyncResult;
428
+ } catch {
429
+ // Unparseable sync output — surface it raw rather than guessing.
430
+ return { exitCode: r.exitCode, stdout: r.stdout, stderr: '' };
431
+ }
432
+ // Shape guard (review finding 1): a parseable envelope that is not a FeatureSyncResult
433
+ // (missing `proposal`) must fail loudly with a message, not a TypeError in
434
+ // classifySyncResult. Mirrors the validation the read side already performs.
435
+ if (!result || typeof result !== 'object' || !result.proposal) {
436
+ return {
437
+ exitCode: r.exitCode,
438
+ stdout: r.stdout,
439
+ stderr: 'unrecognized feature sync envelope: missing proposal',
440
+ };
441
+ }
442
+
443
+ // If we have no fingerprint (pre-check fallback path), recompute minimal signals so we can
444
+ // still persist a blocked record. Missing signals yield an empty-string fingerprint, which
445
+ // still suppresses identical retries within the same batch (the inputs haven't changed from
446
+ // our view either). A real input change the next time will still invalidate because the
447
+ // pre-check will succeed and produce a real fingerprint.
448
+ const currentFingerprint =
449
+ fingerprint ??
450
+ computeFingerprint({
451
+ featureContentHash: readFeatureContentHash(args.spurBin, args.featureId) ?? '',
452
+ taskStatusVector: readTaskStatusVector(args.spurBin, args.featureId) ?? [],
453
+ verdictMtimeVector: readVerdictMtimeVector(args.runDir),
454
+ });
455
+
456
+ const processed = processSyncResult(result, currentFingerprint, new Date().toISOString(), false);
457
+ if (processed.persist) writeBlockedState(processed.persist, statePath);
458
+ emitResult(processed.emit, processed.annotation, args.json);
459
+ return { exitCode: 0, stdout: '', stderr: '' };
460
+ }
461
+
462
+ function emitResult(result: FeatureSyncResult, annotation: string, json: boolean): void {
463
+ if (json) {
464
+ process.stdout.write(`${JSON.stringify(result)}\n`);
465
+ } else {
466
+ process.stdout.write(
467
+ `${result.proposal.featureId}: ${result.proposal.from} → ${result.proposal.to} (applied=${result.applied})\n`,
468
+ );
469
+ }
470
+ if (annotation.length > 0) process.stderr.write(`${annotation}\n`);
471
+ }
472
+
473
+ if (import.meta.main) {
474
+ const { exitCode, stdout, stderr } = runBoundedCli(process.argv.slice(2));
475
+ if (stdout) process.stdout.write(stdout);
476
+ if (stderr) process.stderr.write(`${stderr}\n`);
477
+ process.exit(exitCode);
478
+ }