@gobing-ai/spur 0.3.72 → 0.3.73

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.
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * inline-pipeline-parity-check — two-sided gate between the inline pipeline
4
+ * driver's documented action/guard set and the resolved action/guard sets in
5
+ * `.spur/workflows/task-pipeline.yaml` and `.spur/workflows/idea-pipeline.yaml`
6
+ * (task 0755 R2/R3).
7
+ *
8
+ * The driver reference at `plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md`
9
+ * documents the set of action and guard kinds it implements. The two runtime
10
+ * pipelines are the only consumers the driver needs to keep in step with. The
11
+ * check is a symmetric set diff: an element present in one and absent in the
12
+ * other fails the check and names the element.
13
+ *
14
+ * The set is defined in {@link DOCUMENTED} below; the driver's markdown list is
15
+ * the human mirror. Update both when the driver adds or drops a kind.
16
+ *
17
+ * Usage:
18
+ * bun plugins/sp/scripts/inline-pipeline-parity-check.ts
19
+ * [--root <path>] default: repo root
20
+ *
21
+ * Exit code: 0 when the sets agree; 1 on any divergence. Violations are printed
22
+ * to stderr; a summary to stdout.
23
+ */
24
+
25
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
26
+ import { join, resolve } from 'node:path';
27
+ import { parse as parseYaml } from 'yaml';
28
+
29
+ /** Documented action and guard set. Must stay in lockstep with the
30
+ * "Supported action and guard set (0755 R2 parity contract)" section in
31
+ * `plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md`. The
32
+ * driver supports a kind if ANY workflow in `.spur/workflows/*.yaml` uses
33
+ * it (the driver applies to any selected pipeline per its reference doc). */
34
+ const DOCUMENTED = {
35
+ actions: new Set([
36
+ 'shell',
37
+ 'note',
38
+ 'doctor.probe',
39
+ 'file.read.into-var',
40
+ 'hitl.confirm',
41
+ 'agent.run',
42
+ 'proof.fingerprint',
43
+ 'run.artifact',
44
+ 'command.gate',
45
+ ]),
46
+ guards: new Set(['always', 'shell']),
47
+ } as const;
48
+
49
+ /** Directory of workflow definitions the driver is responsible for. */
50
+ const WORKFLOW_DIR = join('config', 'workflows');
51
+
52
+ /** Walk a state list and yield every `kind:` value found in `onEnter` action
53
+ * lists. Skips the top-level workflow `kind:` (e.g. `state-machine`). */
54
+ function collectActionKinds(states: unknown): Set<string> {
55
+ const out = new Set<string>();
56
+ if (!Array.isArray(states)) return out;
57
+ for (const state of states) {
58
+ if (typeof state !== 'object' || state === null) continue;
59
+ const onEnter = (state as { onEnter?: unknown }).onEnter;
60
+ if (!Array.isArray(onEnter)) continue;
61
+ for (const action of onEnter) {
62
+ if (typeof action !== 'object' || action === null) continue;
63
+ const kind = (action as { kind?: unknown }).kind;
64
+ if (typeof kind === 'string') out.add(kind);
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /** Walk a transition list and yield every `guard.kind` value. */
71
+ function collectGuardKinds(transitions: unknown): Set<string> {
72
+ const out = new Set<string>();
73
+ if (!Array.isArray(transitions)) return out;
74
+ for (const transition of transitions) {
75
+ if (typeof transition !== 'object' || transition === null) continue;
76
+ const guard = (transition as { guard?: { kind?: unknown } }).guard;
77
+ const kind = guard?.kind;
78
+ if (typeof kind === 'string') out.add(kind);
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /** Symmetric set diff. Returns elements in `a` but not in `b`, and vice versa. */
84
+ function diff<T>(a: Set<T>, b: Set<T>): { onlyInA: T[]; onlyInB: T[] } {
85
+ const onlyInA: T[] = [];
86
+ const onlyInB: T[] = [];
87
+ for (const x of a) if (!b.has(x)) onlyInA.push(x);
88
+ for (const x of b) if (!a.has(x)) onlyInB.push(x);
89
+ return { onlyInA, onlyInB };
90
+ }
91
+
92
+ function parseArgs(argv: string[]): { root: string } {
93
+ let root = resolve('.');
94
+ for (let i = 0; i < argv.length; i += 1) {
95
+ const arg = argv[i];
96
+ if (arg === '--root' && i + 1 < argv.length) {
97
+ root = resolve(argv[++i] ?? '.');
98
+ } else if (arg === '--help' || arg === '-h') {
99
+ process.stdout.write('Usage: bun inline-pipeline-parity-check.ts [--root <path>]\n');
100
+ process.exit(0);
101
+ }
102
+ }
103
+ return { root };
104
+ }
105
+
106
+ function listWorkflowFiles(dir: string): string[] {
107
+ let entries: string[];
108
+ try {
109
+ entries = readdirSync(dir);
110
+ } catch {
111
+ return [];
112
+ }
113
+ return entries.filter((e) => e.endsWith('.yaml')).map((e) => join(dir, e));
114
+ }
115
+
116
+ async function main(): Promise<number> {
117
+ const { root } = parseArgs(process.argv.slice(2));
118
+ const errors: string[] = [];
119
+
120
+ const workflowDir = join(root, WORKFLOW_DIR);
121
+ if (!statSync(workflowDir, { throwIfNoEntry: false })) {
122
+ process.stderr.write(`inline-pipeline-parity-check: workflow directory not found: ${workflowDir}\n`);
123
+ return 1;
124
+ }
125
+
126
+ const files = listWorkflowFiles(workflowDir);
127
+ if (files.length === 0) {
128
+ process.stderr.write(`inline-pipeline-parity-check: no .yaml workflows found in ${workflowDir}\n`);
129
+ return 1;
130
+ }
131
+
132
+ const unionActions = new Set<string>();
133
+ const unionGuards = new Set<string>();
134
+ const perFileKinds: { path: string; actions: Set<string>; guards: Set<string> }[] = [];
135
+
136
+ for (const path of files) {
137
+ let parsed: unknown;
138
+ try {
139
+ parsed = parseYaml(readFileSync(path, 'utf8'));
140
+ } catch (err) {
141
+ errors.push(`${path}: failed to parse (${err instanceof Error ? err.message : String(err)})`);
142
+ continue;
143
+ }
144
+ if (typeof parsed !== 'object' || parsed === null) {
145
+ continue;
146
+ }
147
+ const def = parsed as { states?: unknown; transitions?: unknown };
148
+ const actions = collectActionKinds(def.states);
149
+ const guards = collectGuardKinds(def.transitions);
150
+ perFileKinds.push({ path, actions, guards });
151
+ for (const a of actions) unionActions.add(a);
152
+ for (const g of guards) unionGuards.add(g);
153
+ }
154
+
155
+ const actionDiff = diff(unionActions, DOCUMENTED.actions);
156
+ const guardDiff = diff(unionGuards, DOCUMENTED.guards);
157
+
158
+ for (const x of actionDiff.onlyInA) {
159
+ const usedIn = perFileKinds.filter((f) => f.actions.has(x)).map((f) => f.path);
160
+ errors.push(`action kind "${x}" used in YAML (${usedIn.join(', ')}) but absent from inline-pipeline-driver.md`);
161
+ }
162
+ for (const x of actionDiff.onlyInB) {
163
+ errors.push(`action kind "${x}" documented in inline-pipeline-driver.md but never used in any workflow`);
164
+ }
165
+ for (const x of guardDiff.onlyInA) {
166
+ const usedIn = perFileKinds.filter((f) => f.guards.has(x)).map((f) => f.path);
167
+ errors.push(`guard kind "${x}" used in YAML (${usedIn.join(', ')}) but absent from inline-pipeline-driver.md`);
168
+ }
169
+ for (const x of guardDiff.onlyInB) {
170
+ errors.push(`guard kind "${x}" documented in inline-pipeline-driver.md but never used in any workflow`);
171
+ }
172
+
173
+ if (errors.length > 0) {
174
+ process.stderr.write(`inline-pipeline-parity-check: ${errors.length} divergence(s)\n`);
175
+ for (const e of errors) process.stderr.write(` - ${e}\n`);
176
+ return 1;
177
+ }
178
+
179
+ process.stdout.write(
180
+ `inline-pipeline-parity-check: ok (${unionActions.size} actions, ${unionGuards.size} guards agree across ${files.length} workflows)\n`,
181
+ );
182
+ return 0;
183
+ }
184
+
185
+ process.exit(await main());
@@ -145,7 +145,7 @@ function main(): void {
145
145
 
146
146
  const dbPath = join(process.cwd(), '.spur', 'spur.db');
147
147
  if (!existsSync(dbPath)) {
148
- fail(wbs, [`spur database not found at ${dbPath} — run a real history import first`]);
148
+ fail(wbs, [`local spur database not found at ${dbPath} — run a real history import first`]);
149
149
  }
150
150
 
151
151
  let count: number;
@@ -269,6 +269,10 @@ function extractAcIdentities(taskContent: string, featureContent: string | null)
269
269
  const leading = label.split(/\s+/)[0] ?? '';
270
270
  if (leading && leading !== label) identities.add(leading);
271
271
  }
272
+ for (const m of section.matchAll(/^[ \t]*Scenario:\s*(.+)\s*$/gm)) {
273
+ const title = (m[1] ?? '').trim();
274
+ if (title) identities.add(title);
275
+ }
272
276
  if (featureContent !== null) {
273
277
  for (const m of featureContent.matchAll(/^[ \t]*Scenario:\s*(.+)\s*$/gm)) {
274
278
  const title = (m[1] ?? '').trim();
@@ -229,3 +229,26 @@ verifying shape, or stand in a `note` action until the path is proven, then swap
229
229
  `agent.run` input referencing a command rather than a raw prompt, guards a single predicate
230
230
  ([workflow-fit-and-tuning.md](workflow-fit-and-tuning.md#3-node-simplicity-budget)).
231
231
  - [ ] Validates clean AND dry-run reaches the expected terminal state.
232
+
233
+ ## Optional version literal (task 0756)
234
+
235
+ Both dialects accept an optional root `version` field. The literal is **behavior-neutral** — it
236
+ exists as an identity tag, not a routing key. The contract:
237
+
238
+ - **Absent** → reported as `unversioned`. The default for all 11 shipped definitions.
239
+ - **Present, non-empty string** → reported as `explicit(<literal>)`. The literal is wrapped in
240
+ parentheses verbatim — no parsing, no ordering, no compatibility check.
241
+ - **Present, empty string (`version: ""`)** → **rejected** with a diagnostic naming the empty
242
+ value. The rejection lives in the resolve/preflight seam
243
+ (`packages/app/src/workflow/workflow-resolver.ts`), not in the dialect JSON schemas: those carry
244
+ `minLength: 1` for editors and Ajv consumers, but the load path validates against the engine's
245
+ Zod schema, which has no minimum. Move the check upstream once
246
+ `@gobing-ai/ts-dual-workflow-engine` ships `z.string().min(1)` on the root version.
247
+
248
+ The literal folds into the definition digest (`packages/app/src/workflow/composition-baseline.ts`),
249
+ so a version-only edit changes the digest with zero behavior change. `show` and `trace` do **not**
250
+ surface the literal by default — the digest stays the rendered run identity (D8 decision D5).
251
+
252
+ **No registry, no semver parser, no compatibility engine.** A future-major requirement needs
253
+ objective evidence: a consumer that branches on version, or a real drift incident the digest
254
+ diagnostic could not disambiguate. Neither exists today.
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  name: inline-pipeline-driver
3
3
  description: "Interactive host-session interpreter for Spur state-machine pipelines: execute the existing FSM without a workflow agent subprocess while preserving actions, guards, artifacts, and provenance."
4
+ owner: spur-dev-maintainers
5
+ retirement-criterion: "The per-task interpreter retires once the engine covers per-task execution for /sp:dev-runall with real terminal runs and the parity check (plugins/sp/scripts/inline-pipeline-parity-check.ts) is green (D8 decision D7). Batch orchestration wrapper may remain."
4
6
  see_also:
5
7
  - spur-dev
6
8
  - execution-workflow
@@ -9,6 +11,24 @@ see_also:
9
11
 
10
12
  # Inline Pipeline Driver
11
13
 
14
+ **Owner:** `spur-dev-maintainers` (per task 0755 R1). Reach the named owner via the frontmatter; no need to read the originating task.
15
+
16
+ **Retirement criterion (0755 R5, D8 decision D7):** the per-task interpreter retires once the engine covers per-task execution for `/sp:dev-runall` with real terminal runs **and** the parity check (this doc's documented action/guard set ≡ the resolved action/guard set of every `.spur/workflows/*.yaml`) is green. Recording the criterion is part of this task; acting on it is not — that is a separate A3-gate decision.
17
+
18
+ ## Supported action and guard set (0755 R2 parity contract)
19
+
20
+ The action and guard kinds this driver implements. The parity check
21
+ (`plugins/sp/scripts/inline-pipeline-parity-check.ts`) compares this set against
22
+ the resolved actions and guards of every `.spur/workflows/*.yaml`; any element present
23
+ in one and absent in the other fails the check. Add a new kind here when the driver
24
+ implements it; remove the entry when the corresponding kind is dropped from the YAML.
25
+
26
+ **Actions:** `shell` · `note` · `doctor.probe` · `file.read.into-var` · `hitl.confirm` · `agent.run` · `proof.fingerprint` · `run.artifact` · `command.gate`
27
+
28
+ **Guards (transitions):** `always` · `shell`
29
+
30
+ ## What this driver is
31
+
12
32
  This driver is the interactive control-inversion path granted by ADR-047. It applies when an
13
33
  interactive `/sp:dev-run --mode full`, sequential `/sp:dev-runall`, `/sp:dev-idea`, or
14
34
  `/sp:dev-plan` invocation omits `--agent` or passes `--agent inline`. A named executor,
@@ -17,7 +17,9 @@
17
17
  "minLength": 1
18
18
  },
19
19
  "version": {
20
- "type": "string"
20
+ "type": "string",
21
+ "minLength": 1,
22
+ "description": "Optional behavior-neutral identity tag (0756). Absent = unversioned; present = a non-empty opaque literal surfaced as explicit(<literal>). Not parsed, ordered, or compatibility-checked; no registry."
21
23
  },
22
24
  "description": {
23
25
  "type": "string"
@@ -17,7 +17,9 @@
17
17
  "minLength": 1
18
18
  },
19
19
  "version": {
20
- "type": "string"
20
+ "type": "string",
21
+ "minLength": 1,
22
+ "description": "Optional behavior-neutral identity tag (0756). Absent = unversioned; present = a non-empty opaque literal surfaced as explicit(<literal>). Not parsed, ordered, or compatibility-checked; no registry."
21
23
  },
22
24
  "description": {
23
25
  "type": "string"