@gobing-ai/spur 0.3.73 → 0.3.75

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 (93) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.example.yaml +15 -4
  3. package/config/pipeline-budgets.json +4 -4
  4. package/config/rules/strict/runtime-boundaries.yaml +1 -1
  5. package/config/templates/AGENTS.md +5 -0
  6. package/config/templates/docs/99_PROJECT_CONSTITUTION.md +2 -0
  7. package/config/workflows/basic.yaml +5 -2
  8. package/config/workflows/docs-pipeline.yaml +117 -55
  9. package/config/workflows/feature-dev.yaml +185 -121
  10. package/config/workflows/feature-lifecycle.yaml +1 -0
  11. package/config/workflows/history-anatomy.yaml +8 -4
  12. package/config/workflows/idea-pipeline.yaml +123 -43
  13. package/config/workflows/pr-review.yaml +31 -8
  14. package/config/workflows/task-lifecycle.yaml +1 -0
  15. package/config/workflows/task-pipeline.yaml +965 -905
  16. package/config/workflows/wayfinder-resolution.yaml +124 -90
  17. package/config/workflows/wrapup-pipeline.yaml +304 -107
  18. package/package.json +1 -1
  19. package/plugins/sp/agents/expert-spur.md +7 -4
  20. package/plugins/sp/commands/dev-idea.md +1 -1
  21. package/plugins/sp/plugin.json +1 -1
  22. package/plugins/sp/scripts/history-anatomy-cache.mjs +7 -1
  23. package/plugins/sp/scripts/history-anatomy-cache.ts +11 -2
  24. package/plugins/sp/scripts/pr-reviewing.mjs +5 -1
  25. package/plugins/sp/scripts/pr-reviewing.ts +7 -1
  26. package/plugins/sp/scripts/transition-shim-check.ts +6 -6
  27. package/plugins/sp/skills/pr-reviewing/SKILL.md +4 -1
  28. package/plugins/sp/skills/spur-cli/references/self.md +17 -3
  29. package/plugins/sp/skills/spur-cli/references/tasks.md +4 -2
  30. package/plugins/sp/skills/spur-cli/references/workflows/workflow-fit-and-tuning.md +5 -5
  31. package/plugins/sp/skills/spur-cli/references/workflows.md +4 -4
  32. package/plugins/sp/skills/spur-dev/SKILL.md +17 -0
  33. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +23 -8
  34. package/plugins/sp/skills/spur-dev/references/dev-operations.md +1 -1
  35. package/plugins/sp/skills/spur-dev/references/execution-batch.md +3 -3
  36. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +5 -3
  37. package/plugins/sp/skills/wayfinder/SKILL.md +1 -1
  38. package/spur.js +2999 -1010
  39. package/web/_astro/{BoardApp.BYCNkMOn.js → BoardApp.BOW1815F.js} +101 -100
  40. package/web/_astro/BoardApp.C4zimv1U.js +1 -0
  41. package/web/_astro/{TaskDetail.CgUreSP2.js → TaskDetail.CX6C7dSJ.js} +1 -1
  42. package/web/_astro/{arc.BySSh34M.js → arc.CwSvH1ji.js} +1 -1
  43. package/web/_astro/{architectureDiagram-3BPJPVTR.DM46TS_h.js → architectureDiagram-3BPJPVTR.Dxne1RvP.js} +1 -1
  44. package/web/_astro/{blockDiagram-GPEHLZMM.tZhvNUHA.js → blockDiagram-GPEHLZMM.D9Fb14rC.js} +1 -1
  45. package/web/_astro/{c4Diagram-AAUBKEIU.PT4Or4Nf.js → c4Diagram-AAUBKEIU.DmvWSFvv.js} +1 -1
  46. package/web/_astro/channel.CQHdDVp9.js +1 -0
  47. package/web/_astro/{chunk-2J33WTMH.J9r0_Bbe.js → chunk-2J33WTMH.BCCktIrE.js} +1 -1
  48. package/web/_astro/{chunk-4BX2VUAB.hzyeIvhR.js → chunk-4BX2VUAB.BzCk9q27.js} +1 -1
  49. package/web/_astro/{chunk-55IACEB6.B0rO7qVh.js → chunk-55IACEB6.CZXVgTk4.js} +1 -1
  50. package/web/_astro/{chunk-727SXJPM.wE_Uk5D4.js → chunk-727SXJPM.CJL8UIpX.js} +1 -1
  51. package/web/_astro/{chunk-AQP2D5EJ.DqEEjQw7.js → chunk-AQP2D5EJ.Dz8MDEdw.js} +1 -1
  52. package/web/_astro/{chunk-FMBD7UC4.CDoD9sBX.js → chunk-FMBD7UC4.CkQeYYUw.js} +1 -1
  53. package/web/_astro/{chunk-ND2GUHAM.CtX5nF9P.js → chunk-ND2GUHAM.KiC1QgzH.js} +1 -1
  54. package/web/_astro/{chunk-QZHKN3VN.CK_EwfaT.js → chunk-QZHKN3VN.AJ08mw2e.js} +1 -1
  55. package/web/_astro/{classDiagram-4FO5ZUOK.DLt5a8Lh.js → classDiagram-4FO5ZUOK.DRWRDKzK.js} +1 -1
  56. package/web/_astro/{classDiagram-v2-Q7XG4LA2.DLt5a8Lh.js → classDiagram-v2-Q7XG4LA2.DRWRDKzK.js} +1 -1
  57. package/web/_astro/{cose-bilkent-S5V4N54A.CMCWP49h.js → cose-bilkent-S5V4N54A.CavydfLP.js} +1 -1
  58. package/web/_astro/{cynefin-OW5HDTMX.HyXw_vdS.js → cynefin-OW5HDTMX.D_7o_a0B.js} +1 -1
  59. package/web/_astro/{dagre-BM42HDAG.BTuAzh01.js → dagre-BM42HDAG.LWk2dKgg.js} +1 -1
  60. package/web/_astro/{diagram-2AECGRRQ.D9dr9wfT.js → diagram-2AECGRRQ.82lbq6aB.js} +1 -1
  61. package/web/_astro/{diagram-5GNKFQAL.C4Rot0hj.js → diagram-5GNKFQAL.hyNL1pwY.js} +1 -1
  62. package/web/_astro/{diagram-KO2AKTUF.B_TK5uWC.js → diagram-KO2AKTUF.BaLqVf-b.js} +1 -1
  63. package/web/_astro/{diagram-LMA3HP47.JkXKK7CO.js → diagram-LMA3HP47.DQJxj0oz.js} +1 -1
  64. package/web/_astro/{diagram-OG6HWLK6.BzMN8Bd6.js → diagram-OG6HWLK6.DhlL8cG2.js} +1 -1
  65. package/web/_astro/{erDiagram-TEJ5UH35.DVZaWGUd.js → erDiagram-TEJ5UH35.HoTnXwkF.js} +1 -1
  66. package/web/_astro/{flowDiagram-I6XJVG4X.rjEiWUfR.js → flowDiagram-I6XJVG4X.DkFKlZIZ.js} +1 -1
  67. package/web/_astro/{ganttDiagram-6RSMTGT7.C_EgAarK.js → ganttDiagram-6RSMTGT7.9qPfOTDb.js} +1 -1
  68. package/web/_astro/{gitGraphDiagram-PVQCEYII.B-QQSDsK.js → gitGraphDiagram-PVQCEYII.B4G18Dwc.js} +1 -1
  69. package/web/_astro/index.Dcr_8fiK.css +1 -0
  70. package/web/_astro/{infoDiagram-5YYISTIA.DlWesz7T.js → infoDiagram-5YYISTIA.e3KJkXAM.js} +1 -1
  71. package/web/_astro/{ishikawaDiagram-YF4QCWOH.BUMZOawi.js → ishikawaDiagram-YF4QCWOH.CY3yddhD.js} +1 -1
  72. package/web/_astro/{journeyDiagram-JHISSGLW.CWfkxfjY.js → journeyDiagram-JHISSGLW.DaAKIO1t.js} +1 -1
  73. package/web/_astro/{kanban-definition-UN3LZRKU.B-YpMwXf.js → kanban-definition-UN3LZRKU.DGkDddtc.js} +1 -1
  74. package/web/_astro/{linear.D7uqzENp.js → linear.DnDPcpd1.js} +1 -1
  75. package/web/_astro/{mermaid.core.CxrNppBD.js → mermaid.core.1uBmxa9t.js} +4 -4
  76. package/web/_astro/{mindmap-definition-RKZ34NQL.B4Qe7cM2.js → mindmap-definition-RKZ34NQL.XFJVayxw.js} +1 -1
  77. package/web/_astro/{pieDiagram-4H26LBE5.Ds-5j2ro.js → pieDiagram-4H26LBE5.BXm2OgHh.js} +1 -1
  78. package/web/_astro/{quadrantDiagram-W4KKPZXB.tBd38uNC.js → quadrantDiagram-W4KKPZXB.DgB2p5fc.js} +1 -1
  79. package/web/_astro/{requirementDiagram-4Y6WPE33.sFENkWl3.js → requirementDiagram-4Y6WPE33.jBR39bnS.js} +1 -1
  80. package/web/_astro/{sankeyDiagram-5OEKKPKP.BeB-Hk7C.js → sankeyDiagram-5OEKKPKP.N3u9OhE3.js} +1 -1
  81. package/web/_astro/{sequenceDiagram-3UESZ5HK.DnTeaSpx.js → sequenceDiagram-3UESZ5HK.CZhqZXNL.js} +1 -1
  82. package/web/_astro/{stateDiagram-AJRCARHV.B-8Jt5EJ.js → stateDiagram-AJRCARHV.DlNu1VEa.js} +1 -1
  83. package/web/_astro/{stateDiagram-v2-BHNVJYJU.Br7xoqMW.js → stateDiagram-v2-BHNVJYJU.BEqi8NQV.js} +1 -1
  84. package/web/_astro/{timeline-definition-PNZ67QCA.C-3WdOyi.js → timeline-definition-PNZ67QCA.BPcexclc.js} +1 -1
  85. package/web/_astro/{vennDiagram-CIIHVFJN.DCIs7Lc6.js → vennDiagram-CIIHVFJN.BZalxKGQ.js} +1 -1
  86. package/web/_astro/{wardleyDiagram-YWT4CUSO.rGAL-bbz.js → wardleyDiagram-YWT4CUSO.BqOljod9.js} +1 -1
  87. package/web/_astro/{xychartDiagram-2RQKCTM6.hkfQKiRl.js → xychartDiagram-2RQKCTM6.tkO4ppN5.js} +1 -1
  88. package/web/index.html +2 -2
  89. package/config/corpus-baseline.json +0 -1805
  90. package/config/workflow-composition-baseline.json +0 -615
  91. package/web/_astro/BoardApp.E12MFjOS.js +0 -1
  92. package/web/_astro/channel.5cYKr5cs.js +0 -1
  93. package/web/_astro/index.B5MTfe7k.css +0 -1
@@ -17,7 +17,10 @@
17
17
  # -> feature-transition (conditional: if vars.feature set)
18
18
  # -> branch-cleanup (conditional: if vars.merge=true)
19
19
  # -> done
20
- # (task-resolve with empty list short-circuits to `skipped`).
20
+ # task-resolve with a VALIDATED empty list short-circuits to `skipped`;
21
+ # invalid wrap input, an unresolved/non-completed task, a failed metrics
22
+ # capture, a failed required sync, or a failed feature gate routes to
23
+ # `failed` (0770) instead of being absorbed by an always-defensive skip.
21
24
  #
22
25
  # Vars (passed as a JSON object via `--vars`):
23
26
  # tasks — JSON array of WBS strings, passed as a JSON-encoded STRING value
@@ -39,21 +42,46 @@
39
42
  # a
40
43
  # single model query that both repairs doc drift (sp:doc-evolve) and captures
41
44
  # working learnings (task 0607 R2 — one query answers both; count 2 -> 1).
42
- # - feature-transition is a soft shell (always exit 0) so a blocked feature sync
43
- # does not abort wrap-up after learnings/metrics already landed.
45
+ # - feature-transition is a soft shell (always exit 0); a failed required sync
46
+ # or feature gate records FAIL and routes to `failed` instead of claiming
47
+ # success (0770).
48
+ # - 0770 truthful outcomes: vars.tasks is parsed and validated ONCE at
49
+ # task-resolve (JSON array of non-empty WBS strings, every member resolved
50
+ # via `task show` to a completed status); siblings consume the normalized
51
+ # run-scoped artifact and never re-parse raw input (malformed input can no
52
+ # longer be silently re-interpreted as an empty list). All captures are
53
+ # run-scoped under .spur/run/<runId>-wrapup-*; a lookup failure is
54
+ # recorded, never silently omitted as success.
55
+ # - 0783 validated consumers (audit 0781 F-04): validation accepts only
56
+ # canonical four-digit WBS strings (whitespace is rejected, not trimmed);
57
+ # the route writer, every guard, model prompt and operator note after
58
+ # resolution read the run-scoped capture, never raw vars.tasks, and a
59
+ # missing/corrupted capture or status refuses progression. Metrics
60
+ # revalidate the capture, require well-shaped task lookups, serialize rows
61
+ # with jq (never interpolated printf), and write PASS only after every
62
+ # required append succeeds. Required feature sync succeeds only for a
63
+ # valid matching proposal with no gateBlocked/requiresConfirm condition
64
+ # whose target status is freshly observed; applied:false is a successful
65
+ # no-op only when from == to and that status is observed — nonzero,
66
+ # malformed, partial, blocked or unreadable outcomes fail explicitly, and
67
+ # an affected-feature check can never convert a failed sync into success.
44
68
  # - branch-cleanup HITL is exhaustive (yes/no/cancel → done; missing answer
45
- # defense).
69
+ # defense) and consent-only: it records the decision, performs no git op.
46
70
  # - Wrap-up never mutates task status (consumes completed work only).
47
71
 
48
72
  "$schema": "@gobing-ai/spur/schemas/state-machine-workflow.schema.json"
49
73
  kind: state-machine
50
74
  name: wrapup-pipeline
75
+ version: "3"
51
76
  description: "Post-execution wrap-up: doc-sync (doc drift + learning capture), metrics, feature-transition, branch-cleanup"
52
77
  iterationBound: 10
53
78
  initialState: start
54
79
  terminalStates:
55
80
  - done
56
81
  - skipped
82
+ - failed
83
+ failureStates:
84
+ - failed
57
85
  vars:
58
86
  tasks: "[]"
59
87
  feature: ""
@@ -64,22 +92,11 @@ vars:
64
92
  __runId: ""
65
93
  mode: ""
66
94
  stepTimeoutMs: "1800000"
67
- # Corpus-aware quality gate for the feature-transition hop (R1, task 0625).
68
- # The per-task gate (`spur-check`) deliberately excludes corpus-check the
69
- # only
70
- # sweep that observes feature-level findings — so a transition that arms one
71
- # would go green. This gate is therefore the CORPUS SWEEP ALONE (~29 s), not
72
- # `spur-check-new`: every task in the feature already paid a full `spur-check`
73
- # in its own pipeline, and the states that run before this hop (`doc-sync`,
74
- # `metrics-record`) write only markdown and task sections, which Biome skips
75
- # (`ignoreUnknown: true`). Re-running the ~105 s per-task gate here
76
- # re-verifies
77
- # unchanged code and measures nothing new. Soft by design: the gate reports
78
- # PASS/FAIL and lets the operator decide; it never hard-fails the wrap-up
79
- # shell.
95
+ # Recheck the affected feature after sync (D61 / ADR-108), not the corpus
96
+ # or full code suite. Explicit caller overrides remain supported.
80
97
  # TRUSTED CONFIG ONLY — this string is executed via `sh -c` (same surface as
81
98
  # task-pipeline's qualityGateCmd). Never interpolate untrusted input into it.
82
- featureGateCmd: "bun run corpus-check"
99
+ featureGateCmd: '$spurBin feature check "$feature"'
83
100
  __hitlAnswer: ""
84
101
 
85
102
  states:
@@ -94,27 +111,95 @@ states:
94
111
 
95
112
  - id: task-resolve
96
113
  description: >
97
- Validate the task list and evaluate closed proportional routing (0758 R1-R4).
98
- Empty list -> skipped; tasks > 0 && mode == fast -> fast-path (metrics-record,
114
+ Validate the task list ONCE and evaluate closed proportional routing (0758
115
+ R1-R4). Validation (0770 + 0783): vars.tasks must be a JSON array of
116
+ canonical four-digit WBS strings — non-arrays, non-strings, whitespace or
117
+ other shapes are rejected, not trimmed — deduplicated in first-seen
118
+ order, with __runId present, and every member must resolve via `spur task
119
+ show` to a completed status (done/cancelled); failed or malformed
120
+ lookups fail. Invalid input or an unresolved/non-completed task records
121
+ FAIL (reason into the run-scoped reason file) and routes to `failed`;
122
+ the route writer and every guard, prompt and note after resolution read
123
+ the normalized run-scoped artifact
124
+ .spur/run/<runId>-wrapup-tasks.json and never re-parse raw input — a
125
+ missing or corrupted capture refuses progression. A validated empty list
126
+ -> skipped; length > 0 && mode == fast -> fast-path (metrics-record,
99
127
  bypassing doc-sync); missing/unknown/conflict -> doc-sync (safety-path).
100
128
  Writes bounded machine-readable reason to reasonFile.
101
129
  onEnter:
102
130
  - kind: note
103
131
  options:
104
132
  message: "Resolving task list: ${vars.tasks} (proportional mode: ${vars.mode})"
105
- # Run-id confinement (0758 R3). The run-scoped reason file is the only route artifact: a
106
- # second copy at the fixed path `.spur/run/wrapup-route-reason.txt` had no reader and was
107
- # overwritten by whichever run finished last, so a route claim read from it belonged to no
108
- # particular run. The log append carries the run id for the same reason — an unattributed
109
- # line is log scraping, which R5 rejects as evidence.
133
+ # 0770/0783 truthful input: parse and validate vars.tasks exactly once,
134
+ # here and only here. Malformed JSON, a non-array, a non-string entry,
135
+ # whitespace or a non-canonical WBS (anything but four digits), a missing
136
+ # __runId, an unresolved task, a failed or malformed lookup, or a task
137
+ # not at a completed status records FAIL (routed to `failed` by the first
138
+ # declared edge) and is never re-interpreted as an empty list by a later
139
+ # sibling guard. The normalized, deduplicated list is persisted
140
+ # run-scoped; the route writer, metrics, prompts, notes and guards only
141
+ # ever read that capture.
142
+ - kind: shell
143
+ options:
144
+ command: >-
145
+ mkdir -p .spur/run &&
146
+ RUN_ID="$__runId" &&
147
+ if [ -z "$RUN_ID" ]; then
148
+ echo "task-resolve: __runId is empty — refusing the legacy fixed-path fallback" >&2;
149
+ exit 1;
150
+ fi &&
151
+ REASON_FILE=".spur/run/$RUN_ID-route-reason.txt" &&
152
+ STATUS_FILE=".spur/run/$RUN_ID-wrapup-resolve.status" &&
153
+ TASKS_FILE=".spur/run/$RUN_ID-wrapup-tasks.json" &&
154
+ if ! printf '%s' "$tasks" | jq -e 'type == "array" and all(.[]; type == "string" and test("^[0-9]{4}$"))' > /dev/null 2>&1; then
155
+ echo "task-resolve: tasks must be a JSON array of canonical four-digit WBS strings (whitespace is rejected, not trimmed)" >&2;
156
+ echo "failed:tasks is not a JSON array of canonical four-digit WBS strings" > "$REASON_FILE";
157
+ printf 'FAIL\n' > "$STATUS_FILE";
158
+ else
159
+ printf '%s' "$tasks" | jq -c 'reduce .[] as $w ([]; if any(.[]; . == $w) then . else . + [$w] end)' > "$TASKS_FILE";
160
+ RESOLVE_RC=0;
161
+ for wbs in $(jq -r '.[]' "$TASKS_FILE"); do
162
+ TASK_JSON=$($spurBin task show "$wbs" --json 2>/dev/null || true);
163
+ TASK_STATUS=$(printf '%s' "$TASK_JSON" | jq -r '.frontmatter.status // .status // empty' 2>/dev/null || true);
164
+ if [ "$TASK_STATUS" != "done" ] && [ "$TASK_STATUS" != "cancelled" ]; then
165
+ STATUS_DISPLAY="$TASK_STATUS";
166
+ if [ -z "$STATUS_DISPLAY" ]; then STATUS_DISPLAY=unresolved; fi;
167
+ echo "task-resolve: task $wbs did not resolve to a completed status (status=$STATUS_DISPLAY)" >&2;
168
+ RESOLVE_RC=1;
169
+ fi;
170
+ done;
171
+ if [ "$RESOLVE_RC" -eq 0 ]; then
172
+ printf 'PASS\n' > "$STATUS_FILE";
173
+ else
174
+ echo "failed:unresolved or non-completed task (see $TASKS_FILE)" > "$REASON_FILE";
175
+ printf 'FAIL\n' > "$STATUS_FILE";
176
+ fi;
177
+ fi;
178
+ exit 0
179
+ # Route reason writer (0758 R3/R4 run-attribution pins intact). Runs AFTER
180
+ # validation and reads ONLY the validated run-scoped capture — never raw
181
+ # vars.tasks (0783 R2/R5); the fixed-path fallback is gone because
182
+ # validation refuses an empty __runId above. A FAIL resolve exits without
183
+ # writing: the failed reason written by validation stands, and the skip
184
+ # reason can never mask it. A missing/corrupted capture yields N=-1 and
185
+ # no skipped claim; the failed defense edge owns the run.
110
186
  - kind: shell
111
187
  options:
112
188
  command: >-
113
189
  mkdir -p .spur/run .spur/memory &&
114
190
  RUN_ID="$__runId" &&
115
- if [ -z "$RUN_ID" ]; then RUN_ID="wrapup"; fi &&
191
+ if [ -z "$RUN_ID" ]; then
192
+ echo "task-resolve: __runId is empty — refusing to write a route reason" >&2;
193
+ exit 1;
194
+ fi &&
116
195
  REASON_FILE=".spur/run/$RUN_ID-route-reason.txt" &&
117
- if [ "$(echo "$tasks" | jq length 2>/dev/null || echo 0)" -eq 0 ]; then
196
+ STATUS_FILE=".spur/run/$RUN_ID-wrapup-resolve.status" &&
197
+ TASKS_FILE=".spur/run/$RUN_ID-wrapup-tasks.json" &&
198
+ if [ "$(cat "$STATUS_FILE" 2>/dev/null)" = FAIL ]; then
199
+ exit 0;
200
+ fi &&
201
+ N=$(jq length "$TASKS_FILE" 2>/dev/null || echo -1) &&
202
+ if [ "$N" -eq 0 ]; then
118
203
  echo "skipped:empty task list" > "$REASON_FILE";
119
204
  elif [ "$mode" = "fast" ]; then
120
205
  echo "fast:evidence complete+consistent" > "$REASON_FILE";
@@ -146,88 +231,115 @@ states:
146
231
  options:
147
232
  agent: ${vars.agent}
148
233
  input: >-
149
- First run Skill(skill="sp:doc-evolve", args="wrapup tasks=${vars.tasks}") to repair drift in
234
+ First run Skill(skill="sp:doc-evolve", args="wrapup") to repair drift in
150
235
  docs/00_ADR.md, docs/03_ARCHITECTURE.md, docs/04_DESIGN.md, docs/design/* per constitution edit
151
- rules; do not write task/feature corpus. THEN extract working learnings from tasks ${vars.tasks}
236
+ rules; do not write task/feature corpus. The batch task list is the normalized, validated
237
+ capture at .spur/run/${vars.__runId}-wrapup-tasks.json — read it for the WBS ids; never
238
+ re-parse raw input. THEN extract working learnings from those tasks —
152
239
  conventions, errors fixed, patterns, gotchas, grouped by date and task WBS — as raw markdown
153
- (no fences) and END your final message with that markdown (it is captured to .spur/run/wrapup-learnings.md).
240
+ (no fences) and END your final message with that markdown (it is captured to the
241
+ run-scoped .spur/run/${vars.__runId}-wrapup-learnings.md).
154
242
  # Declared Layer-1 role (0538 R2): routing reason beside the agent:
155
243
  # pin.
156
244
  role: coder
157
- answerFile: .spur/run/wrapup-learnings.md
158
- expectFile: .spur/run/wrapup-learnings.md
245
+ answerFile: .spur/run/${vars.__runId}-wrapup-learnings.md
246
+ expectFile: .spur/run/${vars.__runId}-wrapup-learnings.md
159
247
  timeoutMs: ${vars.stepTimeoutMs}
160
248
  - kind: shell
161
249
  options:
162
250
  command: >-
163
- mkdir -p .spur/memory &&
164
- if test -s .spur/run/wrapup-learnings.md; then
165
- cat .spur/run/wrapup-learnings.md >> .spur/memory/learnings.md &&
251
+ mkdir -p .spur/run .spur/memory &&
252
+ LEARNINGS_FILE=".spur/run/$__runId-wrapup-learnings.md" &&
253
+ if test -s "$LEARNINGS_FILE"; then
254
+ cat "$LEARNINGS_FILE" >> .spur/memory/learnings.md &&
166
255
  printf '\n' >> .spur/memory/learnings.md;
167
256
  else
168
- echo "wrapup: learning capture missing or empty at $PWD/.spur/run/wrapup-learnings.md - append skipped" >&2;
257
+ echo "wrapup: learning capture missing or empty at $PWD/$LEARNINGS_FILE - append skipped" >&2;
169
258
  fi &&
170
259
  exit 0
171
260
 
172
261
  - id: metrics-record
173
262
  description: >
174
263
  Append one JSONL row per task to .spur/memory/wrapup-metrics.jsonl deterministically.
264
+ Revalidates the normalized run-scoped capture from task-resolve (0770 + 0783):
265
+ a missing, corrupted or non-canonical capture records FAIL; every member must
266
+ produce a successful, well-shaped `task show` lookup (a missing metrics row is
267
+ never silently absorbed as success); rows are serialized with jq — never
268
+ interpolated printf JSON — so escaped fields stay parseable; and PASS is
269
+ written only after every required append succeeds. A missing per-task verdict
270
+ is UNKNOWN telemetry, never proof of completion; previously written valid rows
271
+ survive a failure.
175
272
  onEnter:
176
273
  - kind: shell
177
274
  options:
178
275
  command: >-
179
- mkdir -p .spur/memory &&
180
- printf '%s' "$tasks" | jq -r '(if type == "string" then (try fromjson catch .) else . end) | if type == "array" then .[] else empty end' |
181
- while IFS= read -r wbs; do
182
- if [ -n "$wbs" ]; then
276
+ mkdir -p .spur/run .spur/memory &&
277
+ STATUS_FILE=".spur/run/$__runId-wrapup-metrics.status" &&
278
+ TASKS_FILE=".spur/run/$__runId-wrapup-tasks.json" &&
279
+ METRICS_RC=0 &&
280
+ if ! jq -e 'type == "array" and all(.[]; type == "string" and test("^[0-9]{4}$"))' "$TASKS_FILE" > /dev/null 2>&1; then
281
+ echo "metrics-record: run-scoped task capture missing, corrupted or non-canonical — refusing to record metrics" >&2;
282
+ printf 'FAIL\n' > "$STATUS_FILE";
283
+ else
284
+ for wbs in $(jq -r '.[]' "$TASKS_FILE"); do
183
285
  task_json=$($spurBin task show "$wbs" --json 2>/dev/null || true);
184
- if [ -n "$task_json" ]; then
185
- feat=$(printf '%s' "$task_json" | jq -r '.frontmatter.feature_id // .feature_id // empty' 2>/dev/null || true);
186
- stat=$(printf '%s' "$task_json" | jq -r '.frontmatter.status // .status // "unknown"' 2>/dev/null || true);
286
+ if printf '%s' "$task_json" | jq -e '(.frontmatter.status // .status) != null' > /dev/null 2>&1; then
287
+ feat=$(printf '%s' "$task_json" | jq -r '.frontmatter.feature_id // .feature_id // ""');
288
+ stat=$(printf '%s' "$task_json" | jq -r '.frontmatter.status // .status // "unknown"');
187
289
  verdict="UNKNOWN";
188
290
  if [ -f ".spur/run/$wbs-verdict.json" ]; then
189
291
  v=$(jq -r '.verdict // "UNKNOWN"' ".spur/run/$wbs-verdict.json" 2>/dev/null || true);
190
292
  if [ -n "$v" ]; then verdict="$v"; fi;
191
293
  fi;
192
294
  ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ");
193
- printf '{"wbs":"%s","feature_id":"%s","status":"%s","verdict":"%s","timestamp":"%s"}\n' "$wbs" "$feat" "$stat" "$verdict" "$ts" >> .spur/memory/wrapup-metrics.jsonl;
295
+ if ! jq -cn --arg wbs "$wbs" --arg feature_id "$feat" --arg status "$stat" --arg verdict "$verdict" --arg timestamp "$ts" '{wbs:$wbs, feature_id:$feature_id, status:$status, verdict:$verdict, timestamp:$timestamp}' >> .spur/memory/wrapup-metrics.jsonl; then
296
+ echo "metrics-record: metrics append failed for task $wbs — recording FAIL instead of claiming the row landed" >&2;
297
+ METRICS_RC=1;
298
+ fi;
299
+ else
300
+ echo "metrics-record: task $wbs lookup failed or was malformed — recording FAIL instead of silently omitting its metrics row" >&2;
301
+ METRICS_RC=1;
194
302
  fi;
195
- fi;
196
- done &&
303
+ done;
304
+ if [ "$METRICS_RC" -eq 0 ]; then printf 'PASS\n' > "$STATUS_FILE"; else printf 'FAIL\n' > "$STATUS_FILE"; fi;
305
+ fi;
197
306
  exit 0
198
307
 
199
308
  - id: feature-transition
200
309
  description: >
201
310
  If vars.feature is set, sync feature status via bounded feature-sync-bounded
202
- (0411). Soft shell blocked/failed sync must not abort wrap-up after metrics.
203
- Empty vars.feature is NOT a blocked sync but a mis-invocation (the engine only
204
- enters this state when vars.feature is set) and fails loud (dogfood 2026-08-15,
205
- feature I3: a silent exit 0 made the wrap look complete with no transition).
311
+ (0411). The shell terminates `exit 0` so artifacts already landed are never
312
+ discarded, but the outcome is truthful (0770 + 0783 R4): sync succeeds only
313
+ with a valid proposal matching vars.feature, no gateBlocked/requiresConfirm
314
+ condition, and a freshly observed feature status equal to proposal.to;
315
+ applied:false is a successful explicit no-change only when from == to and
316
+ that status is observed. A nonzero, malformed, partial, blocked or
317
+ unreadable outcome records FAIL in the run-scoped sync status and routes
318
+ to `failed` — an affected-feature check cannot convert a failed sync into
319
+ success. Empty vars.feature is NOT a blocked sync but a mis-invocation
320
+ (the engine only enters this state when vars.feature is set) and fails
321
+ loud (dogfood 2026-08-15, feature I3).
206
322
  onEnter:
207
- # Genuinely soft: the terminating `exit 0` must be reached on BOTH
208
- # branches, so
209
- # the sync is chained with `;` with `&&` a non-zero sync skipped `exit
210
- # 0` and
211
- # aborted the run at this state, discarding the learnings/metrics that
212
- # already
213
- # landed and never reaching branch-cleanup/done (the exact failure the
214
- # state
215
- # description promises against). `feature-sync-bounded.ts` is a
216
- # Spur-monorepo
217
- # path (`spur init` does not scaffold `plugins/sp/`), so seeded projects
218
- # fall
219
- # back to the plain `spur feature sync` verb.
220
- # R1 (0625): a feature transition that changed state may have armed a
221
- # feature-level finding the per-task fast gate (spur-check) never sees.
222
- # Run the corpus-aware gate after an applied sync OR a non-zero exit that
223
- # may follow a partial multi-hop transition, then report PASS/FAIL. Soft
224
- # by design: the operator decides, and wrap-up never hard-fails here.
323
+ # The terminating `exit 0` must be reached on every branch — the sync is
324
+ # chained with `;`, because `&&` would abort the state, discarding the
325
+ # learnings/metrics that already landed. Soft TERMINATION is not soft
326
+ # TRUTH: the result is classified per 0783 R4 and only a verified sync
327
+ # writes PASS. `feature-sync-bounded.ts` is a Spur-monorepo path (`spur
328
+ # init` does not scaffold `plugins/sp/`), so seeded projects fall back to
329
+ # the plain `spur feature sync` verb.
330
+ # R1 (0625): a feature transition that changed state may have left
331
+ # cross-task fallout the per-task fast gates never see. The
332
+ # affected-feature gate (featureGateCmd) runs after an applied sync OR a
333
+ # non-zero exit that may follow a partial multi-hop transition. It is a
334
+ # diagnostic only: a gate PASS can never convert a failed sync into
335
+ # success, and a gate FAIL fails the step (0770 failure routing).
225
336
  # NOTE: do not add
226
337
  # `#` comments INSIDE the folded shell string — YAML `>-` folds them onto
227
338
  # the following statement line and comments it out.
228
339
  - kind: shell
229
340
  options:
230
341
  command: >-
342
+ mkdir -p .spur/run &&
231
343
  if [ -z "$feature" ]; then
232
344
  echo "feature-transition: vars.feature is empty — refusing no-op feature sync (mis-invocation, not a blocked sync)" >&2;
233
345
  exit 1;
@@ -241,47 +353,96 @@ states:
241
353
  fi);
242
354
  SYNC_RC=$?;
243
355
  printf '%s\n' "$SYNC_OUTPUT";
244
- APPLIED=$(printf '%s' "$SYNC_OUTPUT" | jq -r '.applied // false' 2>/dev/null || echo false);
356
+ OBSERVED=$($spurBin feature show "$feature" --json 2>/dev/null | jq -r '.status // .frontmatter.status // empty' 2>/dev/null || true);
357
+ if [ -z "$OBSERVED" ]; then OBSERVED=unreadable; fi;
358
+ APPLIED=unreadable;
359
+ SYNC_OK=0;
360
+ REASON="";
361
+ if [ "$SYNC_RC" -ne 0 ]; then
362
+ REASON="sync exited nonzero (rc=$SYNC_RC)";
363
+ elif ! printf '%s' "$SYNC_OUTPUT" | jq -e 'type == "object" and (.proposal | type == "object") and (.proposal.featureId | type == "string") and (.proposal.from | type == "string") and (.proposal.to | type == "string") and (.applied | type == "boolean")' > /dev/null 2>&1; then
364
+ REASON="malformed or unreadable sync result";
365
+ else
366
+ P_FROM=$(printf '%s' "$SYNC_OUTPUT" | jq -r '.proposal.from // ""');
367
+ P_TO=$(printf '%s' "$SYNC_OUTPUT" | jq -r '.proposal.to // ""');
368
+ APPLIED=$(printf '%s' "$SYNC_OUTPUT" | jq -r '.applied');
369
+ if [ "$(printf '%s' "$SYNC_OUTPUT" | jq -r '.proposal.featureId // ""')" != "$feature" ]; then
370
+ REASON="sync proposal does not match feature $feature";
371
+ elif [ "$(printf '%s' "$SYNC_OUTPUT" | jq -r '.proposal.gateBlocked // false')" = "true" ]; then
372
+ REASON="sync proposal is gate-blocked — a blocked sync is not a no-change success";
373
+ elif [ "$(printf '%s' "$SYNC_OUTPUT" | jq -r '.proposal.requiresConfirm // false')" = "true" ]; then
374
+ REASON="sync proposal requires operator confirmation";
375
+ elif [ "$APPLIED" = "true" ] && [ "$OBSERVED" != "$P_TO" ]; then
376
+ REASON="applied sync did not land on the proposal target (observed=$OBSERVED, to=$P_TO)";
377
+ elif [ "$APPLIED" = "false" ] && { [ "$P_FROM" != "$P_TO" ] || [ "$OBSERVED" != "$P_TO" ]; }; then
378
+ REASON="sync applied nothing without a from==to observed no-op (from=$P_FROM, to=$P_TO, observed=$OBSERVED)";
379
+ else
380
+ SYNC_OK=1;
381
+ fi;
382
+ fi;
383
+ GATE="skipped";
245
384
  if [ "$APPLIED" = "true" ] || [ "$SYNC_RC" -ne 0 ]; then
246
- echo "feature-transition: sync applied or failed after a possible partial transition for $feature — running corpus-aware gate: $featureGateCmd";
385
+ echo "feature-transition: sync applied or failed after a possible partial transition for $feature — running feature gate: $featureGateCmd";
247
386
  if sh -c "$featureGateCmd"; then
248
- echo "feature-transition: corpus-aware gate PASS for feature $feature";
387
+ GATE="PASS";
388
+ echo "feature-transition: feature gate PASS for feature $feature";
249
389
  else
250
- echo "feature-transition: corpus-aware gate FAIL for feature $feature — inspect findings before reporting the transition complete" >&2;
390
+ GATE="FAIL";
391
+ echo "feature-transition: feature gate FAIL for feature $feature — inspect findings before reporting the transition complete" >&2;
251
392
  fi;
252
393
  else
253
- echo "feature-transition: sync did not apply a transition (rc=$SYNC_RC, applied=$APPLIED) — corpus-aware gate skipped";
394
+ echo "feature-transition: sync did not apply a transition (rc=$SYNC_RC, applied=$APPLIED) — feature gate skipped";
395
+ fi;
396
+ SYNC_STATUS="PASS";
397
+ if [ "$SYNC_OK" -ne 1 ] || [ "$GATE" = "FAIL" ]; then
398
+ SYNC_STATUS="FAIL";
399
+ echo "feature-transition: required synchronization failed for $feature — $REASON; gate=$GATE" >&2;
400
+ elif [ "$APPLIED" = "false" ]; then
401
+ echo "feature-transition: feature sync verified for $feature (from==to observed at $OBSERVED, gate=$GATE) — explicit no-change";
402
+ else
403
+ echo "feature-transition: feature sync verified for $feature (applied, observed=$OBSERVED, gate=$GATE)";
404
+ fi;
405
+ if [ "$SYNC_STATUS" = "PASS" ]; then
406
+ printf 'PASS\n' > ".spur/run/$__runId-wrapup-sync.status";
407
+ else
408
+ printf 'FAIL\n' > ".spur/run/$__runId-wrapup-sync.status";
254
409
  fi;
255
410
  exit 0
256
411
 
257
412
  - id: branch-cleanup
258
413
  description: >
259
- Irreversible HITL gate. If vars.merge=true, dispatch branch cleanup (merge or
260
- delete). This gate ALWAYS pauses even under --auto because branch operations
261
- are irreversible (Auto-Decision Principle #6). The operator must explicitly confirm.
414
+ Irreversible-operation HITL gate, consent-only (0770). If vars.merge=true,
415
+ branch cleanup (merge or delete) is a candidatebut this gate records
416
+ the operator decision and performs NO git operation itself; an operator
417
+ or a separate approved surface executes any actual merge/delete. This
418
+ gate ALWAYS pauses — even under --auto — because branch operations are
419
+ irreversible (Auto-Decision Principle #6).
262
420
  pause: true
263
421
  onEnter:
264
422
  - kind: hitl.confirm
265
423
  options:
266
- prompt: "Branch cleanup for tasks ${vars.tasks}. This is IRREVERSIBLE (merge or delete). Confirm to proceed?"
424
+ prompt: "Branch cleanup for the validated task list (.spur/run/${vars.__runId}-wrapup-tasks.json). This is IRREVERSIBLE (merge or delete). Confirm to proceed?"
267
425
 
268
426
  - id: done
269
427
  description: >
270
- Terminal — wrap-up complete. Output summary: tasks wrapped, learnings captured,
271
- metrics recorded, feature advanced (if applicable), branch cleaned (if applicable).
428
+ Terminal — wrap-up complete. Summary: tasks wrapped, learnings captured,
429
+ metrics recorded, feature advanced or explicitly no-change (if applicable),
430
+ branch-cleanup decision recorded (consent-only; no git operation performed).
272
431
  onEnter:
273
432
  - kind: note
274
433
  options:
275
- message: "Wrap-up pipeline complete for tasks: ${vars.tasks}. Learnings at .spur/memory/learnings.md, metrics at .spur/memory/wrapup-metrics.jsonl."
276
- # Checkpoint write: record session state for resume (Phase 4, task 0171
277
- # R3)
278
- - kind: shell
279
- options:
280
- command: 'mkdir -p .spur/memory/sessions && echo "checkpoint: wrapup-pipeline done tasks=$tasks ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" > .spur/memory/sessions/wrapup-checkpoint.md'
434
+ message: "Wrap-up pipeline complete for the validated task list at .spur/run/${vars.__runId}-wrapup-tasks.json. Learnings at .spur/memory/learnings.md, metrics at .spur/memory/wrapup-metrics.jsonl. Branch cleanup: consent recorded only (merge=${vars.merge}); no git operation performed by wrap-up."
281
435
 
282
436
  - id: skipped
283
437
  description: Terminal — wrap-up skipped (empty task list or operator abort).
284
438
 
439
+ - id: failed
440
+ description: >
441
+ Terminal — wrap-up failed (0770): invalid wrap input, an unresolved or
442
+ non-completed task, a failed metrics capture, a failed required sync, or
443
+ a failed feature gate. Already-written artifacts (learnings, metrics
444
+ rows, route reason) are preserved; nothing is rolled back.
445
+
285
446
  transitions:
286
447
  # ── start -> task-resolve ──
287
448
  - from: start
@@ -290,31 +451,47 @@ transitions:
290
451
  guard:
291
452
  kind: always
292
453
 
293
- # ── task-resolve: proportional route table (task 0758 R1-R4) ──
454
+ # ── task-resolve: validation fail + proportional route table (0758 R1-R4, 0770) ──
455
+ - from: task-resolve
456
+ to: failed
457
+ description: >
458
+ Validation FAIL (0770): malformed wrap input, a non-string/empty entry, an
459
+ unresolved or non-completed task, or an empty __runId. Declared before the
460
+ route edges so invalid input can never be absorbed by the empty-list skip.
461
+ guard:
462
+ kind: shell
463
+ options:
464
+ command: 'test "$(cat .spur/run/$__runId-wrapup-resolve.status 2>/dev/null)" = FAIL'
465
+ # Route edges key on the validated run-scoped capture, never raw vars.tasks
466
+ # (0783 R2). A missing or corrupted capture yields -1, which satisfies none
467
+ # of the numeric edges, so the run falls through to the always-defense
468
+ # `failed` edge instead of claiming a skip or a route.
294
469
  - from: task-resolve
295
470
  to: skipped
296
- description: Task list is empty — skip wrap-up.
471
+ description: Validated task list is empty — skip wrap-up (only a validated [] may skip).
297
472
  guard:
298
473
  kind: shell
299
474
  options:
300
- command: 'test "$(echo "$tasks" | jq length 2>/dev/null || echo 0)" -eq 0'
475
+ command: 'test "$(jq length .spur/run/$__runId-wrapup-tasks.json 2>/dev/null || echo -1)" -eq 0'
301
476
  - from: task-resolve
302
477
  to: metrics-record
303
478
  description: Proportional fast path — complete and consistent evidence bypasses doc-sync.
304
479
  guard:
305
480
  kind: shell
306
481
  options:
307
- command: 'test "$(echo "$tasks" | jq length 2>/dev/null || echo 0)" -gt 0 && test "$mode" = fast'
482
+ command: 'test "$(jq length .spur/run/$__runId-wrapup-tasks.json 2>/dev/null || echo -1)" -gt 0 && test "$mode" = fast'
308
483
  - from: task-resolve
309
484
  to: doc-sync
310
485
  description: Proportional safety path — missing/unknown/conflicting evidence routes to full doc-sync.
311
486
  guard:
312
487
  kind: shell
313
488
  options:
314
- command: 'test "$(echo "$tasks" | jq length 2>/dev/null || echo 0)" -gt 0 && test "$mode" != fast'
489
+ command: 'test "$(jq length .spur/run/$__runId-wrapup-tasks.json 2>/dev/null || echo -1)" -gt 0 && test "$mode" != fast'
315
490
  - from: task-resolve
316
- to: skipped
317
- description: Defense — task list unparseable; skip rather than fail with no-passing-transition.
491
+ to: failed
492
+ description: >
493
+ Defense (0770) — no PASS/FAIL resolve status (shell crashed or never ran):
494
+ route to `failed` rather than claiming a skip.
318
495
  guard:
319
496
  kind: always
320
497
 
@@ -325,47 +502,67 @@ transitions:
325
502
  guard:
326
503
  kind: always
327
504
 
328
- # ── metrics-record: conditional routing based on feature and merge ──
329
- # Declaration order matters: feature-transition is tried first (if feature is
330
- # set),
331
- # then branch-cleanup (if merge=true but no feature), then done (neither).
505
+ # ── metrics-record: fail edge, then conditional routing (feature merge → done) ──
506
+ # Declaration order matters: the FAIL edge is tried first (0770), then
507
+ # feature-transition (if feature is set), then branch-cleanup (if merge=true
508
+ # but no feature), then done (neither). Routing edges additionally require
509
+ # the metrics status PASS so a missing status can never claim success.
510
+ - from: metrics-record
511
+ to: failed
512
+ description: >
513
+ Metrics capture failed for at least one task — a missing metrics row is
514
+ recorded as failure, never silently omitted as success (0770).
515
+ guard:
516
+ kind: shell
517
+ options:
518
+ command: 'test "$(cat .spur/run/$__runId-wrapup-metrics.status 2>/dev/null)" = FAIL'
332
519
  - from: metrics-record
333
520
  to: feature-transition
334
- description: Feature id is set — advance feature through legal lifecycle edges.
521
+ description: Metrics PASS and feature id is set — advance feature through legal lifecycle edges.
335
522
  guard:
336
523
  kind: shell
337
524
  options:
338
- command: 'test -n "$feature"'
525
+ command: 'test "$(cat .spur/run/$__runId-wrapup-metrics.status 2>/dev/null)" = PASS && test -n "$feature"'
339
526
  - from: metrics-record
340
527
  to: branch-cleanup
341
- description: No feature id, but merge is requested — go to branch cleanup.
528
+ description: Metrics PASS, no feature id, but merge is requested — go to branch cleanup.
342
529
  guard:
343
530
  kind: shell
344
531
  options:
345
- command: 'test -z "$feature" && test "$merge" = true'
532
+ command: 'test "$(cat .spur/run/$__runId-wrapup-metrics.status 2>/dev/null)" = PASS && test -z "$feature" && test "$merge" = true'
346
533
  - from: metrics-record
347
534
  to: done
348
- description: No feature id and no merge — wrap-up is done.
535
+ description: Metrics PASS, no feature id and no merge — wrap-up is done.
349
536
  guard:
350
537
  kind: shell
351
538
  options:
352
- command: 'test -z "$feature" && test "$merge" != true'
539
+ command: 'test "$(cat .spur/run/$__runId-wrapup-metrics.status 2>/dev/null)" = PASS && test -z "$feature" && test "$merge" != true'
353
540
 
354
- # ── feature-transition: -> branch-cleanup (if merge) or done ──
541
+ # ── feature-transition: fail edge, then branch-cleanup (if merge) or done ──
542
+ - from: feature-transition
543
+ to: failed
544
+ description: >
545
+ Required synchronization failed (0770): non-zero sync exit, an invalid
546
+ sync result, or a failed feature gate. The failed status carries the
547
+ actual reason; already-written learnings/metrics stay on disk.
548
+ guard:
549
+ kind: shell
550
+ options:
551
+ command: 'test "$(cat .spur/run/$__runId-wrapup-sync.status 2>/dev/null)" = FAIL'
355
552
  - from: feature-transition
356
553
  to: branch-cleanup
357
- description: Feature advanced — merge is requested.
554
+ description: Sync PASS — merge is requested.
358
555
  guard:
359
556
  kind: shell
360
557
  options:
361
- command: 'test "$merge" = true'
558
+ command: 'test "$(cat .spur/run/$__runId-wrapup-sync.status 2>/dev/null)" = PASS && test "$merge" = true'
362
559
  - from: feature-transition
363
560
  to: done
364
- description: Feature advanced — no merge requested.
561
+ description: Sync PASS — no merge requested.
365
562
  guard:
366
563
  kind: shell
367
564
  options:
368
- command: 'test "$merge" != true'
565
+ command: 'test "$(cat .spur/run/$__runId-wrapup-sync.status 2>/dev/null)" = PASS && test "$merge" != true'
369
566
 
370
567
  # ── branch-cleanup: HITL exhaustive routing (yes / no / cancel → done) ──
371
568
  # No irreversible git op is wired yet; confirmation is recorded, then wrap
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/spur",
3
- "version": "0.3.73",
3
+ "version": "0.3.75",
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",