@skill-map/spec 0.10.0 → 0.12.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,125 @@
1
1
  # Spec changelog
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 68c5e28: Step 14.1 — `sm serve` + Hono BFF skeleton
8
+
9
+ Adds `src/server/` Hono workspace with single-port wiring (`/api/health` real,
10
+ `/api/*` 404 stubs, `/ws` no-op upgrade, `serveStatic` + SPA fallback). Real
11
+ `ServeCommand` extracted from stub at `cli/commands/stubs.ts` to dedicated
12
+ `cli/commands/serve.ts` extending `SmCommand`. Loopback-only through v0.6.0
13
+ (Decision #119). Boot resilient to missing DB — `/api/health` reports
14
+ `db: 'missing'`. Spec `cli-contract.md` `sm serve` row updated to full flag
15
+ set; new `### Server` subsection (skeleton — endpoints fill at 14.2).
16
+
17
+ **Files added (server)**
18
+
19
+ - `src/server/index.ts` — `createServer(opts)` factory returning `ServerHandle` (`{ address, close }`); resolves spec version, builds the Hono app, instantiates a `WebSocketServer({ noServer: true })`, hands both to `@hono/node-server`'s `serve({ websocket: { server: wss } })`. Closing the http server tears down the WSS automatically (node-server registers the `'close'` hook internally); `close()` calls `wss.close()` defensively for forward-compatibility.
20
+ - `src/server/app.ts` — Hono app construction. Routes registered in single-port order: `GET /api/health` → real, `ALL /api/*` → structured 404, `GET /ws` via the injected `attachWs` registrar, static handler + SPA fallback. Global `app.onError` formats every uncaught throw into the error envelope.
21
+ - `src/server/options.ts` — `IServerOptions` + `validateServerOptions(input)`. Loopback-only check for `--dev-cors`; port range check `[0, 65535]`; scope validation.
22
+ - `src/server/paths.ts` — `resolveDefaultUiDist(ctx)` walks upwards from cwd looking for `ui/dist/browser/index.html`; `resolveExplicitUiDist(ctx, raw)` honours absolute paths for `--ui-dist`.
23
+ - `src/server/static.ts` — wraps `@hono/node-server`'s `serveStatic` middleware with the SPA-fallback layer (`serveStatic` does not do SPA fallback — it `next()`s on miss, which is exactly the seam we hook into). Absolute `root` paths work on POSIX in node-server@2.0.1 (verified runtime probe — implementation is `path.join(root, filename)`); the `.d.ts` "Absolute paths are not supported" string is stale (upstream issue honojs/node-server#187 still open). When the bundle is missing (`uiDist === null`), a tiny placeholder middleware serves the boot-without-bundle hint at `/`.
24
+ - `src/server/ws.ts` — `noopWebSocketRoute(app)` registers `GET /ws` via the official `upgradeWebSocket` re-exported from `@hono/node-server@2.x`. The 14.1 handler closes the connection in `onOpen` with code 1000 + reason `'no broadcaster yet'`. 14.4 swaps this registrar for the chokidar-fed broadcaster — one-line change in `index.ts`, `app.ts` untouched.
25
+ - `src/server/health.ts` — `buildHealth(deps)` synchronous; `resolveSpecVersion()` async, called once at boot.
26
+ - `src/server/i18n/server.texts.ts` — `SERVER_TEXTS` catalog.
27
+
28
+ **Files added (CLI)**
29
+
30
+ - `src/cli/commands/serve.ts` — `ServeCommand extends SmCommand`. Parses flags, validates, calls `createServer`, registers SIGINT/SIGTERM handlers, awaits shutdown. `protected emitElapsed = false` (long-running daemon).
31
+ - `src/cli/i18n/serve.texts.ts` — `SERVE_TEXTS` catalog.
32
+
33
+ **Tests added (15)**
34
+
35
+ - `src/test/server-boot.test.ts` (7) — boot/listen/health JSON, custom port, db state present/missing, structured 404, /ws upgrade closes with code 1000 + reason 'no broadcaster yet' (uses real `WebSocket` client from `ws`), shutdown < 1s + idempotent close, inline placeholder when uiDist null.
36
+ - `src/test/server-flags.test.ts` (6) — host non-loopback + dev-cors rejection, port out-of-range, port non-numeric, scope invalid, ui-dist missing, ui-dist with valid bundle.
37
+ - `src/test/server-db-missing.test.ts` (2) — `--db <missing>` exits 5, default boots cleanly with db:missing.
38
+
39
+ **Files edited**
40
+
41
+ - `src/cli/commands/stubs.ts` — `ServeCommand` removed; replaced with a comment pointer.
42
+ - `src/cli/entry.ts` — registers the new `ServeCommand`.
43
+ - `src/package.json` — adds `hono@4.12.16`, `@hono/node-server@2.0.1`, `ws@8.20.0` (deps); `@types/ws@8.18.1` (dev). All exact-pinned per AGENTS.md.
44
+ - `spec/cli-contract.md` — `sm serve` row replaced with the full 14.1 flag set; new `#### Server` subsection (stability: experimental).
45
+ - `spec/CHANGELOG.md` — `[Unreleased]` `### Minor` entry for the spec change.
46
+ - `spec/index.json` — regenerated (40 files hashed; previous head was 215 lines).
47
+
48
+ **Decisions during implementation (flag for orchestrator)**
49
+
50
+ - WebSocket support uses `@hono/node-server@2.x`'s built-in `upgradeWebSocket` plus the canonical `ws@8.20.0` Node WebSocket library, per the official README pattern. The previously-published `@hono/node-ws` adapter was deprecated when node-server@2.0 absorbed WebSocket support natively (PR honojs/node-server#328). The 14.4 broadcaster will replace `noopWebSocketRoute` with its own one-line registrar — no API churn between 14.1 and 14.4.
51
+ - The `/api/*` catch-all is wired with `app.all('/api/*', ...)` BEFORE the `/ws` registrar and the static handler so neither a `serveStatic` filesystem hit nor the SPA fallback can shadow API endpoints. `/ws` is registered BEFORE the static handler so a literal `/ws` path on disk inside `uiDist` cannot accidentally shadow the upgrade route.
52
+ - `serveStatic` from `@hono/node-server/serve-static` accepts absolute root paths at runtime on POSIX (its implementation is `path.join(root, filename)`); the `.d.ts` string saying otherwise is documentation drift, not a runtime contract. Verified with a runtime probe and cross-referenced against the open upstream issue (honojs/node-server#187). Documented in `src/server/static.ts` so future contributors don't re-investigate.
53
+
54
+ ## 0.11.0
55
+
56
+ ### Minor Changes
57
+
58
+ - f8fca25: Step 10 prep — job artifacts move into the database (B2: content-addressed storage)
59
+
60
+ Removes the on-disk `.skill-map/jobs/<id>.md` and `.skill-map/reports/<id>.json` artifacts from the spec. Rendered job content and report payloads now live in the kernel database; the filesystem is no longer a normative layer of the job lifecycle. Pre-1.0 minor breaking per `versioning.md` § Pre-1.0.
61
+
62
+ **Why**: every other piece of operational state (`state_summaries`, `state_enrichments`, `state_plugin_kvs`, `node_enrichments`) already lives in the DB. Jobs and reports were the only outliers — and being outliers cost real complexity (orphan-file detection, partial backups, two-source-of-truth GC). With B2 (content-addressed dedup keyed on the existing `content_hash`), retries / `--force` / cross-node fan-out reuse a single content blob, so DB-only does not blow up storage on heavy users.
63
+
64
+ **Schema changes**
65
+
66
+ - New table `state_job_contents` (`content_hash` PK, `content` TEXT, `created_at`). Content-addressed: multiple `state_jobs` rows MAY reference the same row.
67
+ - `state_jobs.file_path` removed. The rendered content is fetched via `state_job_contents.content_hash` join.
68
+ - `state_executions.report_path` → `state_executions.report_json` (TEXT, parsed-JSON-on-read per the `_json` naming convention).
69
+
70
+ **Schema-typed contract changes**
71
+
72
+ - `Job.filePath` removed.
73
+ - `ExecutionRecord.reportPath` → `ExecutionRecord.report` (object/null — the parsed JSON payload).
74
+ - `Job.failureReason` and `ExecutionRecord.failureReason` enums: `job-file-missing` → `content-missing` (defensive failure-mode label for DB corruption where a job row outlives its content row; the runtime invariant should keep this state unreachable).
75
+ - `history-stats.schema.json` `perFailureReason` mirrors the rename.
76
+
77
+ **CLI surface changes**
78
+
79
+ - `sm job preview <id>` now prints the rendered content from `state_job_contents` (no file). Same output, different source.
80
+ - `sm job claim --json` is the contracted Skill-agent handover: returns `{id, nonce, content}` so the agent can call `sm record` afterwards with the nonce in hand. The plain-stdout form (id only) is preserved for legacy scripts.
81
+ - `sm record --report <path-or-dash>` accepts a file path OR `-` (stdin); the kernel reads the payload and stores it inline in `report_json`. The on-disk report file becomes operationally ephemeral — implementations SHOULD remove it after the kernel acknowledges the callback (courtesy GC, not normative).
82
+ - `sm job prune --orphan-files` removed. Replaced by automatic `state_job_contents` GC inside `sm job prune`: deletes terminal jobs past retention, then collects orphan content rows in the same transaction.
83
+ - `sm doctor` checks change accordingly: drops the "orphan job files / orphan DB rows pointing at missing files" pair; adds two DB-internal checks (`state_jobs` rows whose `content_hash` is missing from `state_job_contents`; `state_job_contents` rows referenced by zero `state_jobs` rows).
84
+
85
+ **Event stream changes**
86
+
87
+ - `job.spawning.data.jobFilePath` → `job.spawning.data.contentHash` (references the content row instead of a file path).
88
+ - `job.callback.received.data.reportPath` and `job.completed.data.reportPath` → `executionId` (references the `state_executions` row that holds the inline report payload). Reports are intentionally NOT inlined in events — consumers query the row when they need the body.
89
+
90
+ **Architecture changes**
91
+
92
+ - `RunnerPort.run(jobFilePath, options)` → `run(jobContent, options)` returning `{report, ...}` instead of `{reportPath, ...}`. Path-based reporting is no longer part of the port contract. Runners that need an actual file (the canonical case being `claude -p` reading stdin from a path) materialize a temp file inside `run()` and remove it after spawn — temp files are operational, not normative.
93
+
94
+ **Atomicity edge cases consolidated**
95
+
96
+ `spec/job-lifecycle.md` §Atomicity edge cases drops the four file-related rows. Two new DB-internal cases take their place: `state_jobs` row outliving its `state_job_contents` row (failure: `content-missing`); `state_job_contents` row with no live job references (GC straggler — `sm job prune` collects).
97
+
98
+ **Files touched**
99
+
100
+ - `spec/db-schema.md` — new `state_job_contents` section, `state_jobs.file_path` removed, `state_executions.report_path` → `report_json`, integrity section rewritten.
101
+ - `spec/job-lifecycle.md` — §Submit step 8 rewritten (DB store), §Atomic claim documents `--json` shape, §Atomicity edge cases consolidated, §Record callback rewritten for `--report` path-or-stdin semantics, §Retention extended to cover `state_job_contents` GC, failure-reason rename.
102
+ - `spec/cli-contract.md` — `sm job preview` / `sm job claim` / `sm job prune` rows updated, `sm job prune --orphan-files` row removed, `sm record` block rewritten with `<path-or-dash>`, `sm doctor` integrity bullets updated.
103
+ - `spec/prompt-preamble.md` — §How the kernel applies step 5 rewritten (DB store, no file).
104
+ - `spec/architecture.md` — §`RunnerPort` operations + reference impls updated for content-string + parsed-report shape.
105
+ - `spec/job-events.md` — `job.spawning` / `job.callback.received` / `job.completed` payloads changed.
106
+ - `spec/conformance/README.md` + `coverage.md` — `preamble-bitwise-match` references updated to `sm job preview` stdout.
107
+ - `spec/schemas/job.schema.json` — `filePath` property removed, failure-reason enum rename.
108
+ - `spec/schemas/execution-record.schema.json` — `reportPath` → `report` (object/null), failure-reason enum rename.
109
+ - `spec/schemas/history-stats.schema.json` — `perFailureReason` enum rename.
110
+ - `spec/index.json` regenerated (40 files hashed); `npm run spec:check` green.
111
+
112
+ **Migration for consumers**
113
+
114
+ - Any consumer reading `state_jobs.file_path` or `state_executions.report_path` reads from the renamed columns / DB-only paths instead.
115
+ - Any tooling that watched `.skill-map/jobs/*.md` or `.skill-map/reports/*.json` needs to query the DB or call the relevant `sm` verb.
116
+ - `--orphan-files` flag callers must drop the flag; `sm job prune` already does the equivalent automatically.
117
+ - Skill agents drain via `sm job claim --json` (id + nonce + content together) instead of `sm job claim` + reading a file.
118
+
119
+ **Out of scope**
120
+
121
+ The reference impl side of this (migration that adds `state_job_contents` + drops `state_jobs.file_path`; storage-adapter helpers; runtime piping in `ClaudeCliRunner` for the temp-file dance) lands in follow-up changesets under `@skill-map/cli`. The spec change above is self-contained: shipping it alone changes nothing at runtime, but unblocks the implementation phases.
122
+
3
123
  ## 0.10.0
4
124
 
5
125
  ### Minor Changes
@@ -103,6 +223,25 @@ list`, `sm plugins doctor`, `sm db prune` plugin filter, runtime
103
223
 
104
224
  ## [Unreleased]
105
225
 
226
+ ### Minor
227
+
228
+ - **`sm serve` row + `### Server` subsection** in `cli-contract.md` —
229
+ Step 14.1 promotes `sm serve` from an implementation-defined stub to a
230
+ documented surface. The verb row at `§Verb catalog` › `### Server`
231
+ expands the flag set to the full 14.1 contract: `--port` (default
232
+ `4242`), `--host` (default `127.0.0.1`, loopback-only through v0.6.0),
233
+ `--scope project|global`, `--db <path>`, `--no-built-ins`,
234
+ `--no-plugins`, `--open` / `--no-open`, `--dev-cors`, `--ui-dist
235
+ <path>` (hidden). New `#### Server` subsection documents the
236
+ single-port mandate, the boot-with-missing-DB resilience contract
237
+ (`/api/health` returns `db: 'missing'`), the v14.1 endpoint surface
238
+ (`GET /api/health` real, `ALL /api/*` 404 stubs, `GET /ws` upgrade-only,
239
+ static + SPA fallback), the structured error envelope shape, and the
240
+ flag table. Marked `*(Stability: experimental — locks at v0.6.0.)*` —
241
+ endpoints fill at v14.2, broadcaster at v14.4. Additive minor per
242
+ `versioning.md` § Pre-1.0 (no breaking change to the existing row's
243
+ semantics; the old wording was strictly less specific).
244
+
106
245
  ### Minor (breaking, pre-1.0)
107
246
 
108
247
  - **`Node.kind` opens to any non-empty string (was the closed enum
@@ -121,7 +260,7 @@ values]` to `{ "type": "string", "minLength": 1 }`. The
121
260
  `TEXT NOT NULL`.
122
261
  - `extensions/action.schema.json#/.../filter/kind` (the per-kind
123
262
  filter for action applicability) widens the same way: `items:
124
- { type: 'string', minLength: 1 }` instead of the closed enum.
263
+ { type: 'string', minLength: 1 }` instead of the closed enum.
125
264
  Migration: consumers who validate exported `Node` JSON against
126
265
  `node.schema.json` will now accept external-Provider kinds. Any
127
266
  consumer that hard-coded the closed enum elsewhere (UI filter chip
@@ -398,53 +537,51 @@ the`CamelCasePlugin`; raw SQL fragments must use snake_case to
398
537
  a real i18n library, the strings move as-is. Functions would
399
538
  have to be re-shaped first.
400
539
 
401
- Helper at `kernel/util/tx.ts`. Contract:
402
-
403
- - Every `{{name}}` token MUST have a matching key in the vars
404
- object — missing key throws (silent fallback hides
405
- forgotten args in production).
406
- - `null` / `undefined` values throw — caller coerces
407
- upstream.
408
- - Whitespace inside the braces tolerated (`{{ name }}`) so
409
- long templates wrap cleanly across `+`-joined lines.
410
- - Plural / conditional logic does NOT live in the template;
411
- the caller picks `*_singular` vs `*_plural` keys.
412
-
413
- Files created:
414
-
415
- - `kernel/util/tx.ts` — the helper itself, with 13 tests in
416
- `test/tx.test.ts` (single / multi token, whitespace,
417
- missing / null / undefined keys, identifier shapes, error
418
- truncation).
419
- - `kernel/i18n/orchestrator.texts.ts` — frontmatter
420
- malformed/invalid templates, `extension.error` payloads,
421
- root validation errors.
422
- - `kernel/i18n/plugin-loader.texts.ts` — every `load-error` /
423
- `invalid-manifest` / `incompatible-spec` reason, plus the
424
- import timeout message.
425
- - `cli/i18n/scan.texts.ts` — `sm scan` flag-clash / scan
426
- failure / guard / summary templates, plus the `sm scan
427
-
428
- compare-with`dump-load errors.
429
- -`cli/i18n/watch.texts.ts`—`sm watch`lifecycle templates.
430
- -`cli/i18n/init.texts.ts`—`sm init`templates including
431
- the`--dry-run`previews and the singular/plural pair for
432
- gitignore updates.
433
- -`cli/i18n/db.texts.ts`—`sm db reset`/`sm db restore` templates including their`--dry-run`previews.
434
- -`cli/i18n/cli-progress-emitter.texts.ts`— the
435
- `extension.error: ...` stderr line.
436
-
437
- String content moved verbatim every existing test that
438
- matches on stderr / stdout content keeps passing. Trivial
439
- single-token strings (`'No issues.\n'`) and rare per-handler
440
- bespoke phrases stay inline; the pattern is now established
441
- for whoever wants to migrate them in a follow-up.
442
-
443
- Note on `ui/` divergence: today the two workspaces use
444
- different shapes for their text tables (functions in `ui/`,
445
- templates in `cli/`). Aligning them is a follow-up the day a
446
- real i18n library lands, both converge on its native shape.
447
- The CLI shape is closer to the eventual destination.
540
+ Helper at `kernel/util/tx.ts`. Contract:
541
+
542
+ - Every `{{name}}` token MUST have a matching key in the vars
543
+ object — missing key throws (silent fallback hides
544
+ forgotten args in production).
545
+ - `null` / `undefined` values throw — caller coerces
546
+ upstream.
547
+ - Whitespace inside the braces tolerated (`{{ name }}`) so
548
+ long templates wrap cleanly across `+`-joined lines.
549
+ - Plural / conditional logic does NOT live in the template;
550
+ the caller picks `*_singular` vs `*_plural` keys.
551
+
552
+ Files created:
553
+
554
+ - `kernel/util/tx.ts` — the helper itself, with 13 tests in
555
+ `test/tx.test.ts` (single / multi token, whitespace,
556
+ missing / null / undefined keys, identifier shapes, error
557
+ truncation).
558
+ - `kernel/i18n/orchestrator.texts.ts` — frontmatter
559
+ malformed/invalid templates, `extension.error` payloads,
560
+ root validation errors.
561
+ - `kernel/i18n/plugin-loader.texts.ts` — every `load-error` /
562
+ `invalid-manifest` / `incompatible-spec` reason, plus the
563
+ import timeout message.
564
+ - `cli/i18n/scan.texts.ts` — `sm scan` flag-clash / scan
565
+ failure / guard / summary templates, plus the `sm scan
566
+
567
+ compare-with`dump-load errors.
568
+
569
+ -`cli/i18n/watch.texts.ts`—`sm watch`lifecycle templates. -`cli/i18n/init.texts.ts`—`sm init`templates including
570
+ the`--dry-run`previews and the singular/plural pair for
571
+ gitignore updates. -`cli/i18n/db.texts.ts`—`sm db reset`/`sm db restore` templates including their`--dry-run`previews. -`cli/i18n/cli-progress-emitter.texts.ts`— the
572
+ `extension.error: ...` stderr line.
573
+
574
+ String content moved verbatim — every existing test that
575
+ matches on stderr / stdout content keeps passing. Trivial
576
+ single-token strings (`'No issues.\n'`) and rare per-handler
577
+ bespoke phrases stay inline; the pattern is now established
578
+ for whoever wants to migrate them in a follow-up.
579
+
580
+ Note on `ui/` divergence: today the two workspaces use
581
+ different shapes for their text tables (functions in `ui/`,
582
+ templates in `cli/`). Aligning them is a follow-up — the day a
583
+ real i18n library lands, both converge on its native shape.
584
+ The CLI shape is closer to the eventual destination.
448
585
 
449
586
  - **N6 — `TIssueSeverity` aliased to `Severity`.** SQLite schema
450
587
  type now reads `type TIssueSeverity = Severity` instead of
@@ -1092,9 +1229,9 @@ kind, normalizedTrigger)` and prints one row per group with the
1092
1229
  (`Links out (12, 9 unique)`). When N > 1 detector emits the same
1093
1230
  logical link, the row also gets a `(×N)` suffix.
1094
1231
 
1095
- `--json` output is byte-identical to before — raw rows, no merge.
1096
- Storage is byte-identical to before. The grouping is purely a
1097
- read-time presentation choice for human eyes.
1232
+ `--json` output is byte-identical to before — raw rows, no merge.
1233
+ Storage is byte-identical to before. The grouping is purely a
1234
+ read-time presentation choice for human eyes.
1098
1235
 
1099
1236
  **Spec changes (patch)**:
1100
1237
 
package/architecture.md CHANGED
@@ -78,12 +78,16 @@ Each plugin (and each built-in bundle) declares a **granularity** that controls
78
78
 
79
79
  ### `RunnerPort`
80
80
 
81
- Executes an action against a job file. Returns a report reference (or an error) plus runner-side metrics (duration, tokens, exit code).
81
+ Executes an action against rendered job content. Returns the produced report (or an error) plus runner-side metrics (duration, tokens, exit code).
82
82
 
83
- Operations: `run(jobFilePath, options)` → `{ reportPath, tokensIn, tokensOut, durationMs, exitCode } | Error`.
83
+ Operations: `run(jobContent, options)` → `{ report, tokensIn, tokensOut, durationMs, exitCode } | Error`.
84
+
85
+ `jobContent` is a string: the kernel reads `state_job_contents` for the job and passes the content directly. There is no on-disk job file as part of the contract — runners that need an actual file (the `claude -p` subprocess, for example) materialize a temporary file inside `run()` and remove it after spawn. The temp file is operational, not normative.
86
+
87
+ `report` is the parsed JSON the runner produced; the kernel ingests it into `state_executions.report_json`. Path-based reporting is not part of the port contract.
84
88
 
85
89
  Two reference implementations:
86
- - `ClaudeCliRunner` — subprocess `claude -p < jobfile`.
90
+ - `ClaudeCliRunner` — subprocess `claude -p` with the content piped into a temp file or stdin.
87
91
  - `MockRunner` — deterministic fake for tests.
88
92
 
89
93
  The **Skill agent** does NOT implement this port: it is a peer driving adapter (alongside CLI and Server) that runs inside an LLM session and consumes `sm job claim` + `sm record` as a kernel client. The name "Skill runner" is descriptive, not structural — only the `ClaudeCliRunner` (and its test fake) implement `RunnerPort`. See [`job-lifecycle.md`](./job-lifecycle.md).
package/cli-contract.md CHANGED
@@ -123,7 +123,8 @@ Diagnostic report:
123
123
  - DB file integrity (PRAGMA quick_check equivalent).
124
124
  - Pending migrations (count + list).
125
125
  - Orphan history rows (count).
126
- - Orphan job files (count).
126
+ - `state_jobs` rows whose `content_hash` is missing from `state_job_contents` (corrupt-state count).
127
+ - `state_job_contents` GC stragglers (count of rows referenced by zero `state_jobs` rows; `sm job prune` collects these).
127
128
  - Plugins in error state (list).
128
129
  - LLM runner availability (`claude` binary on PATH, version).
129
130
  - Detected Providers that matched nothing, or whose `explorationDir` does not exist on disk (non-blocking warning).
@@ -235,15 +236,14 @@ See `job-lifecycle.md` for the state machine; this table is the CLI surface.
235
236
  | `sm job submit ... --priority <n>` | Override job priority. Integer; higher runs first. Default `0`. Negative allowed (deprioritize). Frozen on `state_jobs.priority` at submit time. |
236
237
  | `sm job list [--status ...] [--action ...] [--node ...]` | List jobs. |
237
238
  | `sm job show <job.id>` | Detail: current state, claim timestamp, TTL remaining, runner, content hash. |
238
- | `sm job preview <job.id>` | Render the job MD file without executing. |
239
- | `sm job claim [--filter <action>]` | Atomic primitive: return next queued job id, mark it running. Exit 0 with id on stdout; exit 1 if queue empty. |
239
+ | `sm job preview <job.id>` | Print the rendered MD content of the job without executing. Reads from `state_job_contents`; there is no on-disk artifact. |
240
+ | `sm job claim [--filter <action>]` | Atomic primitive: return next queued job id, mark it running. Exit 0 with id on stdout; exit 1 if queue empty. `--json` returns `{id, nonce, content}` — drivers that intend to call `sm record` afterwards MUST use the `--json` form to receive the nonce. |
240
241
  | `sm job run` | Full CLI-runner loop: claim + spawn + record. Runs one job. |
241
242
  | `sm job run --all` | Drain the queue (sequential through `v1.0`; in-runner parallelism deferred). |
242
243
  | `sm job run --max N` | Drain at most N jobs. |
243
244
  | `sm job status [<job.id>]` | Counts (per status) or single-job status. |
244
245
  | `sm job cancel <job.id> \| --all` | Force a running job to `failed` state with reason `user-cancelled`. `--all` cancels every `queued` and `running` job. |
245
- | `sm job prune` | Retention GC for completed/failed jobs (per config policy). |
246
- | `sm job prune --orphan-files` | Remove MD files with no matching DB row. |
246
+ | `sm job prune` | Retention GC: deletes terminal jobs past the configured retention window AND collects orphaned `state_job_contents` rows in the same transaction. |
247
247
 
248
248
  Submit returns the job id on stdout in pretty mode, or a `Job` object conforming to `job.schema.json` in `--json` mode.
249
249
 
@@ -253,12 +253,12 @@ Submit returns the job id on stdout in pretty mode, or a `Job` object conforming
253
253
 
254
254
  ```
255
255
  sm record --id <job.id> --nonce <n> --status completed \
256
- --report <path> \
256
+ --report <path-or-dash> \
257
257
  --tokens-in N --tokens-out N --duration-ms N \
258
258
  --model <name>
259
259
  ```
260
260
 
261
- Closes a running job with success.
261
+ Closes a running job with success. `--report` accepts either a filesystem path the kernel reads, or `-` to read the JSON payload from stdin. The kernel stores the parsed JSON inline on `state_executions.report_json`; the path / stdin source is ingestion-only and not retained.
262
262
 
263
263
  ```
264
264
  sm record --id <job.id> --nonce <n> --status failed --error "..."
@@ -316,7 +316,55 @@ Destructive verbs (`reset --state`, `reset --hard`, `restore`) require interacti
316
316
 
317
317
  | Command | Purpose |
318
318
  |---|---|
319
- | `sm serve [--port N] [--host ...] [--no-open]` | Start Hono + WebSocket for the Web UI. Default port is implementation-defined but MUST be the same across runs. Implementations MUST NOT bind 0.0.0.0 by default. |
319
+ | `sm serve [--port N] [--host ...] [--scope project\|global] [--db <path>] [--no-built-ins] [--no-plugins] [--open\|--no-open] [--dev-cors] [--ui-dist <path>]` | Start Hono + WebSocket for the Web UI. Single-port mandate: SPA + REST + WS under one listener. Default port 4242, default host 127.0.0.1 (loopback-only through v0.6.0; multi-host deferred — see §Server). |
320
+
321
+ #### Server
322
+
323
+ *(Stability: experimental — locks at v0.6.0.)*
324
+
325
+ The reference implementation ships a Hono BFF rooted at `src/server/`. One Node process serves the Angular SPA, the REST API under `/api/*`, and the WebSocket at `/ws` — single-port mandate, no proxy. Loopback-only assumption through v0.6.0: no per-connection auth on `/ws`; combining `--dev-cors` with a non-loopback `--host` is rejected (exit 2).
326
+
327
+ **Boot resilience**: `sm serve` boots even when the project DB is missing. `/api/health` reports `db: 'missing'` so the SPA can render an empty-state CTA instead of failing the connection. Explicit `--db <path>` that doesn't exist is the exception — that exits 5 (NotFound) per `§Exit codes`.
328
+
329
+ **Endpoints (v14.1 surface)**:
330
+
331
+ | Path | Status | Shape |
332
+ |---|---|---|
333
+ | `GET /api/health` | implemented | `{ ok: true, schemaVersion, specVersion, implVersion, scope: 'project'\|'global', db: 'present'\|'missing' }` |
334
+ | `ALL /api/*` (other) | reserved | structured 404 envelope (see below); real endpoints land at v14.2 |
335
+ | `GET /ws` | upgrade-only | accepts WebSocket upgrade and immediately closes; broadcaster lands at v14.4 |
336
+ | `GET *` | implemented | static asset from the resolved UI bundle, falling back to `index.html` for SPA deep links |
337
+
338
+ **Error envelope** (mirrors `§Machine-readable output rules`):
339
+
340
+ ```json
341
+ {
342
+ "ok": false,
343
+ "error": {
344
+ "code": "not-found" | "bad-query" | "db-missing" | "internal",
345
+ "message": "<human-readable>",
346
+ "details": { ... } | null
347
+ }
348
+ }
349
+ ```
350
+
351
+ HTTP status mapping: `400` → `bad-query`, `404` → `not-found`, `500` → `internal` / `db-missing`.
352
+
353
+ **Flag surface**:
354
+
355
+ | Flag | Default | Purpose |
356
+ |---|---|---|
357
+ | `--port N` | `4242` | Listening port. `0` = OS-assigned (handle reports the bound port). |
358
+ | `--host <ip>` | `127.0.0.1` | Listening host. Implementations MUST NOT bind `0.0.0.0` by default. |
359
+ | `--scope project\|global` | `project` | Effective scope for `/api/*` reads. Alias for `-g/--global`. |
360
+ | `--db <path>` | resolved per spec § Global flags | Override the DB file location. Missing explicit `--db` exits 5. |
361
+ | `--no-built-ins` | off | Skip built-in plugin registration (parity with `sm scan --no-built-ins`). |
362
+ | `--no-plugins` | off | Skip drop-in plugin discovery. |
363
+ | `--open` / `--no-open` | `--open` | Auto-open the SPA in the user's default browser after listen. |
364
+ | `--dev-cors` | off | Enable permissive CORS for the Angular dev-server proxy workflow. Loopback-only when set. |
365
+ | `--ui-dist <path>` | auto | Override the UI bundle directory. Hidden flag — used by the demo build pipeline + tests; everyday users never need it. |
366
+
367
+ **Graceful shutdown**: SIGINT / SIGTERM trigger a graceful close; the verb returns exit 0 on clean shutdown. Bind failure (port in use, EACCES) returns exit 2.
320
368
 
321
369
  ---
322
370
 
@@ -78,8 +78,7 @@ A case is a JSON document with this shape:
78
78
  "assertions": [
79
79
  { "type": "exit-code", "value": 0 },
80
80
  { "type": "json-path", "path": "$.schemaVersion", "equals": 1 },
81
- { "type": "file-exists", "path": ".skill-map/jobs/*.md" },
82
- { "type": "file-contains-verbatim", "path": ".skill-map/jobs/*.md", "fixture": "preamble-v1.txt" }
81
+ { "type": "stdout-contains-verbatim", "fixture": "preamble-v1.txt" }
83
82
  ]
84
83
  }
85
84
  ```
@@ -125,7 +124,7 @@ Cases explicitly referenced elsewhere in the spec (landing before v1.0):
125
124
 
126
125
  | Id | Source | Verifies |
127
126
  |---|---|---|
128
- | `preamble-bitwise-match` | `prompt-preamble.md` | Rendered job files contain `preamble-v1.txt` byte-for-byte. Deferred to Step 10 (requires `sm job preview`). |
127
+ | `preamble-bitwise-match` | `prompt-preamble.md` | Rendered job content (printed by `sm job preview`) contains `preamble-v1.txt` byte-for-byte. Deferred to Step 10 (requires `sm job preview`). |
129
128
 
130
129
  ### Provider-owned (per `<plugin-dir>/conformance/`)
131
130
 
@@ -43,7 +43,7 @@ These have their own conformance cases even though they are not JSON Schemas.
43
43
 
44
44
  | # | Artifact | Case | Status | Notes |
45
45
  |---|---|---|---|---|
46
- | A | Preamble verbatim text | `preamble-bitwise-match` | 🟠 deferred | Deferred to Step 10 (needs `sm job preview` to render a job file). Fixture: `fixtures/preamble-v1.txt` (already present, byte-identical to `prompt-preamble.md` source). |
46
+ | A | Preamble verbatim text | `preamble-bitwise-match` | 🟠 deferred | Deferred to Step 10 (needs `sm job preview` to print the rendered content from `state_job_contents`). Fixture: `fixtures/preamble-v1.txt` (already present, byte-identical to `prompt-preamble.md` source). |
47
47
  | B | Kernel empty-boot invariant | `kernel-empty-boot` | 🟢 covered | All extensions disabled → empty ScanResult. |
48
48
  | C | Atomic-claim race safety | — | 🔴 missing | Blocked by Step 10. Two concurrent `sm job claim` invocations against a single queued row — exactly one MUST succeed. |
49
49
  | D | Duplicate detection | — | 🔴 missing | Blocked by Step 10. Two `sm job submit` with same `(action, version, node, contentHash)` — second exits 3. |
package/db-schema.md CHANGED
@@ -232,7 +232,6 @@ Matching [`schemas/job.schema.json`](./schemas/job.schema.json). See [`job-lifec
232
232
  | `failure_reason` | TEXT | NULL, CHECK in (`runner-error`, `report-invalid`, `timeout`, `abandoned`, `job-file-missing`, `user-cancelled`) |
233
233
  | `runner` | TEXT | NULL, CHECK in (`cli`, `skill`, `in-process`) |
234
234
  | `ttl_seconds` | INTEGER | NOT NULL |
235
- | `file_path` | TEXT | NULL |
236
235
  | `created_at` | INTEGER | NOT NULL |
237
236
  | `claimed_at` | INTEGER | NULL |
238
237
  | `finished_at` | INTEGER | NULL |
@@ -241,6 +240,28 @@ Matching [`schemas/job.schema.json`](./schemas/job.schema.json). See [`job-lifec
241
240
 
242
241
  Indexes: `ix_state_jobs_status`, `ix_state_jobs_action_node_hash` (unique partial index WHERE `status IN ('queued','running')` for duplicate detection).
243
242
 
243
+ The rendered job content is NOT stored on this table. It lives in `state_job_contents` keyed by `content_hash` so multiple jobs with identical action + node + template pairs share a single physical blob. See `state_job_contents` below for the storage shape and GC contract.
244
+
245
+ ### `state_job_contents`
246
+
247
+ Content-addressed store for the rendered MD content of every queued or completed job. Decouples the content from the lifecycle row in `state_jobs` so that retries / `--force` reruns / cross-node fan-out emissions of the same prompt all reference one blob.
248
+
249
+ | Column | Type | Constraint |
250
+ |---|---|---|
251
+ | `content_hash` | TEXT | PRIMARY KEY |
252
+ | `content` | TEXT | NOT NULL |
253
+ | `created_at` | INTEGER | NOT NULL |
254
+
255
+ No indexes (PK already covers lookup by hash; the table is keyed-by-hash exclusively).
256
+
257
+ **Insertion semantics**: `INSERT OR IGNORE INTO state_job_contents(content_hash, content, created_at) VALUES (?, ?, ?)` — an existing row for the same hash is a no-op (the prior insert already paid the storage cost).
258
+
259
+ **GC contract**: `sm job prune` MUST delete every row whose `content_hash` is no longer referenced by any `state_jobs` row, in the same transaction that prunes the job rows. Implementations MUST NOT delete `state_job_contents` rows on `sm job cancel` (a cancelled job's content is recoverable via `sm job submit --force` of the same content_hash and dedup is desirable).
260
+
261
+ `content_hash` is the same hash that `state_jobs.content_hash` carries, computed at submit time as `sha256(actionId + actionVersion + bodyHash + frontmatterHash + promptTemplateHash)`. Two jobs with identical `content_hash` MUST render to identical content (the formula is deterministic over all rendering inputs); the table relies on this invariant to dedup.
262
+
263
+ Honest note on FK enforcement: SQLite foreign keys are off by default and the kernel does not currently turn them on (per `dialect.ts`). The `state_jobs.content_hash → state_job_contents.content_hash` relationship is enforced procedurally by the storage adapter (insert content row before job row in the same transaction; never delete content while jobs reference it). A future foreign-key push may upgrade this to a true FK without breaking the contract.
264
+
244
265
  ### `state_executions`
245
266
 
246
267
  Matching [`schemas/execution-record.schema.json`](./schemas/execution-record.schema.json).
@@ -262,11 +283,13 @@ Matching [`schemas/execution-record.schema.json`](./schemas/execution-record.sch
262
283
  | `duration_ms` | INTEGER | NULL |
263
284
  | `tokens_in` | INTEGER | NULL |
264
285
  | `tokens_out` | INTEGER | NULL |
265
- | `report_path` | TEXT | NULL |
286
+ | `report_json` | TEXT | NULL |
266
287
  | `job_id` | TEXT | NULL |
267
288
 
268
289
  Indexes: `ix_state_executions_extension_id`, `ix_state_executions_started_at`, `ix_state_executions_job_id`.
269
290
 
291
+ The full report payload (the JSON the model returned, validated against the action's `reportSchemaRef`) is stored inline in `report_json`. There is no on-disk report file. `sm job show <id>` and `sm history --json` read the column directly.
292
+
270
293
  ### `state_summaries`
271
294
 
272
295
  One row per `(node_id, summarizer_action_id)`. See [`schemas/summaries/`](./schemas/summaries/).
@@ -463,11 +486,11 @@ Both verbs operate on FK ownership only; neither edits files on disk.
463
486
  - DB file exists and is readable.
464
487
  - `PRAGMA quick_check` (or equivalent) returns OK.
465
488
  - Applied migration version matches code-bundled migrations.
466
- - No orphan job files (`.skill-map/jobs/*.md` without a matching DB row).
467
- - No orphan DB rows (jobs whose `file_path` does not exist).
489
+ - No `state_jobs` rows whose `content_hash` is missing from `state_job_contents` (corrupt state — the content row was deleted out from under a live job).
490
+ - No `state_job_contents` rows whose `content_hash` is referenced by zero `state_jobs` rows (GC stragglers `sm job prune` should have collected these).
468
491
  - No plugin in `load-error` or `incompatible-spec` status.
469
492
 
470
- Failures are reported with suggested remediation (e.g., "run `sm db migrate`", "run `sm job prune --orphan-files`").
493
+ Failures are reported with suggested remediation (e.g., "run `sm db migrate`", "run `sm job prune`").
471
494
 
472
495
  ---
473
496
 
package/index.json CHANGED
@@ -166,27 +166,27 @@
166
166
  }
167
167
  ]
168
168
  },
169
- "specPackageVersion": "0.10.0",
169
+ "specPackageVersion": "0.12.0",
170
170
  "integrity": {
171
171
  "algorithm": "sha256",
172
172
  "files": {
173
- "CHANGELOG.md": "a7712801b4513c3212cbefb8b1e7540accd40e22c4aa09234b98f6b044bd42c9",
173
+ "CHANGELOG.md": "c85703fa37d3c084e2251c0b77626c94fa5c6897289c1534432ae45ac168762b",
174
174
  "README.md": "bd30780525e75379eaeb5f8a903bdc601daf3862f3ec69dffc96c437e8d476fc",
175
- "architecture.md": "c69a50e3e9b7d091799bd19cd9efe854a924c83bc2c8e79e0fcb727196151f6c",
176
- "cli-contract.md": "1d09d047e07fd8793968259660012ebf64ab136967afead2d2666a59a40a020a",
177
- "conformance/README.md": "07970f06c467e34413f07f6d8bec09ece892d1a903fa039d25b34a77e91187d2",
175
+ "architecture.md": "9a6d96d150af60ed8d476af572d07dcce605f116fde720bebb2662b11250bf4b",
176
+ "cli-contract.md": "89dcd366624821c1ab77d1014229b4c209953f337d0c62d16b24472c64c3bad4",
177
+ "conformance/README.md": "838b1247e1ffb402d96bd8a0fe9c1c0f4a99ed0fbc4bf8156f7a58330cac27f5",
178
178
  "conformance/cases/kernel-empty-boot.json": "ad4bbe9d637537625025c8bdb61285b1433568a2544b1ce0248f304ccff8c350",
179
- "conformance/coverage.md": "eb57cd979bca59a252e3cd49796e068ac601169f859f32cdf37634486574c44c",
179
+ "conformance/coverage.md": "4df23b78ec44887dc355e0622b9008bb2514f3b8e9c302db18eb51532fda5275",
180
180
  "conformance/fixtures/preamble-v1.txt": "1e0aeef224b64477bdc13a949c3ad402e68249caf499ecdba1302371677c068b",
181
- "db-schema.md": "6542311118d2e74bdb7c7a48c208eccd5e55cf574f7cb38e976d6d7c3c666745",
181
+ "db-schema.md": "cbd2d3395ca4f01065d6f15405c7442d59a468b15448377d6f9373fa6aeff334",
182
182
  "interfaces/security-scanner.md": "4a982667008f233656f44c61ce9948e062432d3debdcbf7a134da03bd4139d7d",
183
- "job-events.md": "831501bd696a2801e2d160b314eb49794d0ba553da4487e15c7dcc72a1c230f6",
184
- "job-lifecycle.md": "1fe88b1a2ed204e41bb41ac172fbb3e912dccd0dd8a1f8ea8e21a681b336d6ee",
183
+ "job-events.md": "8f371e0991816eca2e1a55cbd8a50733546ca5e7c861588048c18be1d22dbd57",
184
+ "job-lifecycle.md": "12bfc27690c92cf93682a3b6fbfeb7e2d252d33f704fd2d7de9a13db713e6281",
185
185
  "plugin-author-guide.md": "2dcdf8c570342d94c2c8f8d47594715254e3956d7939443023f1d6420e2b30d0",
186
186
  "plugin-kv-api.md": "04b2178f46fb88adeae9240df9c9e1761b660396072001dac32cd402e11a2d7d",
187
- "prompt-preamble.md": "23a8eff0477fbbc46192a27781bc781bda4202bb9c669b7a7a002b0d668146b0",
187
+ "prompt-preamble.md": "fb40ab510234383326f198dec82cd6d744f28b7432eebac6cbfbb7ca1c483b7d",
188
188
  "schemas/conformance-case.schema.json": "7cd0f3aae5124f24be57cddb213d002d0466f79d06fd3da896075c8b28650410",
189
- "schemas/execution-record.schema.json": "607e939bfcac4e18385ef93e27bbe28987ba35d5a7c67f3d6e4377ca819a9425",
189
+ "schemas/execution-record.schema.json": "9628fa557cb856402f3a5f1d1167c609e46a197c850fe8171abfddd46c1028a8",
190
190
  "schemas/extensions/action.schema.json": "262272175c06a2e33c08f819a45c3ef8260276c91a9d0542fdffc932aeb32db7",
191
191
  "schemas/extensions/base.schema.json": "e5c3406b88b0496a89791890b05083929429319d96b1f8cea0bec3ec9e3de8af",
192
192
  "schemas/extensions/extractor.schema.json": "122d3f81ef91edcde9798e7dc8fcbf442a2996deea65aa4b03c9d5cb01ba2519",
@@ -197,7 +197,7 @@
197
197
  "schemas/frontmatter/base.schema.json": "dfee192458765b8cb872ef9e7145ec31b9e07ceb19ee44be48af2172329e7a38",
198
198
  "schemas/history-stats.schema.json": "23f472d1de06d23fc775aabba821f8375f347af4dc8d89ba567980d61a11f9de",
199
199
  "schemas/issue.schema.json": "40f6f8abadcce0fd8eac9df27ffcc20b2fc9fda6970142ddb8e7e56b1760b9b1",
200
- "schemas/job.schema.json": "582999899f8846f70c4d745d2813e53b97a4f5ccd3d8d163eeb68b201e7124e4",
200
+ "schemas/job.schema.json": "ffbdd51c54b487c44eb57fabd07f624ac1030c14ef69b46933c154092853a84c",
201
201
  "schemas/link.schema.json": "0a95a24849a38b9ef5bad5361519a9f9e012b5bc3001289fad29d0851fceff6b",
202
202
  "schemas/node.schema.json": "f9a0cc5d5a7f581915719e91ae401e46a82688e0865fd3a2eb64bd42798a6b2b",
203
203
  "schemas/plugins-registry.schema.json": "5ca1d4970ae64f064f05c237a649d9f82d5edbeb7c121ec50cb4aaca13f4bc51",
package/job-events.md CHANGED
@@ -145,7 +145,7 @@ Emitted when a drain attempts to claim but finds no eligible job.
145
145
 
146
146
  ### `job.spawning`
147
147
 
148
- Emitted when the runner is about to execute the job file.
148
+ Emitted when the runner is about to execute the job content.
149
149
 
150
150
  ```json
151
151
  {
@@ -156,12 +156,12 @@ Emitted when the runner is about to execute the job file.
156
156
  "data": {
157
157
  "runner": "cli | skill | in-process",
158
158
  "command": "claude -p",
159
- "jobFilePath": ".skill-map/jobs/d-20260420-143055-b001.md"
159
+ "contentHash": "0a3f…"
160
160
  }
161
161
  }
162
162
  ```
163
163
 
164
- `command` is implementation-defined free-form; it is descriptive, not invokable.
164
+ `command` is implementation-defined free-form; it is descriptive, not invokable. `contentHash` references the row in `state_job_contents` the runner is about to execute against — useful for downstream observers that want to correlate the spawn with the rendered content (which is in DB, not on disk).
165
165
 
166
166
  > **Hookable** — see [`architecture.md` §Hook · curated trigger set](./architecture.md#hook--curated-trigger-set). Plugins MAY subscribe a `hook` extension to this event for pre-flight checks or audit logging. Reactions only — hooks cannot block the spawn.
167
167
 
@@ -197,11 +197,13 @@ Emitted inside `sm record` when the callback arrives and passes nonce validation
197
197
  "data": {
198
198
  "status": "completed | failed",
199
199
  "model": "claude-opus-4-7",
200
- "reportPath": ".skill-map/reports/d-20260420-143055-b001.json"
200
+ "executionId": "e-20260420-143104-b001"
201
201
  }
202
202
  }
203
203
  ```
204
204
 
205
+ `executionId` references the just-written `state_executions` row whose `report_json` carries the report payload. Consumers that need the content fetch it via `sm history --json` or directly from the DB; the event itself stays small.
206
+
205
207
  `runId` on this event is the run that originally claimed the job. If the record is called from outside a CLI run — the canonical case being a Skill agent that called `sm job claim` + `sm record` without ever entering `sm job run` — the kernel MUST synthesize a `runId` of the form `r-ext-YYYYMMDD-HHMMSS-XXXX` (same timestamp + 4-hex shape as real run ids, with the `r-ext-` prefix reserved for externally-driven claims).
206
208
 
207
209
  Synthetic-run envelope: when a Skill agent claims a job, the kernel MUST emit — on the server's WebSocket and in the `--json` ndjson stream if active — a full envelope covering that claim:
@@ -232,11 +234,13 @@ Emitted when a job transitions to `completed`.
232
234
  "tokensIn": 2431,
233
235
  "tokensOut": 1072,
234
236
  "model": "claude-opus-4-7",
235
- "reportPath": ".skill-map/reports/d-20260420-143055-b001.json"
237
+ "executionId": "e-20260420-143104-b001"
236
238
  }
237
239
  }
238
240
  ```
239
241
 
242
+ `executionId` references the `state_executions` row that holds the report payload (in `report_json`). The full report is intentionally NOT inlined in the event — keep events small and let consumers query the row when they want the body.
243
+
240
244
  > **Hookable** — see [`architecture.md` §Hook · curated trigger set](./architecture.md#hook--curated-trigger-set). The most common hookable event: notification, billing, downstream dispatch.
241
245
 
242
246
  ### `job.failed`
package/job-lifecycle.md CHANGED
@@ -39,7 +39,7 @@ Terminal states: `completed`, `failed`. Once terminal, a job MUST NOT transition
39
39
  | `queued` | `running` | Atomic claim by a runner. |
40
40
  | `queued` | `failed` | `sm job cancel <id>` (reason `user-cancelled`). |
41
41
  | `running` | `completed` | `sm record --status completed` with valid nonce. |
42
- | `running` | `failed` | `sm record --status failed`, OR TTL expired (reason `abandoned`), OR runner subprocess returned non-zero (reason `runner-error`), OR report failed schema validation (reason `report-invalid`), OR job file missing at runtime (reason `job-file-missing`). |
42
+ | `running` | `failed` | `sm record --status failed`, OR TTL expired (reason `abandoned`), OR runner subprocess returned non-zero (reason `runner-error`), OR report failed schema validation (reason `report-invalid`), OR rendered content row missing at runtime (reason `job-file-missing` — historically named for the on-disk artifact; now refers to a missing `state_job_contents` row, a DB-corruption-only state since the runtime invariant is that `state_jobs.content_hash` always resolves). |
43
43
 
44
44
  Any other transition attempt MUST be rejected and MUST NOT mutate state. Implementations SHOULD log the attempt.
45
45
 
@@ -56,8 +56,8 @@ Any other transition attempt MUST be rejected and MUST NOT mutate state. Impleme
56
56
  5. Compute `ttlSeconds` per §TTL resolution below. Frozen on `state_jobs.ttlSeconds` for the life of this job.
57
57
  6. Resolve `priority` (integer, default `0`). Precedence (lowest → highest): action manifest `defaultPriority` → user config `jobs.perActionPriority.<actionId>` → flag `--priority <n>`. Higher runs first; ties broken by `createdAt ASC`. Negative values are permitted and run after the default bucket. The resolved value is frozen on `state_jobs.priority` at submit time and is immutable for the life of the job.
58
58
  7. Generate `nonce` (implementation-chosen; MUST be cryptographically random, ≥ 128 bits of entropy).
59
- 8. Render the job file at `.skill-map/jobs/<id>.md`, applying the canonical preamble (see [`prompt-preamble.md`](./prompt-preamble.md)).
60
- 9. Insert a row in `state_jobs` with `status = 'queued'`, `createdAt = now`.
59
+ 8. Render the rendered job content (canonical preamble + action template + interpolated user content per [`prompt-preamble.md`](./prompt-preamble.md)) and write it to `state_job_contents` via `INSERT OR IGNORE` keyed by `content_hash`. Multiple `state_jobs` rows MAY share the same `content_hash` row: the content is stored exactly once and refcounted by reference. Implementations MUST NOT persist the rendered content to a filesystem path — the DB row is the canonical artifact.
60
+ 9. Insert a row in `state_jobs` with `status = 'queued'`, `createdAt = now`. The row's `content_hash` references the just-stored `state_job_contents.content_hash`. Steps 8 and 9 MUST run inside one transaction.
61
61
  10. Return the job id.
62
62
 
63
63
  `--all` fans out one job per node matching the action's `preconditions`. Each fan-out job is independent: some may be duplicates and be refused, others succeed. The CLI reports a summary.
@@ -91,6 +91,8 @@ The second `AND status = 'queued'` guards against a race where two runners selec
91
91
 
92
92
  `sm job claim` exposes this primitive to Skill agents (and any driving adapter that wants to drain from outside a CLI-runner loop): returns the id on stdout (exit 0) or exits 1 if the queue is empty.
93
93
 
94
+ In `--json` mode, `sm job claim` instead returns the document `{ "id": "<id>", "nonce": "<nonce>", "content": "<rendered MD content>" }`. Drivers MUST use the `--json` form when they intend to call `sm record` afterwards: the nonce is the sole credential the callback verb checks, and embedding it in the claim's structured response is the contracted handover. The plain stdout form (id only) is preserved for legacy scripts that just want to know what id was claimed.
95
+
94
96
  ---
95
97
 
96
98
  ## TTL and auto-reap
@@ -165,13 +167,15 @@ Negative or zero values MUST be rejected with exit 2 at submit time.
165
167
  1. Load the job by id. If not found → exit 5.
166
168
  2. Compare the supplied nonce against `state_jobs.nonce`. Mismatch → exit 4 without mutation.
167
169
  3. If `state_jobs.status != 'running'` → exit 2 with message "job not in running state". This catches late callbacks after a reap.
168
- 4. If `--status completed`: validate the report file against the action's declared report schema. On validation failure → transition to `failed` with reason `report-invalid`; DO NOT stay `running`.
169
- 5. Write the execution record (see [`schemas/execution-record.schema.json`](./schemas/execution-record.schema.json)) with the full metrics.
170
+ 4. If `--status completed`: read the report payload from the path passed to `--report` (the path is implementation-input only; the kernel reads its contents and stores them inline — there is no canonical on-disk report artifact), validate the parsed JSON against the action's declared report schema. On validation failure → transition to `failed` with reason `report-invalid`; DO NOT stay `running`.
171
+ 5. Write the execution record (see [`schemas/execution-record.schema.json`](./schemas/execution-record.schema.json)) with the full metrics. The report payload (if any) is stored inline in `state_executions.report_json` as the parsed JSON; the input path is NOT retained.
170
172
  6. Transition the job to the terminal state.
171
173
  7. Emit `job.callback.received` followed by `job.completed` or `job.failed` (see [`job-events.md`](./job-events.md)).
172
174
 
173
175
  The nonce is the sole authentication factor. A compromised nonce allows forged callbacks for that single job. Nonces MUST be generated per-job; never reused; never logged at info level or above.
174
176
 
177
+ `--report` accepts either a file path or `-` (stdin). Drivers MAY choose either form; the kernel ingests both into `report_json` identically. The on-disk file the runner authored is ephemeral — implementations SHOULD remove it after the kernel acknowledges the callback (this is a courtesy GC, not a normative requirement).
178
+
175
179
  ---
176
180
 
177
181
  ## Duplicate prevention rationale
@@ -204,11 +208,9 @@ Implementations MUST handle each of the following:
204
208
 
205
209
  | Scenario | Required handling |
206
210
  |---|---|
207
- | DB says `queued` or `running`, but the job MD file is missing on disk. | Mark `failed` with `failureReason = job-file-missing`. `sm doctor` MUST report these proactively. |
208
- | MD file present in `.skill-map/jobs/`, no matching DB row. | `sm doctor` MUST list them. Implementations MUST NOT auto-delete. `sm job prune --orphan-files` removes them explicitly. |
209
- | User edited the MD file between submit and run. | By design: the runner uses the current file contents. The user owns the consequences. Event stream MAY note the mtime change. |
210
- | Job `completed`, MD file still present. | Normal. Retention policy (`sm job prune` per `jobs.retention.*` config) eventually cleans up. |
211
- | Runner crashes between `claim` and reading the file. | Covered by TTL/reap: when `expiresAt` passes, the next reap marks the job `failed` with `abandoned`. |
211
+ | `state_jobs` row exists but its `content_hash` is missing from `state_job_contents` (DB corruption — the content row was deleted by external means). | Mark `failed` with `failureReason = job-file-missing`. `sm doctor` MUST report these proactively. The kernel does NOT itself produce this state under normal operation; the contract is that submit and prune both keep the two tables consistent. The legacy enum name `job-file-missing` is preserved across the disk-to-DB shift to keep the failure-reason vocabulary backward-compatible — the semantic now refers to a missing content row rather than a missing on-disk file. |
212
+ | `state_job_contents` row references no live `state_jobs` row (GC straggler). | `sm doctor` MUST list them. `sm job prune` MUST collect them in the same transaction that prunes terminal jobs. |
213
+ | Runner crashes between `claim` and reading the content. | Covered by TTL/reap: when `expiresAt` passes, the next reap marks the job `failed` with `abandoned`. |
212
214
  | Callback arrives after reap already failed the job. | Reject with exit 2 (see Record step 3). The runner should treat this as an error and log it. |
213
215
 
214
216
  ---
@@ -234,13 +236,15 @@ Config controls (`jobs.retention.completed`, `jobs.retention.failed`):
234
236
 
235
237
  `sm job prune` applies retention. Implementations MAY run this on a schedule (e.g., on `sm doctor`, or in a cron adapter) but MUST NOT prune implicitly during normal verb execution.
236
238
 
239
+ `sm job prune` MUST also collect orphaned `state_job_contents` rows (no live `state_jobs` references) in the same transaction that prunes terminal jobs. The natural ordering is: delete terminal `state_jobs` rows in the retention window, then delete `state_job_contents` rows whose `content_hash` no longer appears in any `state_jobs` row. This keeps the two tables consistent without separate verbs.
240
+
237
241
  ---
238
242
 
239
243
  ## See also
240
244
 
241
245
  - [`architecture.md`](./architecture.md) — `RunnerPort` definition; driving-adapter peer rule for Skill agents.
242
246
  - [`job-events.md`](./job-events.md) — canonical event stream emitted during job execution.
243
- - [`prompt-preamble.md`](./prompt-preamble.md) — verbatim preamble prepended to every rendered job file.
247
+ - [`prompt-preamble.md`](./prompt-preamble.md) — verbatim preamble prepended to every rendered job content row.
244
248
  - [`db-schema.md`](./db-schema.md) — `state_jobs` and `state_executions` table catalogs.
245
249
  - [`cli-contract.md`](./cli-contract.md) — `sm job` verb surface and exit codes.
246
250
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-map/spec",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "JSON Schemas, prose contracts, and conformance suite for the skill-map specification.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  # Prompt preamble
2
2
 
3
- Canonical text the kernel prepends to every rendered job file before the action-specific template. The preamble exists to mitigate prompt injection from user-authored node content. This document defines:
3
+ Canonical text the kernel prepends to every rendered job content blob before the action-specific template. The preamble exists to mitigate prompt injection from user-authored node content. This document defines:
4
4
 
5
5
  1. The **delimiter contract** that wraps user content.
6
6
  2. The **verbatim preamble text** (the only normative text in the spec).
@@ -116,7 +116,7 @@ On `sm job submit`:
116
116
  2. The kernel validates that the template does not interpolate user text outside of `<user-content>` blocks.
117
117
  3. The kernel prepends the verbatim preamble text above.
118
118
  4. The kernel renders the template by interpolating the node content, wrapping it in `<user-content>`.
119
- 5. The kernel writes the result to `.skill-map/jobs/<id>.md`.
119
+ 5. The kernel stores the result in `state_job_contents` keyed by `contentHash` (content-addressed: multiple jobs that resolve to the same `contentHash` share one row). There is no canonical filesystem artifact — `sm job preview` and `sm job claim --json` both read directly from this table. Subprocess runners that need a file (e.g., `claude -p` reading stdin from a path) materialize a temporary file from the DB row and remove it after spawn; the temp file is operationally ephemeral, not part of the contract.
120
120
  6. The kernel computes `contentHash` over (among other things) the concatenation of preamble + template. A changed preamble (e.g., spec bump) MUST produce a different hash and therefore MUST NOT collide with prior jobs.
121
121
 
122
122
  Implementations MUST NOT modify the preamble text at runtime (e.g., based on locale, model, or config). The text is universal and invariant.
@@ -158,4 +158,4 @@ Defense-in-depth: the deterministic rule `injection-pattern` (shipped as a built
158
158
 
159
159
  ## Stability
160
160
 
161
- The verbatim text above is **stable** as of spec v1.0.0. It is reproduced in the conformance suite as [`conformance/fixtures/preamble-v1.txt`](./conformance/fixtures/preamble-v1.txt). Any implementation whose rendered job files do not contain this text verbatim fails the conformance check `preamble-bitwise-match`.
161
+ The verbatim text above is **stable** as of spec v1.0.0. It is reproduced in the conformance suite as [`conformance/fixtures/preamble-v1.txt`](./conformance/fixtures/preamble-v1.txt). Any implementation whose rendered job content (read via `sm job preview` or `sm job claim --json`) does not contain this text verbatim fails the conformance check `preamble-bitwise-match`.
@@ -42,7 +42,7 @@
42
42
  "failureReason": {
43
43
  "type": ["string", "null"],
44
44
  "enum": ["runner-error", "report-invalid", "timeout", "abandoned", "job-file-missing", "user-cancelled", null],
45
- "description": "Normalized reason when `status = failed` or `cancelled`. Null for `completed`."
45
+ "description": "Normalized reason when `status = failed` or `cancelled`. Null for `completed`. `job-file-missing` mirrors the same enum value on `Job.failureReason` (legacy name preserved across the disk-to-DB shift)."
46
46
  },
47
47
  "exitCode": {
48
48
  "type": ["integer", "null"],
@@ -78,7 +78,7 @@
78
78
  },
79
79
  "reportPath": {
80
80
  "type": ["string", "null"],
81
- "description": "Path to the written report, relative to scope root. Null for actions that don't produce a report file."
81
+ "description": "Legacy field preserved across the disk-to-DB shift. Under B2 (DB-only job artifacts) this is always null because reports live inline in `state_executions.report_json`. Phase A of Step 10 will rename to `report` (object/null) carrying the parsed payload; until then the field remains as documented for backward compat."
82
82
  },
83
83
  "jobId": {
84
84
  "type": ["string", "null"],
@@ -45,7 +45,7 @@
45
45
  "failureReason": {
46
46
  "type": ["string", "null"],
47
47
  "enum": ["runner-error", "report-invalid", "timeout", "abandoned", "job-file-missing", "user-cancelled", null],
48
- "description": "Populated when `status = failed`."
48
+ "description": "Populated when `status = failed`. `job-file-missing` (legacy name preserved across the disk-to-DB shift) covers DB corruption where `state_jobs.content_hash` no longer resolves in `state_job_contents` — the runtime invariant should keep this state unreachable; the enum value exists as a defensive failure-mode label."
49
49
  },
50
50
  "runner": {
51
51
  "type": ["string", "null"],
@@ -56,10 +56,6 @@
56
56
  "minimum": 1,
57
57
  "description": "Resolved TTL at submit time: max(expectedDurationSeconds × graceMultiplier, minimumTtlSeconds). Frozen."
58
58
  },
59
- "filePath": {
60
- "type": ["string", "null"],
61
- "description": "Relative path to the rendered job file (usually `.skill-map/jobs/<id>.md`). Null before rendering."
62
- },
63
59
  "createdAt": { "type": "integer", "description": "Unix ms. Submit time." },
64
60
  "claimedAt": { "type": ["integer", "null"], "description": "Unix ms. Null while queued." },
65
61
  "finishedAt": { "type": ["integer", "null"], "description": "Unix ms. Null while not terminal." },