@gobing-ai/spur 0.3.50 → 0.3.51

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.
@@ -2,8 +2,9 @@ $schema: "@gobing-ai/spur/schemas/rule-file.schema.json"
2
2
  # Coverage gate — per-file line coverage meets threshold from Bun's lcov output.
3
3
  # Absorbed from ts-libs/.spur/rules/quality/coverage-gate.yaml, re-scoped to
4
4
  # Spur's monorepo layout:
5
- # - lcovPath kept at .coverage/lcov.info (Spur's `bun run test` writes
6
- # coverage there via --coverage-dir=.coverage)
5
+ # - lcovPath kept at .coverage/lcov.info (Spur's `bun run test:coverage`
6
+ # what `bun run check` and the `*:full` chains run — writes coverage there
7
+ # via --coverage-dir=.coverage; plain `bun run test` skips coverage)
7
8
  # - include expanded to apps/** + packages/** (Spur tests cover both;
8
9
  # ts-libs only covers packages/**)
9
10
  # - threshold kept at 90 matching bunfig.toml coverageThreshold
@@ -43,19 +43,21 @@ terminalStates:
43
43
  failureStates:
44
44
  - failed
45
45
  vars:
46
- mode: 'full'
47
- baseBranch: ''
48
- focus: ''
49
- noWait: 'false'
50
- waitTimeoutSec: '600'
51
- waitIntervalSec: '30'
52
- preReviewCmd: ''
53
- __runId: ''
46
+ mode: "full"
47
+ baseBranch: ""
48
+ focus: ""
49
+ noWait: "false"
50
+ waitTimeoutSec: "600"
51
+ waitIntervalSec: "30"
52
+ preReviewCmd: ""
53
+ __runId: ""
54
54
 
55
55
  states:
56
56
  - id: preflight
57
57
  description: >
58
- Soft probe: git/gh/repo checks (detached HEAD, dirty tree, gh auth, GitHub remote).
58
+ Soft probe: git/gh/repo checks (detached HEAD, dirty tree, gh auth, GitHub remote,
59
+ base-branch refusal — preflight refuses when the current branch IS the resolved base,
60
+ before any push can publish it).
59
61
  Writes PASS|FAIL to .spur/run/${vars.__runId}-pr-preflight.status; always exit 0.
60
62
  onEnter:
61
63
  - kind: shell
@@ -64,7 +66,7 @@ states:
64
66
  mkdir -p .spur/run &&
65
67
  STATUS_FILE=".spur/run/$__runId-pr-preflight.status" &&
66
68
  set +e &&
67
- bun "$(superskill script path sp pr-reviewing.ts)" preflight --json > ".spur/run/$__runId-pr-context.json";
69
+ bun "$(superskill script path sp pr-reviewing.ts)" preflight --base "$baseBranch" --json > ".spur/run/$__runId-pr-context.json";
68
70
  rc=$?; set -e &&
69
71
  if [ "$rc" -eq 0 ]; then printf 'PASS\n' > "$STATUS_FILE"; else printf 'FAIL\n' > "$STATUS_FILE"; fi &&
70
72
  exit 0
@@ -212,7 +214,7 @@ transitions:
212
214
  command: 'test "$(cat .spur/run/$__runId-pr-preflight.status 2>/dev/null)" = PASS'
213
215
  - from: preflight
214
216
  to: failed
215
- description: Preflight red (detached HEAD, dirty tree, gh auth, no GitHub remote) — stop before any publishing
217
+ description: Preflight red (detached HEAD, dirty tree, gh auth, no GitHub remote, or the current branch being the base branch) — stop before any publishing
216
218
  guard:
217
219
  kind: always
218
220
 
@@ -85,6 +85,12 @@ vars:
85
85
  # TRUSTED CONFIG ONLY — this string is executed via `sh -c` (see test/test-recheck). Never
86
86
  # interpolate untrusted operator/LLM input into qualityGateCmd (task 0436 SECUA residual).
87
87
  qualityGateCmd: "bun run format && bun run spur-check"
88
+ # Cheap red-detector run before the full gate on **recheck only**; empty ⇒ no probe
89
+ # (full gate every recheck — the pre-0587 behavior). A project overriding qualityGateCmd
90
+ # should override this too. TRUSTED CONFIG ONLY — executed via `sh -c` (same surface as
91
+ # qualityGateCmd). Invariant: `review` is only ever entered through a full green
92
+ # qualityGateCmd — only the full gate writes PASS to <wbs>-test-gate.status.
93
+ gateProbeCmd: "bun run lint"
88
94
  # Max /sp:dev-fixall attempts after a red quality-gate probe/recheck (bounded; no thrash).
89
95
  # Attempt counter: .spur/run/<wbs>-test-fix-attempt. Default 2 = two fixall hops before failed.
90
96
  qualityGateMaxFixAttempts: "2"
@@ -300,7 +306,7 @@ states:
300
306
  # `${vars.qualityGateCmd}` at `test` is the gate that actually decides.
301
307
  - kind: shell
302
308
  options:
303
- command: '$formatCmd ; exit 0'
309
+ command: "$formatCmd ; exit 0"
304
310
 
305
311
  # ── test hop (quality gate + bounded auto-fix) ─────────────────────────────
306
312
  # NOT /sp:dev-unit. That command (sp:code-testing) *extends/generates* tests toward
@@ -389,28 +395,43 @@ states:
389
395
  or the pipeline `failed` state (FAIL and attempts exhausted) — never a
390
396
  raw lifecycle abort that skips the terminal `failed` state.
391
397
  onEnter:
398
+ # 0587 R3: probe-then-full recheck. A red gateProbeCmd records FAIL and skips the full
399
+ # gate (the measured waste is re-running a 110–140s gate to learn the tree is still red);
400
+ # a green probe (or empty gateProbeCmd) falls through to the full-gate loop unchanged.
401
+ # Only the full gate writes PASS, so the `test-recheck → review` guard (reads PASS)
402
+ # still means a full green qualityGateCmd ran — invariant preserved by construction.
392
403
  - kind: shell
393
404
  options:
394
405
  command: >-
395
406
  mkdir -p .spur/run &&
396
407
  STATUS_FILE=".spur/run/$wbs-test-gate.status" &&
397
408
  LOG_FILE=".spur/run/$wbs-test-gate.log" &&
409
+ FINDINGS_FILE=".spur/run/$wbs-test-gate.findings" &&
398
410
  : > "$LOG_FILE" &&
399
- gate_attempt=1;
400
- while [ "$gate_attempt" -le 5 ]; do
401
- ATTEMPT_LOG="$LOG_FILE.attempt-$gate_attempt";
402
- sh -c "$qualityGateCmd" > "$ATTEMPT_LOG" 2>&1; gate_rc=$?;
403
- gate_locked=0;
404
- grep -q 'SQLiteError: database is locked' "$ATTEMPT_LOG" && gate_locked=1;
405
- cat "$ATTEMPT_LOG" >> "$LOG_FILE";
406
- rm -f "$ATTEMPT_LOG";
407
- if [ "$gate_rc" -eq 0 ] || [ "$gate_locked" -ne 1 ] || [ "$gate_attempt" -ge 5 ]; then break; fi;
408
- printf 'quality gate: database is locked; retrying (%s/5) in 10s\n' "$gate_attempt" | tee -a "$LOG_FILE";
409
- sleep 10;
410
- gate_attempt=$((gate_attempt + 1));
411
- done &&
411
+ probe_rc=0;
412
+ if [ -n "$gateProbeCmd" ]; then
413
+ sh -c "$gateProbeCmd" > "$LOG_FILE.probe" 2>&1; probe_rc=$?;
414
+ cat "$LOG_FILE.probe" >> "$LOG_FILE";
415
+ rm -f "$LOG_FILE.probe";
416
+ fi;
417
+ if [ "$probe_rc" -ne 0 ]; then
418
+ gate_rc=$probe_rc;
419
+ else
420
+ gate_attempt=1;
421
+ while [ "$gate_attempt" -le 5 ]; do
422
+ ATTEMPT_LOG="$LOG_FILE.attempt-$gate_attempt";
423
+ sh -c "$qualityGateCmd" > "$ATTEMPT_LOG" 2>&1; gate_rc=$?;
424
+ gate_locked=0;
425
+ grep -q 'SQLiteError: database is locked' "$ATTEMPT_LOG" && gate_locked=1;
426
+ cat "$ATTEMPT_LOG" >> "$LOG_FILE";
427
+ rm -f "$ATTEMPT_LOG";
428
+ if [ "$gate_rc" -eq 0 ] || [ "$gate_locked" -ne 1 ] || [ "$gate_attempt" -ge 5 ]; then break; fi;
429
+ printf 'quality gate: database is locked; retrying (%s/5) in 10s\n' "$gate_attempt" | tee -a "$LOG_FILE";
430
+ sleep 10;
431
+ gate_attempt=$((gate_attempt + 1));
432
+ done;
433
+ fi &&
412
434
  cat "$LOG_FILE" &&
413
- FINDINGS_FILE=".spur/run/$wbs-test-gate.findings" &&
414
435
  set +e; grep -oE '[A-Za-z0-9_./-]+\.[A-Za-z]+:[0-9]+' "$LOG_FILE" | sort -u | head -20 | tr '\n' ' ' > "$FINDINGS_FILE"; set -e &&
415
436
  if [ "$gate_rc" -eq 0 ]; then
416
437
  printf 'PASS\n' > "$STATUS_FILE";
@@ -709,4 +730,4 @@ transitions:
709
730
  guard:
710
731
  kind: shell
711
732
  options:
712
- command: '! $spurBin task check $wbs'
733
+ command: "! $spurBin task check $wbs"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/spur",
3
- "version": "0.3.50",
3
+ "version": "0.3.51",
4
4
  "description": "Spur CLI — local-first harness for mainstream coding agents: constraint checking, workflow orchestration, agent health, and history analytics. Bun-native; exposes the `spur` command.",
5
5
  "keywords": [
6
6
  "spur",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.3.50",
3
+ "version": "0.3.51",
4
4
  "description": "Spur — a local-first harness engineering toolkit that wraps mainstream coding agents with constraint checking, workflow orchestration, and history analytics.",
5
5
  "extensions": {
6
6
  "pi": ["./hooks/pi/guard-extension.ts"]
@@ -88,6 +88,12 @@ interface PreflightContext {
88
88
  defaultBranch: string;
89
89
  }
90
90
 
91
+ interface Upstream {
92
+ ref: string;
93
+ ahead: number;
94
+ behind: number;
95
+ }
96
+
91
97
  interface Finding {
92
98
  kind: 'review' | 'inline' | 'comment';
93
99
  severity: string;
@@ -433,6 +439,22 @@ function preflightContext(): PreflightContext {
433
439
  };
434
440
  }
435
441
 
442
+ /** Upstream divergence, non-fatal: a missing upstream is a normal state, not an error. */
443
+ function resolveUpstream(): Upstream | null {
444
+ const refRes = run(['git', 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
445
+ if (refRes.code !== 0) return null;
446
+ const ref = refRes.stdout.trim();
447
+ const count = (res: CmdResult): number => {
448
+ const n = Number(res.stdout.trim());
449
+ return res.code === 0 && Number.isFinite(n) ? n : 0;
450
+ };
451
+ return {
452
+ ref,
453
+ ahead: count(run(['git', 'rev-list', '--count', '@{u}..HEAD'])),
454
+ behind: count(run(['git', 'rev-list', '--count', 'HEAD..@{u}'])),
455
+ };
456
+ }
457
+
436
458
  function viewPr(): GhPr | null {
437
459
  const res = run([
438
460
  'gh',
@@ -507,15 +529,27 @@ function cmdPreflight(args: ParsedArgs): void {
507
529
  2,
508
530
  );
509
531
  }
532
+ const base = (args.flags.get('--base') ?? '').trim() || ctx.defaultBranch;
533
+ const upstream = resolveUpstream();
534
+ if (ctx.branch === base) {
535
+ writeStatus(args, 'FAIL');
536
+ fail(
537
+ args,
538
+ `current branch is the base branch (${base}) — a PR reviews a feature branch against it; ` +
539
+ 'check out a feature branch (nothing on the base branch is reviewable)',
540
+ 2,
541
+ );
542
+ }
510
543
  writeStatus(args, 'PASS');
511
544
  emit(
512
545
  args,
513
- { ok: true, ...ctx },
546
+ { ok: true, ...ctx, upstream },
514
547
  [
515
548
  `Repository: ${ctx.nameWithOwner}`,
516
549
  `Branch: ${ctx.branch}`,
517
550
  `HEAD: ${ctx.shortHead}`,
518
551
  `Default: ${ctx.defaultBranch}`,
552
+ `Upstream: ${upstream ? `${upstream.ref} (ahead ${upstream.ahead}, behind ${upstream.behind})` : `none (publishing would create origin/${ctx.branch})`}`,
519
553
  'Local: clean',
520
554
  ].join('\n'),
521
555
  );
@@ -135,9 +135,12 @@ bun "$(superskill script path sp pr-reviewing.ts)" <subcommand> [flags]
135
135
  Installed targets resolve the staged TypeScript source and execute it with Bun, matching the rest
136
136
  of `plugins/sp/scripts`.
137
137
 
138
- 1. **Preflight** — `<script> preflight --json`. Hard-fails on a
139
- detached HEAD, missing `gh` auth, no GitHub remote, or a dirty tree. On a dirty tree, triage
140
- with the user (commit/stash/exclude) before continuing the workflow refuses to guess.
138
+ 1. **Preflight** — `<script> preflight --base "$base" --json`. Hard-fails on a
139
+ detached HEAD, missing `gh` auth, no GitHub remote, a dirty tree, or the current branch
140
+ being the base branch (a PR reviews a feature branch against the base; nothing on the
141
+ base branch is reviewable, and the guard runs before any push can publish it). On a
142
+ dirty tree, triage with the user (commit/stash/exclude) before continuing — the
143
+ workflow refuses to guess.
141
144
  2. **Hygiene** — `<script> hygiene --base "$base" --json`. `BLOCK` (secrets, `.env`, conflict markers,
142
145
  private keys) stops the run — never submit a tainted diff. `WARN` (debug residue) rides along
143
146
  into the report. This is a submission sanity check, not a second local review.
package/spur.js CHANGED
@@ -67152,6 +67152,10 @@ var init_finding_codes2 = __esm(() => {
67152
67152
  });
67153
67153
 
67154
67154
  // ../../packages/app/src/services/planning-check-base.ts
67155
+ function key(e) {
67156
+ return `${e.kind}:${e.id}:${e.code}`;
67157
+ }
67158
+
67155
67159
  class PlanningCheckService {
67156
67160
  fs;
67157
67161
  matrix;
@@ -67244,7 +67248,7 @@ class PlanningCheckService {
67244
67248
  }
67245
67249
  }
67246
67250
  }
67247
- summarizeWithStatus(status, findings, strict, overrides) {
67251
+ summarizeWithStatus(status, findings, strict, overrides, accepted, id) {
67248
67252
  const effectiveFindings = [];
67249
67253
  for (const f of findings) {
67250
67254
  const override = overrides?.[f.code];
@@ -67257,6 +67261,13 @@ class PlanningCheckService {
67257
67261
  if (strict && f.severity === "warning") {
67258
67262
  f.severity = "error";
67259
67263
  }
67264
+ if (accepted && id) {
67265
+ const k = key({ kind: this.docKind, id, code: f.code });
67266
+ const acceptedSev = accepted.get(k);
67267
+ if (acceptedSev !== undefined && acceptedSev === f.severity) {
67268
+ continue;
67269
+ }
67270
+ }
67260
67271
  effectiveFindings.push(f);
67261
67272
  }
67262
67273
  let hasError = false;
@@ -67877,7 +67888,7 @@ class TaskLocator {
67877
67888
  return;
67878
67889
  }
67879
67890
  const folderKeys = source.foldersConfig ? Object.keys(source.foldersConfig.folders) : [];
67880
- this.dirs = [...new Set([source.tasksDir, ...folderKeys.map((key) => source.fs.resolve(key))])];
67891
+ this.dirs = [...new Set([source.tasksDir, ...folderKeys.map((key2) => source.fs.resolve(key2))])];
67881
67892
  }
67882
67893
  static forSingleDir(fs3, dir) {
67883
67894
  return new TaskLocator({ fs: fs3, tasksDir: dir });
@@ -68171,7 +68182,10 @@ var init_task_check = __esm(() => {
68171
68182
  const findings = [];
68172
68183
  const doc2 = this.runL1(raw, wbs, findings);
68173
68184
  if (doc2 === null) {
68174
- return { wbs, ...this.summarizeWithStatus("", findings, strict, options?.severityOverrides) };
68185
+ return {
68186
+ wbs,
68187
+ ...this.summarizeWithStatus("", findings, strict, options?.severityOverrides, options?.accepted, wbs)
68188
+ };
68175
68189
  }
68176
68190
  const fm = doc2.frontmatterData ?? {};
68177
68191
  const status = fm.status ?? "backlog";
@@ -68186,7 +68200,10 @@ var init_task_check = __esm(() => {
68186
68200
  if (status !== "done" && status !== "cancelled") {
68187
68201
  await this.runL4Readiness(doc2, fm, wbs, status, findings, tasksDir);
68188
68202
  }
68189
- return { wbs, ...this.summarizeWithStatus(status, findings, strict, options?.severityOverrides) };
68203
+ return {
68204
+ wbs,
68205
+ ...this.summarizeWithStatus(status, findings, strict, options?.severityOverrides, options?.accepted, wbs)
68206
+ };
68190
68207
  }
68191
68208
  runL3(doc2, entry, status, findings) {
68192
68209
  const reqBodyRaw = doc2.getSection("Requirements");
@@ -68883,9 +68900,6 @@ import { basename as basename4, dirname as dirname10, join as join11, relative,
68883
68900
  function baselineSeverity(e) {
68884
68901
  return e.severity ?? "error";
68885
68902
  }
68886
- function key(e) {
68887
- return `${e.kind}:${e.id}:${e.code}`;
68888
- }
68889
68903
  function resolveProjectRoot(cwd) {
68890
68904
  const fs3 = createNodeFileSystem3(cwd);
68891
68905
  let current = resolve3(cwd);
@@ -69253,12 +69267,29 @@ async function runCorpusCheck(cwd, since) {
69253
69267
  }
69254
69268
  return result;
69255
69269
  }
69270
+ async function loadAcceptedFindings(cwd) {
69271
+ const projectRoot = resolveProjectRoot(cwd);
69272
+ const baselineFile = join11(projectRoot, "config", "corpus-baseline.json");
69273
+ const accepted = new Map;
69274
+ try {
69275
+ if (await Bun.file(baselineFile).exists()) {
69276
+ const baseline = await Bun.file(baselineFile).json();
69277
+ if (Array.isArray(baseline?.entries)) {
69278
+ for (const e of baseline.entries) {
69279
+ accepted.set(key(e), baselineSeverity(e));
69280
+ }
69281
+ }
69282
+ }
69283
+ } catch {}
69284
+ return accepted;
69285
+ }
69256
69286
  var FOG_HEADING, OUT_OF_SCOPE_HEADING, FEATURE_ID, DEFAULT_BRANCHES;
69257
69287
  var init_corpus_check = __esm(() => {
69258
69288
  init_loader();
69259
69289
  init_dist5();
69260
69290
  init_dist2();
69261
69291
  init_feature_check();
69292
+ init_planning_check_base();
69262
69293
  init_task_check();
69263
69294
  init_task_locator();
69264
69295
  FOG_HEADING = /^###\s+Not yet specified\b/;
@@ -79461,6 +79492,7 @@ __export(exports_src2, {
79461
79492
  parseEtimeToSeconds: () => parseEtimeToSeconds,
79462
79493
  normalizeSystemEventPayload: () => normalizeSystemEventPayload,
79463
79494
  normalizeProjectPath: () => normalizeProjectPath,
79495
+ loadAcceptedFindings: () => loadAcceptedFindings,
79464
79496
  isSystemEventEnvelopeV2: () => isSystemEventEnvelopeV2,
79465
79497
  isPortLive: () => isPortLive,
79466
79498
  isPortAvailable: () => isPortAvailable,
@@ -87871,7 +87903,7 @@ import { createRequire } from "module";
87871
87903
  var CLI_CONFIG = {
87872
87904
  binaryName: "spur",
87873
87905
  binaryLabel: "spur",
87874
- binaryVersion: "0.3.50",
87906
+ binaryVersion: "0.3.51",
87875
87907
  configDir: ".spur",
87876
87908
  configFile: ".spur/config.yaml",
87877
87909
  databaseFile: ".spur/spur.db"
@@ -100429,6 +100461,7 @@ ${result.content}`);
100429
100461
  }
100430
100462
  const svc = await makeCheckService(context4);
100431
100463
  const planningFolders = await resolvePlanningFolders(context4.fs);
100464
+ const accepted = await loadAcceptedFindings(context4.cwd);
100432
100465
  const activeFolder = planningFolders.foldersConfig.active_folder;
100433
100466
  const tasksDir = context4.fs.resolve(options.folder ?? activeFolder);
100434
100467
  const printResult = (result) => {
@@ -100454,7 +100487,8 @@ ${result.wbs} (${result.status}): ${result.pass ? "PASS" : "FAIL"}`);
100454
100487
  } else {
100455
100488
  const result = await svc.check(hit.filePath, wbs, {
100456
100489
  strict,
100457
- severityOverrides: planningFolders.severityOverrides
100490
+ severityOverrides: planningFolders.severityOverrides,
100491
+ accepted
100458
100492
  });
100459
100493
  results.push(result);
100460
100494
  printResult(result);
@@ -100471,7 +100505,8 @@ ${result.wbs} (${result.status}): ${result.pass ? "PASS" : "FAIL"}`);
100471
100505
  }
100472
100506
  const result = await svc.check(`${tasksDir}/${fileName}`, w, {
100473
100507
  strict,
100474
- severityOverrides: planningFolders.severityOverrides
100508
+ severityOverrides: planningFolders.severityOverrides,
100509
+ accepted
100475
100510
  });
100476
100511
  results.push(result);
100477
100512
  printResult(result);
@@ -100673,14 +100708,20 @@ async function makeCheckService(context4) {
100673
100708
  return new TaskCheckService(context4.fs, await loadSectionMatrix(context4.cwd), await makeTaskLocator(context4));
100674
100709
  }
100675
100710
  async function runDoneGateCheck(context4, wbs, folderOverride) {
100676
- const foldersConfig = (await resolvePlanningFolders(context4.fs)).foldersConfig;
100711
+ const planningFolders = await resolvePlanningFolders(context4.fs);
100712
+ const foldersConfig = planningFolders.foldersConfig;
100677
100713
  const tasksDir = folderOverride ?? context4.fs.resolve(foldersConfig.active_folder);
100678
100714
  const hit = await new TaskLocator({ fs: context4.fs, tasksDir, foldersConfig }).findByWbs(wbs);
100679
100715
  if (!hit) {
100680
100716
  return false;
100681
100717
  }
100682
100718
  const svc = new TaskCheckService(context4.fs, await loadSectionMatrix(context4.cwd), await makeTaskLocator(context4));
100683
- const result = await svc.check(hit.filePath, wbs, { strict: false });
100719
+ const accepted = await loadAcceptedFindings(context4.cwd);
100720
+ const result = await svc.check(hit.filePath, wbs, {
100721
+ strict: false,
100722
+ severityOverrides: planningFolders.severityOverrides,
100723
+ accepted
100724
+ });
100684
100725
  return result.pass;
100685
100726
  }
100686
100727
  var sectionMatrixCache = new Map;