@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +20 -0
- package/package.json +1 -1
- package/plugins/generations/content-gen/dist/index.js +20 -0
- package/plugins/generations/core-facts-gen/dist/index.js +20 -0
- package/plugins/generations/daily-article-gen/dist/index.js +29 -1
- package/plugins/generations/daily-article-gen/src/index.ts +13 -1
- package/plugins/generations/dailynews-gen/dist/index.js +20 -0
- package/plugins/generations/episode-plan-gen/dist/index.js +24 -0
- package/plugins/generations/episode-plan-gen/src/index.ts +11 -0
- package/plugins/generations/image-gen/dist/index.js +2296 -53
- package/plugins/generations/image-gen/src/providers/agnes.ts +110 -0
- package/plugins/generations/image-gen/src/providers/azure.ts +153 -0
- package/plugins/generations/image-gen/src/providers/codex-cli.ts +170 -0
- package/plugins/generations/image-gen/src/providers/dashscope.ts +485 -0
- package/plugins/generations/image-gen/src/providers/google.ts +268 -0
- package/plugins/generations/image-gen/src/providers/huggingface.ts +59 -0
- package/plugins/generations/image-gen/src/providers/jimeng.ts +259 -0
- package/plugins/generations/image-gen/src/providers/minimax.ts +171 -0
- package/plugins/generations/image-gen/src/providers/openai.ts +319 -0
- package/plugins/generations/image-gen/src/providers/openrouter.ts +257 -0
- package/plugins/generations/image-gen/src/providers/refs.ts +24 -0
- package/plugins/generations/image-gen/src/providers/replicate.ts +279 -0
- package/plugins/generations/image-gen/src/providers/seedream.ts +1 -1
- package/plugins/generations/image-gen/src/providers/types.ts +52 -51
- package/plugins/generations/image-gen/src/providers/zai.ts +237 -0
- package/plugins/generations/news-report-gen/dist/index.js +22193 -0
- package/plugins/generations/news-report-gen/package.json +17 -0
- package/plugins/generations/news-report-gen/plugin.json +7 -0
- package/plugins/generations/news-report-gen/src/index.ts +308 -0
- package/plugins/generations/news-report-gen/tsconfig.json +4 -0
- package/plugins/generations/omni-voice-gen/Makefile +14 -0
- package/plugins/generations/omni-voice-gen/README.md +112 -0
- package/plugins/generations/omni-voice-gen/bin/omni-voice-gen +2 -0
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +2 -0
- package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +6 -0
- package/plugins/generations/omni-voice-gen/plugin.json +6 -0
- package/plugins/generations/omni-voice-gen/profiles.json +12 -0
- package/plugins/generations/omni-voice-gen/pyproject.toml +25 -0
- package/plugins/generations/omni-voice-gen/scripts/coverage_gate.py +74 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__init__.py +1 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__main__.py +39 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/audio.py +190 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/backend.py +150 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/contract.py +76 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/mp3.py +60 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +289 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/profiles.py +100 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +234 -0
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/voicescript.py +352 -0
- package/plugins/generations/omni-voice-gen/uv.lock +3510 -0
- package/plugins/generations/voice-gen/dist/index.js +30 -2
- package/plugins/generations/voice-gen/src/index.ts +16 -1
- package/plugins/generations/voice-gen/src/voicebox-client.ts +3 -1
- package/plugins/ingestions/aihot-ingest/dist/index.js +20 -0
- package/plugins/ingestions/horizon-ingest/dist/index.js +20 -0
- package/plugins/ingestions/last30days-ingest/dist/index.js +20 -0
- package/plugins/ingestions/web-search/dist/index.js +20 -0
- package/plugins/kk/commands/image-extract.md +40 -0
- package/plugins/kk/commands/image-generate.md +18 -1
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/skills/image-authoring/SKILL.md +10 -3
- package/plugins/kk/skills/image-authoring/references/format-drafting.md +57 -0
- package/plugins/kk/skills/image-authoring/references/style-extraction.md +14 -9
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +130 -30
- package/plugins/publishings/emdash-pub/dist/index.js +20 -0
- package/plugins/publishings/podcast-pub/dist/index.js +57 -3
- package/plugins/publishings/podcast-pub/src/index.ts +18 -2
- package/plugins/publishings/podcast-pub/src/show-notes.ts +56 -9
- package/plugins/publishings/qiita-pub/dist/index.js +20 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +96 -6
- package/plugins/publishings/surfdash-pub/src/index.ts +109 -9
- package/plugins/publishings/zenn-pub/dist/index.js +20 -0
- package/plugins/sp/scripts/batch-preflight.mjs +346 -0
- package/plugins/sp/scripts/batch-preflight.ts +459 -0
- package/plugins/sp/scripts/daily-summary/daily-summary.mjs +615 -0
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +846 -0
- package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +223 -0
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +367 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +132 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +169 -0
- package/plugins/sp/scripts/feature-dev-precheck.mjs +171 -0
- package/plugins/sp/scripts/feature-dev-precheck.ts +238 -0
- package/plugins/sp/scripts/feature-sync-bounded.mjs +285 -0
- package/plugins/sp/scripts/feature-sync-bounded.ts +478 -0
- package/plugins/sp/scripts/history-anatomy-cache.mjs +902 -0
- package/plugins/sp/scripts/history-anatomy-cache.ts +1028 -0
- package/plugins/sp/scripts/idea-handoff.mjs +22 -0
- package/plugins/sp/scripts/idea-handoff.ts +44 -0
- package/plugins/sp/scripts/inline-pipeline-parity-check.ts +185 -0
- package/plugins/sp/scripts/inline-run-setup.ts +198 -0
- package/plugins/sp/scripts/pr-reviewing.mjs +769 -0
- package/plugins/sp/scripts/pr-reviewing.ts +925 -0
- package/plugins/sp/scripts/quality-gate.mjs +179 -0
- package/plugins/sp/scripts/quality-gate.ts +217 -0
- package/plugins/sp/scripts/script-contract-check.ts +319 -0
- package/plugins/sp/scripts/stage-registry-adapter.ts +1533 -0
- package/plugins/sp/scripts/surface-drift-inventory.ts +929 -0
- package/plugins/sp/scripts/task-evidence-precheck.ts +181 -0
- package/plugins/sp/scripts/task-size-precheck.ts +175 -0
- package/plugins/sp/scripts/transition-shim-check.ts +238 -0
- package/plugins/sp/scripts/validate-commands.ts +689 -0
- package/plugins/sp/scripts/validate-flag-contracts.ts +878 -0
- package/plugins/sp/scripts/verify-answer-lint.ts +530 -0
- package/plugins/sp/scripts/workflow-step-profile.mjs +316 -0
- package/plugins/sp/scripts/workflow-step-profile.ts +456 -0
- package/plugins/sp/scripts/wrapup-steps.mjs +373 -0
- package/plugins/sp/scripts/wrapup-steps.ts +466 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* feature-dev-precheck — portable identity/roster gate behind the feature-dev pipeline
|
|
4
|
+
* precheck (task 0825, feature I21, governance §1.1 composition budgets).
|
|
5
|
+
*
|
|
6
|
+
* Ports the former `feature-dev.yaml` `precheck:onEnter:0` shell program one-for-one so the
|
|
7
|
+
* workflow stays inside the shell-program caps while writing the same `.spur/run` artifacts:
|
|
8
|
+
* - `<runId>-feature-dev-feature.json` merged `feature show` capture (stdout + stderr)
|
|
9
|
+
* - `<runId>-feature-dev-roster.json` merged `task list --feature` capture
|
|
10
|
+
* - `<runId>-feature-dev-tasks.txt` frozen sorted todo WBS list, comma-joined
|
|
11
|
+
* - `<runId>-feature-dev-precheck.status` `PASS`/`FAIL`
|
|
12
|
+
*
|
|
13
|
+
* Failure ladder (first failing check wins, message to stderr, FAIL status):
|
|
14
|
+
* 1. identity/roster read failure — missing featureId/runId, unknown feature, or a
|
|
15
|
+
* non-zero rc from any of the three reads (message carries the rc triple);
|
|
16
|
+
* 2. roster not a non-empty JSON array;
|
|
17
|
+
* 3. roster contract breach — empty/duplicate/non-string WBS identities or a status
|
|
18
|
+
* outside todo/done/cancelled/backlog/wip/testing/blocked;
|
|
19
|
+
* 4. refinement-blocking statuses present (backlog/wip/testing/blocked).
|
|
20
|
+
*
|
|
21
|
+
* Environment: `featureId`, `__runId` (required), `spurBin` (split on whitespace into a
|
|
22
|
+
* command plus prefix args). Soft-fail contract: the process always exits 0; the verdict
|
|
23
|
+
* lives in the status file. Node-builtin imports only; pure helpers are exported for unit
|
|
24
|
+
* testing (ADR-065).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { spawnSync } from 'node:child_process';
|
|
28
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
|
|
31
|
+
/** Statuses that must be refined/resumed through their own pipelines before batching. */
|
|
32
|
+
export const BLOCKING_STATUSES = ['backlog', 'wip', 'testing', 'blocked'] as const;
|
|
33
|
+
|
|
34
|
+
/** Every status a roster row may carry (anything else fails the structural contract). */
|
|
35
|
+
export const KNOWN_STATUSES = ['todo', 'done', 'cancelled', ...BLOCKING_STATUSES] as const;
|
|
36
|
+
|
|
37
|
+
export interface FeatureDevPrecheckEnv {
|
|
38
|
+
featureId?: string;
|
|
39
|
+
__runId?: string;
|
|
40
|
+
spurBin?: string;
|
|
41
|
+
[key: string]: string | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FeatureDevPrecheckOptions {
|
|
45
|
+
/** Base directory for `.spur/run`; defaults to the process cwd (CLI behavior). */
|
|
46
|
+
cwd?: string;
|
|
47
|
+
/** Extra environment for the spawned CLI reads (merged over process.env). */
|
|
48
|
+
env?: Record<string, string>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface FeatureDevPrecheckResult {
|
|
52
|
+
status: 'PASS' | 'FAIL';
|
|
53
|
+
featureFile: string;
|
|
54
|
+
rosterFile: string;
|
|
55
|
+
tasksFile: string;
|
|
56
|
+
statusFile: string;
|
|
57
|
+
/** Frozen todo list; empty string on FAIL. */
|
|
58
|
+
tasks: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RosterRow {
|
|
62
|
+
wbs: unknown;
|
|
63
|
+
status: unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Identity gate: rc 0 when featureId and __runId are both non-empty, else 1. */
|
|
67
|
+
export function identityRc(featureId: string | undefined, runId: string | undefined): number {
|
|
68
|
+
return featureId && runId ? 0 : 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* jq `type == "object" and .id == $id` port: parsed JSON must be a non-array object whose
|
|
73
|
+
* `.id` equals the supplied feature id.
|
|
74
|
+
*/
|
|
75
|
+
export function featureIdentityMatches(raw: string, featureId: string): boolean {
|
|
76
|
+
let parsed: unknown;
|
|
77
|
+
try {
|
|
78
|
+
parsed = JSON.parse(raw);
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
return (
|
|
83
|
+
typeof parsed === 'object' &&
|
|
84
|
+
parsed !== null &&
|
|
85
|
+
!Array.isArray(parsed) &&
|
|
86
|
+
(parsed as { id?: unknown }).id === featureId
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** jq roster-shape port: parsed JSON must be a non-empty array. */
|
|
91
|
+
export function rosterIsNonEmptyArray(raw: string): boolean {
|
|
92
|
+
let parsed: unknown;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(raw);
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return Array.isArray(parsed) && parsed.length > 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** jq WBS-identity/status contract port: non-empty string wbs, unique, known statuses. */
|
|
102
|
+
export function rosterContractHolds(rows: RosterRow[]): boolean {
|
|
103
|
+
const seen = new Set<string>();
|
|
104
|
+
for (const row of rows) {
|
|
105
|
+
if (row === null || typeof row !== 'object') return false;
|
|
106
|
+
if (typeof row.wbs !== 'string' || row.wbs.length === 0) return false;
|
|
107
|
+
if (seen.has(row.wbs)) return false;
|
|
108
|
+
seen.add(row.wbs);
|
|
109
|
+
if (typeof row.status !== 'string' || !(KNOWN_STATUSES as readonly string[]).includes(row.status)) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** jq blocking-status port: any backlog/wip/testing/blocked member. */
|
|
117
|
+
export function hasBlockingStatus(rows: RosterRow[]): boolean {
|
|
118
|
+
return rows.some(
|
|
119
|
+
(row) => typeof row.status === 'string' && (BLOCKING_STATUSES as readonly string[]).includes(row.status),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** jq `sort | join(",")` port over the todo subset: codepoint-sorted, comma-joined, no newline. */
|
|
124
|
+
export function freezeTodoList(rows: RosterRow[]): string {
|
|
125
|
+
return rows
|
|
126
|
+
.filter((row) => row.status === 'todo' && typeof row.wbs === 'string')
|
|
127
|
+
.map((row) => row.wbs as string)
|
|
128
|
+
.sort()
|
|
129
|
+
.join(',');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** `spurBin` splits on whitespace into a command plus prefix args (PATH-independent runs). */
|
|
133
|
+
export function spurCommand(spurBin: string | undefined): { cmd: string; prefix: string[] } {
|
|
134
|
+
const parts = (spurBin ?? 'spur')
|
|
135
|
+
.trim()
|
|
136
|
+
.split(/\s+/)
|
|
137
|
+
.filter((p) => p.length > 0);
|
|
138
|
+
return { cmd: parts[0] ?? 'spur', prefix: parts.slice(1) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readFileSyncRaw(path: string): string {
|
|
142
|
+
try {
|
|
143
|
+
return readFileSync(path, 'utf8');
|
|
144
|
+
} catch {
|
|
145
|
+
return '';
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function runFeatureDevPrecheck(
|
|
150
|
+
env: FeatureDevPrecheckEnv,
|
|
151
|
+
options: FeatureDevPrecheckOptions = {},
|
|
152
|
+
): FeatureDevPrecheckResult {
|
|
153
|
+
const cwd = options.cwd;
|
|
154
|
+
const runDir = join('.spur', 'run');
|
|
155
|
+
mkdirSync(cwd ? join(cwd, runDir) : runDir, { recursive: true });
|
|
156
|
+
const abs = (p: string): string => (cwd ? join(cwd, p) : p);
|
|
157
|
+
const featureFile = join(runDir, `${env.__runId ?? ''}-feature-dev-feature.json`);
|
|
158
|
+
const rosterFile = join(runDir, `${env.__runId ?? ''}-feature-dev-roster.json`);
|
|
159
|
+
const tasksFile = join(runDir, `${env.__runId ?? ''}-feature-dev-tasks.txt`);
|
|
160
|
+
const statusFile = join(runDir, `${env.__runId ?? ''}-feature-dev-precheck.status`);
|
|
161
|
+
const featureId = env.featureId ?? '';
|
|
162
|
+
|
|
163
|
+
rmSync(abs(statusFile), { force: true });
|
|
164
|
+
|
|
165
|
+
const { cmd, prefix } = spurCommand(env.spurBin);
|
|
166
|
+
/** Spawn one CLI read; merged output into the artifact, 127 on a spawn error (exec loss). */
|
|
167
|
+
const read = (args: string[], target: string): number => {
|
|
168
|
+
const result = spawnSync(cmd, [...prefix, ...args], {
|
|
169
|
+
cwd,
|
|
170
|
+
encoding: 'utf8',
|
|
171
|
+
...(options.env ? { env: { ...process.env, ...options.env } } : {}),
|
|
172
|
+
});
|
|
173
|
+
if (result.error !== undefined) {
|
|
174
|
+
writeFileSync(abs(target), `spawn failed: ${result.error.message}\n`);
|
|
175
|
+
return 127;
|
|
176
|
+
}
|
|
177
|
+
writeFileSync(abs(target), `${result.stdout ?? ''}${result.stderr ?? ''}`);
|
|
178
|
+
return result.status ?? 1;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const idRc = identityRc(featureId, env.__runId);
|
|
182
|
+
const showRc = idRc === 0 ? read(['feature', 'show', featureId, '--json'], featureFile) : 1;
|
|
183
|
+
const listRc =
|
|
184
|
+
idRc === 0 && showRc === 0 && featureIdentityMatches(readFileSyncRaw(abs(featureFile)), featureId)
|
|
185
|
+
? read(['task', 'list', '--feature', featureId, '--json'], rosterFile)
|
|
186
|
+
: 1;
|
|
187
|
+
|
|
188
|
+
const fail = (message: string): FeatureDevPrecheckResult => {
|
|
189
|
+
process.stderr.write(`${message}\n`);
|
|
190
|
+
writeFileSync(abs(statusFile), 'FAIL\n');
|
|
191
|
+
return { status: 'FAIL', featureFile, rosterFile, tasksFile, statusFile, tasks: '' };
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
if (idRc !== 0 || showRc !== 0 || listRc !== 0) {
|
|
195
|
+
return fail(
|
|
196
|
+
`feature-dev precheck: missing featureId/runId, unknown feature '${featureId}', or unreadable roster (rc ${idRc}/${showRc}/${listRc}) — supply an existing planned feature via /sp:dev-plan or /sp:dev-idea; nothing was auto-created or re-planned`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const rosterRaw = readFileSyncRaw(abs(rosterFile));
|
|
201
|
+
if (!rosterIsNonEmptyArray(rosterRaw)) {
|
|
202
|
+
return fail(
|
|
203
|
+
`feature-dev precheck: roster at ${rosterFile} is malformed, not an array, or empty — plan the feature first via /sp:dev-plan; refusing to replan or run an empty batch`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
const rows = JSON.parse(rosterRaw) as RosterRow[];
|
|
207
|
+
if (!rosterContractHolds(rows)) {
|
|
208
|
+
return fail(
|
|
209
|
+
`feature-dev precheck: roster has empty/duplicate/mismatched WBS identities or unknown statuses at ${rosterFile} — repair the task corpus; refusing to batch a broken roster`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (hasBlockingStatus(rows)) {
|
|
213
|
+
return fail(
|
|
214
|
+
'feature-dev precheck: linked task(s) are backlog/wip/testing/blocked — refine or resume them through their own task pipelines before batching; refusing to launch overlapping work',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const tasks = freezeTodoList(rows);
|
|
219
|
+
writeFileSync(abs(tasksFile), tasks);
|
|
220
|
+
writeFileSync(abs(statusFile), 'PASS\n');
|
|
221
|
+
return { status: 'PASS', featureFile, rosterFile, tasksFile, statusFile, tasks };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const FEATURE_DEV_PRECHECK_USAGE =
|
|
225
|
+
'usage: feature-dev-precheck.ts (env: featureId, __runId, optional spurBin) — no subcommands';
|
|
226
|
+
|
|
227
|
+
export function main(argv: string[], env: FeatureDevPrecheckEnv = process.env): number {
|
|
228
|
+
if (argv.length > 0) {
|
|
229
|
+
process.stderr.write(`${FEATURE_DEV_PRECHECK_USAGE}\n`);
|
|
230
|
+
return 2;
|
|
231
|
+
}
|
|
232
|
+
runFeatureDevPrecheck(env);
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (import.meta.main) {
|
|
237
|
+
process.exit(main(process.argv.slice(2)));
|
|
238
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
|
|
4
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
5
|
+
|
|
6
|
+
// plugins/sp/scripts/feature-sync-bounded.ts
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { dirname } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
function classifySyncResult(result) {
|
|
14
|
+
if (result.proposal.gateBlocked === true) return 'blocked';
|
|
15
|
+
if (result.applied === true) return 'applied';
|
|
16
|
+
if (result.proposal.from !== result.proposal.to) return 'blocked';
|
|
17
|
+
return 'no-op';
|
|
18
|
+
}
|
|
19
|
+
function computeFingerprint(input) {
|
|
20
|
+
const material = [
|
|
21
|
+
input.featureContentHash,
|
|
22
|
+
...[...input.taskStatusVector].sort(),
|
|
23
|
+
...[...input.verdictMtimeVector].sort(),
|
|
24
|
+
].join(`
|
|
25
|
+
`);
|
|
26
|
+
return createHash('sha256').update(material).digest('hex').slice(0, 32);
|
|
27
|
+
}
|
|
28
|
+
var blockedStateFile = (featureId, runDir) => `${runDir.replace(/\/$/, '')}/feature-sync-blocked-${featureId}.json`;
|
|
29
|
+
function serializeBlockedState(state) {
|
|
30
|
+
return `${JSON.stringify(state)}
|
|
31
|
+
`;
|
|
32
|
+
}
|
|
33
|
+
function parseBlockedState(raw) {
|
|
34
|
+
const trimmed = raw.trim();
|
|
35
|
+
if (trimmed.length === 0) return null;
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(trimmed);
|
|
38
|
+
if (
|
|
39
|
+
typeof parsed.featureId !== 'string' ||
|
|
40
|
+
typeof parsed.inputFingerprint !== 'string' ||
|
|
41
|
+
typeof parsed.proposal !== 'object' ||
|
|
42
|
+
parsed.proposal === null
|
|
43
|
+
) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return parsed;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function shouldSuppressBlocked(prior, currentFingerprint) {
|
|
52
|
+
if (!prior) return { suppress: false };
|
|
53
|
+
if (prior.inputFingerprint === currentFingerprint) {
|
|
54
|
+
return { suppress: true, replay: prior.result };
|
|
55
|
+
}
|
|
56
|
+
return { suppress: false };
|
|
57
|
+
}
|
|
58
|
+
function decideBoundedSync(prior, currentFingerprint) {
|
|
59
|
+
const decision = shouldSuppressBlocked(prior, currentFingerprint);
|
|
60
|
+
if (decision.suppress && decision.replay) {
|
|
61
|
+
return { kind: 'suppress', replay: decision.replay };
|
|
62
|
+
}
|
|
63
|
+
return { kind: 'invoke' };
|
|
64
|
+
}
|
|
65
|
+
function processSyncResult(result, currentFingerprint, persistedAt, wasSuppressed) {
|
|
66
|
+
const classification = classifySyncResult(result);
|
|
67
|
+
if (classification === 'blocked') {
|
|
68
|
+
return {
|
|
69
|
+
classification,
|
|
70
|
+
emit: result,
|
|
71
|
+
persist: {
|
|
72
|
+
featureId: result.proposal.featureId,
|
|
73
|
+
inputFingerprint: currentFingerprint,
|
|
74
|
+
proposal: result.proposal,
|
|
75
|
+
classification,
|
|
76
|
+
result,
|
|
77
|
+
persistedAt,
|
|
78
|
+
},
|
|
79
|
+
annotation: wasSuppressed
|
|
80
|
+
? `feature-sync-bounded: suppressed duplicate blocked sync for ${result.proposal.featureId} (inputs unchanged)`
|
|
81
|
+
: `feature-sync-bounded: blocked proposal for ${result.proposal.featureId} — ${result.proposal.reason}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
classification,
|
|
86
|
+
emit: result,
|
|
87
|
+
annotation: '',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
var BOUNDED_SYNC_CLI_USAGE = `Usage:
|
|
91
|
+
bun plugins/sp/scripts/feature-sync-bounded.ts <feature-id> \\
|
|
92
|
+
--spur-bin <spur|bun apps/cli/src/index.ts> \\
|
|
93
|
+
[--run-dir .spur/run] [--json]
|
|
94
|
+
|
|
95
|
+
Wraps 'spur feature sync <id> --json' with bounded retry-suppression: an identical
|
|
96
|
+
blocked proposal is reported once and suppressed until feature file content, linked
|
|
97
|
+
task statuses, or verdict artifact mtimes change. Applied and no-op results pass
|
|
98
|
+
through unchanged.
|
|
99
|
+
|
|
100
|
+
Exit: 0 = sync handled (applied / no-op / suppressed-blocked / live-blocked).`;
|
|
101
|
+
function defaultSpurBin() {
|
|
102
|
+
if (process.env.SPUR_BIN) return process.env.SPUR_BIN;
|
|
103
|
+
const local = fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url));
|
|
104
|
+
if (existsSync(local)) return `bun ${local}`;
|
|
105
|
+
return 'spur';
|
|
106
|
+
}
|
|
107
|
+
function parseBoundedSyncCliArgs(argv) {
|
|
108
|
+
let featureId = '';
|
|
109
|
+
let spurBin = defaultSpurBin();
|
|
110
|
+
let runDir = '.spur/run';
|
|
111
|
+
let json = false;
|
|
112
|
+
let help = false;
|
|
113
|
+
for (let i = 0; i < argv.length; i++) {
|
|
114
|
+
const a = argv[i];
|
|
115
|
+
if (a === '--help' || a === '-h') help = true;
|
|
116
|
+
else if (a === '--json') json = true;
|
|
117
|
+
else if (a === '--spur-bin') spurBin = argv[++i] ?? spurBin;
|
|
118
|
+
else if (a === '--run-dir') runDir = argv[++i] ?? runDir;
|
|
119
|
+
else if (!a.startsWith('--') && featureId === '') featureId = a;
|
|
120
|
+
}
|
|
121
|
+
return { featureId, spurBin, runDir, json, help };
|
|
122
|
+
}
|
|
123
|
+
function runSpurJson(spurBin, args) {
|
|
124
|
+
const binParts = spurBin.split(/\s+/).filter(Boolean);
|
|
125
|
+
const cmd = binParts[0] ?? 'spur';
|
|
126
|
+
const cmdArgs = [...binParts.slice(1), ...args];
|
|
127
|
+
const r = spawnSync(cmd, cmdArgs, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
|
|
128
|
+
const decode = (b) => (typeof b === 'string' ? b : Buffer.from(b ?? []).toString('utf8'));
|
|
129
|
+
return {
|
|
130
|
+
stdout: typeof r.stdout === 'string' ? r.stdout : decode(r.stdout),
|
|
131
|
+
stderr: typeof r.stderr === 'string' ? r.stderr : decode(r.stderr),
|
|
132
|
+
exitCode: r.status ?? (r.error ? 1 : 0),
|
|
133
|
+
ok: (r.status ?? (r.error ? 1 : 0)) === 0,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function readFeatureContentHash(spurBin, featureId) {
|
|
137
|
+
const r = runSpurJson(spurBin, ['feature', 'show', featureId, '--json']);
|
|
138
|
+
if (!r.ok) return null;
|
|
139
|
+
try {
|
|
140
|
+
const parsed = JSON.parse(r.stdout);
|
|
141
|
+
if (typeof parsed.content !== 'string') return null;
|
|
142
|
+
return createHash('sha256').update(parsed.content).digest('hex');
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function readTaskStatusVector(spurBin, featureId) {
|
|
148
|
+
const r = runSpurJson(spurBin, ['task', 'list', '--feature', featureId, '--json']);
|
|
149
|
+
if (!r.ok) return null;
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(r.stdout);
|
|
152
|
+
return parsed
|
|
153
|
+
.filter((t) => typeof t.wbs === 'string' && typeof t.status === 'string')
|
|
154
|
+
.map((t) => `${t.wbs}:${t.status}`)
|
|
155
|
+
.sort();
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function readVerdictMtimeVector(runDir) {
|
|
161
|
+
const dir = runDir.replace(/\/$/, '');
|
|
162
|
+
let entries;
|
|
163
|
+
try {
|
|
164
|
+
entries = readdirSync(dir).filter((f) => f.endsWith('-verdict.json'));
|
|
165
|
+
} catch {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
const vector = [];
|
|
169
|
+
for (const entry of entries) {
|
|
170
|
+
try {
|
|
171
|
+
const mtime = statSync(`${dir}/${entry}`).mtimeMs;
|
|
172
|
+
vector.push(`${entry.replace('-verdict.json', '')}:${mtime}`);
|
|
173
|
+
} catch {}
|
|
174
|
+
}
|
|
175
|
+
return vector.sort();
|
|
176
|
+
}
|
|
177
|
+
function writeBlockedState(state, path) {
|
|
178
|
+
try {
|
|
179
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
180
|
+
writeFileSync(path, serializeBlockedState(state));
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
183
|
+
process.stderr.write(`feature-sync-bounded: could not persist blocked state to ${path} — ${reason}
|
|
184
|
+
`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function readBlockedState(path) {
|
|
188
|
+
try {
|
|
189
|
+
if (!existsSync(path)) return null;
|
|
190
|
+
const text = readFileSync(path, 'utf8');
|
|
191
|
+
return parseBlockedState(text);
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function runBoundedCli(argv) {
|
|
197
|
+
const args = parseBoundedSyncCliArgs(argv);
|
|
198
|
+
if (args.help) return { exitCode: 0, stdout: '', stderr: BOUNDED_SYNC_CLI_USAGE };
|
|
199
|
+
if (!args.featureId) return { exitCode: 1, stdout: '', stderr: BOUNDED_SYNC_CLI_USAGE };
|
|
200
|
+
const statePath = blockedStateFile(args.featureId, args.runDir);
|
|
201
|
+
const prior = readBlockedState(statePath);
|
|
202
|
+
const featureContentHash = readFeatureContentHash(args.spurBin, args.featureId);
|
|
203
|
+
const taskStatusVector = readTaskStatusVector(args.spurBin, args.featureId);
|
|
204
|
+
if (featureContentHash === null || taskStatusVector === null) {
|
|
205
|
+
return invokeLiveSync(args, statePath);
|
|
206
|
+
}
|
|
207
|
+
const verdictMtimeVector = readVerdictMtimeVector(args.runDir);
|
|
208
|
+
const currentFingerprint = computeFingerprint({
|
|
209
|
+
featureContentHash,
|
|
210
|
+
taskStatusVector,
|
|
211
|
+
verdictMtimeVector,
|
|
212
|
+
});
|
|
213
|
+
const decision = decideBoundedSync(prior, currentFingerprint);
|
|
214
|
+
if (decision.kind === 'suppress') {
|
|
215
|
+
const processed = processSyncResult(decision.replay, currentFingerprint, new Date().toISOString(), true);
|
|
216
|
+
emitResult(processed.emit, processed.annotation, args.json);
|
|
217
|
+
return { exitCode: 0, stdout: '', stderr: '' };
|
|
218
|
+
}
|
|
219
|
+
return invokeLiveSync(args, statePath, currentFingerprint);
|
|
220
|
+
}
|
|
221
|
+
function invokeLiveSync(args, statePath, fingerprint) {
|
|
222
|
+
const r = runSpurJson(args.spurBin, ['feature', 'sync', args.featureId, '--json']);
|
|
223
|
+
if (!r.ok) {
|
|
224
|
+
return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr };
|
|
225
|
+
}
|
|
226
|
+
let result;
|
|
227
|
+
try {
|
|
228
|
+
result = JSON.parse(r.stdout);
|
|
229
|
+
} catch {
|
|
230
|
+
return { exitCode: r.exitCode, stdout: r.stdout, stderr: '' };
|
|
231
|
+
}
|
|
232
|
+
if (!result || typeof result !== 'object' || !result.proposal) {
|
|
233
|
+
return {
|
|
234
|
+
exitCode: r.exitCode,
|
|
235
|
+
stdout: r.stdout,
|
|
236
|
+
stderr: 'unrecognized feature sync envelope: missing proposal',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const currentFingerprint =
|
|
240
|
+
fingerprint ??
|
|
241
|
+
computeFingerprint({
|
|
242
|
+
featureContentHash: readFeatureContentHash(args.spurBin, args.featureId) ?? '',
|
|
243
|
+
taskStatusVector: readTaskStatusVector(args.spurBin, args.featureId) ?? [],
|
|
244
|
+
verdictMtimeVector: readVerdictMtimeVector(args.runDir),
|
|
245
|
+
});
|
|
246
|
+
const processed = processSyncResult(result, currentFingerprint, new Date().toISOString(), false);
|
|
247
|
+
if (processed.persist) writeBlockedState(processed.persist, statePath);
|
|
248
|
+
emitResult(processed.emit, processed.annotation, args.json);
|
|
249
|
+
return { exitCode: 0, stdout: '', stderr: '' };
|
|
250
|
+
}
|
|
251
|
+
function emitResult(result, annotation, json) {
|
|
252
|
+
if (json) {
|
|
253
|
+
process.stdout.write(`${JSON.stringify(result)}
|
|
254
|
+
`);
|
|
255
|
+
} else {
|
|
256
|
+
process.stdout.write(`${result.proposal.featureId}: ${result.proposal.from} → ${result.proposal.to} (applied=${result.applied})
|
|
257
|
+
`);
|
|
258
|
+
}
|
|
259
|
+
if (annotation.length > 0)
|
|
260
|
+
process.stderr.write(`${annotation}
|
|
261
|
+
`);
|
|
262
|
+
}
|
|
263
|
+
{
|
|
264
|
+
const { exitCode, stdout, stderr } = runBoundedCli(process.argv.slice(2));
|
|
265
|
+
if (stdout) process.stdout.write(stdout);
|
|
266
|
+
if (stderr)
|
|
267
|
+
process.stderr.write(`${stderr}
|
|
268
|
+
`);
|
|
269
|
+
process.exit(exitCode);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export {
|
|
273
|
+
BOUNDED_SYNC_CLI_USAGE,
|
|
274
|
+
blockedStateFile,
|
|
275
|
+
classifySyncResult,
|
|
276
|
+
computeFingerprint,
|
|
277
|
+
decideBoundedSync,
|
|
278
|
+
defaultSpurBin,
|
|
279
|
+
parseBlockedState,
|
|
280
|
+
parseBoundedSyncCliArgs,
|
|
281
|
+
processSyncResult,
|
|
282
|
+
runBoundedCli,
|
|
283
|
+
serializeBlockedState,
|
|
284
|
+
shouldSuppressBlocked,
|
|
285
|
+
};
|