@holmes-lab/holmes-kit 0.9.0 → 0.10.1

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
@@ -5,7 +5,68 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  <!-- @implements A-SPEC-209 -->
8
- ## [0.9.0] - 2026-09-04
8
+ ## [0.10.1] - 2026-09-04
9
+
10
+ ### Fixed
11
+
12
+ - **Antigravity hooks: 0.10.0's `command`+`args` form regressed on the real launcher — reverted to an
13
+ unquoted single command string** (REQ-541). Field measurement on Windows/agy showed the hook
14
+ launcher **ignores `args`** and runs only the `command` string: 0.10.0's `command:"node"` +
15
+ `args:[path]` launched bare `node`, which evaluated the stdin JSON payload as code
16
+ (`SyntaxError: Unexpected token ':'`, exit 1) — every tool blocked. A second measurement had already
17
+ shown it keeps quotes **literal** (the pre-0.10.0 `node "<path>"` form died with MODULE_NOT_FOUND).
18
+ The only form the launcher runs is an unquoted single string, whitespace-split. `.agents/hooks.json`
19
+ now emits `command: "node <forward-slash path>"` (no quotes, no `args`); on win32 a path containing
20
+ a space is converted to its 8.3 short name, best-effort (lookup failure keeps the original path).
21
+ `doctor` continues to judge old (args) and new (string) wirings alike. **Re-run
22
+ `holmes-kit init --target <dir> --agent antigravity --force` after upgrading** — the fix changes
23
+ what init writes, not files already on disk. *Windows/agy real runtime and the 8.3 branch remain
24
+ unverified in this environment; validated by emitted shape + a naive-split execution simulation.*
25
+
26
+ Governance UX tools for multi-agent round-trips and observability, a cross-harness approval fix, and
27
+ an Antigravity portability fix. All additive and backward-compatible; existing behaviour is unchanged
28
+ at the default settings.
29
+
30
+ ### Added
31
+
32
+ - **Governance UX tools** (REQ-538): three new read/act MCP tools plus a conflict-report enrichment.
33
+ - **`spec_unseal`** — the inverse of `spec_approve`: return a sealed (approved) spec to an editable
34
+ `draft` in one act, clearing `approved_digest` and `parent_digests`, and record `spec-unsealed`
35
+ in the provenance ledger. Un-sealing withdraws a seal, so it requires the same out-of-band
36
+ `HOLMES_APPROVAL` as retiring a sealed doc (fail-closed), and it refuses when an **approved** spec
37
+ depends on the target (naming the blockers — their `parent_digests` would go stale). Idempotent on
38
+ an already-draft spec. Distinct from `spec_retire` (which withdraws authority to `outdated`);
39
+ un-seal keeps the spec alive and editable.
40
+ - **`approval_status`** *(read-only)* — report a spec's approval/seal state without parsing files:
41
+ `{ sealed, approvedDigest?, status, parents: [{ id, status, sealed, resolved }], blockers }`.
42
+ `sealed` is the same predicate the code gate reads and `blockers` is the exact list `spec_approve`
43
+ would refuse on, so the report cannot drift from the acts it describes.
44
+ - **`ledger_timeline`** *(read-only)* — return the provenance ledger's events in time order
45
+ (`{ ts, kind, actor, summary, inputs }`), optionally narrowed to one spec, so the governance
46
+ history (approved / unsealed / retired / review-needed …) is legible at a glance. Chain-integrity
47
+ fields are projected away.
48
+ - **`spec_approve` conflict detail** — the optimistic-concurrency refusal now also carries a
49
+ structured `conflict: { id, expectedVersion, currentVersion, retry }` (the version this act read,
50
+ the version now on disk, and what to retry). The refusal itself — refuse, write nothing, the edit
51
+ wins — is unchanged; this only exposes what happened.
52
+ - **Cross-harness approval opt-out** (REQ-540): a new server-env switch **`HOLMES_ELICIT=off`** makes
53
+ the server skip the in-client `elicitInput` prompt entirely and fold to the byte-identical
54
+ refuse+enqueue path — the same path clients that do not advertise elicitation already take. With it
55
+ on, **claude/agy/codex converge on one out-of-band decision surface** (`holmes-kit approve`, whose
56
+ default readline input is robust over SSH) instead of each client's own prompt rendering. Env-only
57
+ (a session cannot set it); unset = byte-identical to before. Not an approval bypass — it routes the
58
+ prompt to the queue; approval still comes only from a grant or `HOLMES_APPROVAL`.
59
+
60
+ ### Fixed
61
+
62
+ - **Antigravity hooks survive a package path with spaces** (REQ-539): the `.agents/hooks.json`
63
+ wiring now emits `command: "node"` + `args: ["<abs path>"]` instead of a single
64
+ `node "<quoted path>"` string. agy's hook launcher tokenizes the command on whitespace **without
65
+ honoring quotes**, so a Windows default path (`C:\Program Files\nodejs\…`) split at the space and
66
+ the PreToolUse hook never loaded (the gate silently off). Passing the path as an `args` element —
67
+ the array form `mcp_config.json` already used — sidesteps tokenization on every platform. `doctor`'s
68
+ Antigravity wiring check reads `args[0]` too (older string wirings still handled). *Real Windows/agy
69
+ execution remains unverified in CI; validated by the emitted JSON shape on the dev environment.*
9
70
 
10
71
  Internalized TDD discipline — RED-first evidence enforced by a new constitution article (ART-8),
11
72
  not a prompt — plus a doctor transparency check and a BUG-1 authoring fix. All additive and
package/README.md CHANGED
@@ -18,7 +18,8 @@
18
18
 
19
19
  - 📋 **Requirements & Specification Governance**: Strict **"No Spec, No Code"** enforcement with 4-tier spec chain traceability (`REQ ➔ H-SPEC ➔ A-SPEC ➔ T-SPEC`) and `// @implements A-SPEC-XXX` code anchors (comma-lists and every anchor in a file participate in the gate).
20
20
  - 🔴 **Inbuilt TDD — RED-first, enforced not asked** *(new in 0.9.0)*: the test-first discipline is a holmes-installed `holmes-tdd-slice` skill **and** a new constitution article **ART-8**. A changed A-SPEC must show a recorded `red-assertion → green` sequence in the ledger; a `red-error` (a test that could not run) is not a valid RED, so "the covering test failed *correctly*" is judged mechanically, not on trust. `test_run` classifies each covered file (`red-assertion`/`red-error`/`green`) and records per-A-SPEC outcomes the Stop hook reads. Ships at `redFirstEvidence: track` (observe-first, non-blocking; `strict`/`off` per repo), evidence-gated and jest-only for now. A T-SPEC may also declare `kills:` mutations and `test_run --mutate` reports which SURVIVED (a coverage gap). Where superpowers *asks* for RED-first and discriminating power, holmes-kit *proves* them.
21
- - 🤖 **Autonomous Approval** *(new in 0.8.0)*: with the out-of-band `HOLMES_AUTONOMOUS_APPROVAL` switch on, an agent seals **low/mid-risk** specs itself (ledgered under an `autonomous:<client>` actor) while `gate-behavior` changes, architecture/gate/taint files, and every upstream `REQ`/`H-SPEC`/`C-SPEC` still ask a human through the in-session TUI. The switch is env-only; a session cannot set it (blocked like `HOLMES_ROLE`). Off = byte-identical to before.
21
+ - 🧰 **Governance UX tools** *(new in 0.10.0)*: `spec_unseal` (the inverse of `spec_approve` return a sealed spec to editable `draft` in one act, out-of-band approval required, refuses approved dependents), `approval_status` and `ledger_timeline` (read-only observability into a spec's seal state and the governance history), and a structured `conflict` on `spec_approve`'s optimistic-concurrency refusal (read vs. current version + retry). See CHANGELOG for details.
22
+ - 🤖 **Autonomous Approval** *(new in 0.8.0)*: with the out-of-band `HOLMES_AUTONOMOUS_APPROVAL` switch on, an agent seals **low/mid-risk** specs itself (ledgered under an `autonomous:<client>` actor) — while `gate-behavior` changes, architecture/gate/taint files, and every upstream `REQ`/`H-SPEC`/`C-SPEC` still ask a human through the in-session TUI. The switch is env-only; a session cannot set it (blocked like `HOLMES_ROLE`). Off = byte-identical to before. *(new in 0.10.0)* Set **`HOLMES_ELICIT=off`** to skip the in-session prompt entirely and route every decision to the out-of-band `holmes-kit approve` queue instead — one decision surface that behaves identically across Claude Code / Antigravity / Codex.
22
23
  - 🪧 **Session Banner + Update Notice** *(new in 0.8.0)*: every session start emits an English intro (version + governance rule + npm URL) to both the human transcript and the agent context (SessionStart hook + MCP `instructions`); when a newer published version is cached, an install-mode-aware update command is appended. Registry check is detached, fail-silent, and opts out via `HOLMES_NO_UPDATE_CHECK`/`CI`.
23
24
  - 🧱 **Deterministic Gate, Hardened** *(new in 0.8.0)*: shell writes are judged at the segment's **effective working directory** (`cd sub && cat > ../src/x.ts` is sealed, legitimate out-of-tree scratch writes are freed); the governing anchor is the **whole set**, not the first match. Every gate change ships with two consecutive clean adversarial rounds.
24
25
  - 🧠 **3-Tier Semantic Layer** *(new in 0.3.0)*: knowledge-graph semantic search with an explicit consent ladder — `none` (default, **zero egress**), `local` (bge-m3, no egress, optional module), `cloud` (gemini-embedding-001, opt-in via `GEMINI_API_KEY`). Measured on 305 traceability cases: recall 0.486 (lexical) → 0.667 (local) → **0.887 (cloud)**; on lexical-zero requests: 0% → 52% → **92%**. Surfaced only additively — rerank, evidence (`semCos`), and `semanticAlternates` — never as a hard filter.
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 2a5de17-mtm0zp6n
1
+ b8567d9-mtn379s6
@@ -49,6 +49,19 @@ export interface AgentFile {
49
49
  path: string;
50
50
  content: string;
51
51
  }
52
+ /**
53
+ * Antigravity 훅 배선.
54
+ *
55
+ * 매처는 `*` 다. 좁히면 그 밖의 도구가 게이트를 지나가고, Antigravity 의 도구 목록은 우리가
56
+ * 통제하지 않는다 — 이름을 열거하는 순간 다음에 추가되는 도구가 무료 통행권을 얻는다.
57
+ * (Claude 배선이 `guardrail` 모드에서 좁은 매처를 쓰는 것과 다른 선택인데, 그쪽은 그 좁힘이
58
+ * 무엇을 뜻하는지 문서화된 모드 선택이고 여기는 기본 배선이기 때문이다.)
59
+ */
60
+ /** `node <forward-slash path>` — unquoted, single string; the only form agy's launcher runs. */
61
+ export declare function antigravityHookCommand(scriptPath: string): string;
62
+ /** win32-only, best-effort: a spaced path becomes its 8.3 short name (no spaces); anything else — or
63
+ * any lookup failure — returns the input unchanged. */
64
+ export declare function shortPathIfSpaced(p: string): string;
52
65
  /**
53
66
  * 이 하네스에 써야 할 파일들. **쓰지는 않는다** — 무엇을 쓸지만 말한다.
54
67
  *
@@ -34,11 +34,14 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.CODEX_MARKETPLACE = exports.CODEX_PLUGIN_DIR = exports.HARNESS_ENFORCES = exports.AGENTS = void 0;
37
+ exports.antigravityHookCommand = antigravityHookCommand;
38
+ exports.shortPathIfSpaced = shortPathIfSpaced;
37
39
  exports.agentFiles = agentFiles;
38
40
  exports.agentLinks = agentLinks;
39
41
  // @implements A-SPEC-442
40
42
  // @implements A-SPEC-193
41
43
  const path = __importStar(require("node:path"));
44
+ const node_child_process_1 = require("node:child_process");
42
45
  const mcp_launcher_1 = require("./mcp-launcher");
43
46
  /**
44
47
  * 하네스별 배선물.
@@ -107,12 +110,39 @@ const mcpConfig = (packageRoot, specsDir, launcher) => {
107
110
  * (Claude 배선이 `guardrail` 모드에서 좁은 매처를 쓰는 것과 다른 선택인데, 그쪽은 그 좁힘이
108
111
  * 무엇을 뜻하는지 문서화된 모드 선택이고 여기는 기본 배선이기 때문이다.)
109
112
  */
110
- // A hook `command` is ONE shell string, so the path must be quoted — `buildHookPlan` in init.ts has
111
- // always done this for the Claude wiring, and this one did not. Measured 2026-08-23 on a machine
112
- // whose home directory contains a space: the shell split the path, the hook never ran, and the
113
- // Antigravity gate was silently off. (The MCP config above is different: it passes `args` as an
114
- // array, where quoting would put literal quote characters into the path.)
115
- const hookCommand = (packageRoot, script) => `node "${path.join(packageRoot, 'bin', script)}"`;
113
+ // @implements A-SPEC-541.1
114
+ // Two field measurements (both 2026-09-04, Windows/agy) pinned the launcher's real contract, each by
115
+ // killing one prior form:
116
+ // · `node "C:\...js"` (quoted, A-SPEC-193 §2b-2) the quotes stayed LITERAL and node got
117
+ // `"C:\...` as a module path MODULE_NOT_FOUND. The launcher does not honor quotes.
118
+ // · `command:'node', args:[path]` (A-SPEC-539.1 / 0.10.0) the launcher IGNORED `args` and ran
119
+ // bare `node`, which took the stdin JSON payload as code → `SyntaxError: Unexpected token ':'`
120
+ // (exit 1), every tool blocked. The launcher executes only the single `command` string.
121
+ // So the only form it can run is an UNQUOTED single command string, whitespace-split — which means
122
+ // the path itself must carry no space. Forward slashes (node accepts them on Windows) avoid JSON
123
+ // backslash escaping; a spaced path is converted to its 8.3 short name on win32, best-effort.
124
+ /** `node <forward-slash path>` — unquoted, single string; the only form agy's launcher runs. */
125
+ function antigravityHookCommand(scriptPath) {
126
+ return `node ${scriptPath.replace(/\\/g, '/')}`;
127
+ }
128
+ /** win32-only, best-effort: a spaced path becomes its 8.3 short name (no spaces); anything else — or
129
+ * any lookup failure — returns the input unchanged. */
130
+ function shortPathIfSpaced(p) {
131
+ if (process.platform !== 'win32' || !p.includes(' '))
132
+ return p;
133
+ try {
134
+ const out = (0, node_child_process_1.execFileSync)('cmd', ['/d', '/c', `for %I in ("${p}") do @echo %~sI`], { encoding: 'utf8' }).trim();
135
+ return out !== '' && !out.includes(' ') ? out : p;
136
+ }
137
+ catch {
138
+ return p;
139
+ }
140
+ }
141
+ const antigravityHookEntry = (packageRoot, script) => ({
142
+ type: 'command',
143
+ command: antigravityHookCommand(shortPathIfSpaced(path.join(packageRoot, 'bin', script))),
144
+ timeout: 30,
145
+ });
116
146
  /**
117
147
  * @implements A-SPEC-442
118
148
  * Codex 훅이 부를 것: **Claude 와 같은 정책 커널**.
@@ -128,17 +158,9 @@ const hooksJson = (packageRoot) => `${JSON.stringify({
128
158
  'holmes-kit': {
129
159
  PreToolUse: [{
130
160
  matcher: '*',
131
- hooks: [{
132
- type: 'command',
133
- command: hookCommand(packageRoot, 'holmes-hook-antigravity.js'),
134
- timeout: 30,
135
- }],
136
- }],
137
- Stop: [{
138
- type: 'command',
139
- command: hookCommand(packageRoot, 'holmes-stop-antigravity.js'),
140
- timeout: 30,
161
+ hooks: [antigravityHookEntry(packageRoot, 'holmes-hook-antigravity.js')],
141
162
  }],
163
+ Stop: [antigravityHookEntry(packageRoot, 'holmes-stop-antigravity.js')],
142
164
  },
143
165
  }, null, 2)}\n`;
144
166
  const AGENTS_MD = (enforced) => `# Holmes-Kit — Workspace Operational Discipline
@@ -810,17 +810,17 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
810
810
  const h = JSON.parse(fs.readFileSync(agyHooks, 'utf8'));
811
811
  const entry = h['holmes-kit'];
812
812
  const pre = entry?.PreToolUse?.[0];
813
- const preCmd = pre?.hooks?.[0]?.command ?? '';
814
- const stopCmd = entry?.Stop?.[0]?.command ?? '';
815
- const stale = [preCmd, stopCmd].filter((c) => {
816
- const p2 = (0, settings_merge_1.hookScriptPath)(c);
817
- return !resolvesToPackage(p2, packageRoot);
818
- });
813
+ // @implements A-SPEC-539.1 — the hook script path now lives in args[0] (command is bare
814
+ // 'node'); older wirings joined it into the command string. Read args first, fall back to
815
+ // parsing the string so a pre-539 hooks.json is still judged correctly.
816
+ const scriptOf = (e) => (Array.isArray(e?.args) && typeof e?.args[0] === 'string') ? e.args[0] : (0, settings_merge_1.hookScriptPath)(e?.command ?? '');
817
+ const stale = [scriptOf(pre?.hooks?.[0]), scriptOf(entry?.Stop?.[0])]
818
+ .filter((p2) => !resolvesToPackage(p2, packageRoot));
819
819
  if (entry === undefined) {
820
820
  add('antigravity wiring', 'WARN', `${agyHooks} 에 holmes-kit 항목이 없습니다`, 'holmes-kit init --target <dir> --agent antigravity');
821
821
  }
822
822
  else if (stale.length > 0) {
823
- add('antigravity wiring', 'FAIL', `이 설치본을 가리키지 않는 명령: ${stale.join(' | ')}`, 'holmes-kit init --target <dir> --agent antigravity --force 로 절대 경로를 갱신하십시오.');
823
+ add('antigravity wiring', 'FAIL', `이 설치본을 가리키지 않는 경로: ${stale.join(' | ')}`, 'holmes-kit init --target <dir> --agent antigravity --force 로 절대 경로를 갱신하십시오.');
824
824
  }
825
825
  else if ((pre?.matcher ?? '') !== '*') {
826
826
  // 매처가 좁으면 그 밖의 도구가 게이트를 지나간다 — 조용한 구멍이므로 말한다.
@@ -0,0 +1,21 @@
1
+ import { ProvenanceEvent } from './provenance-chain';
2
+ /** One governance event, projected to what a timeline shows — chain-integrity fields dropped. */
3
+ export interface TimelineEntry {
4
+ ts: string;
5
+ kind: string;
6
+ actor: string;
7
+ summary: string;
8
+ inputs: string[];
9
+ }
10
+ /** The subset of a ledger event this projection reads (structurally typed so callers can pass
11
+ * FileLedgerStore.loadAll() results directly). */
12
+ type TimelineSource = Pick<ProvenanceEvent, 'ts' | 'kind' | 'actor' | 'summary'> & Partial<Pick<ProvenanceEvent, 'inputs' | 'seq'>>;
13
+ /**
14
+ * @implements A-SPEC-538.3
15
+ * Pure: order ledger events into a timeline, optionally narrowed to one spec. Ascending by `ts`
16
+ * (ISO strings sort lexically), tiebroken by `seq` so same-timestamp events keep chain order. When
17
+ * `id` is given, only events whose `inputs` reference it survive. Chain-integrity fields
18
+ * (hash/prevHash/seq/replicaId) are plumbing, not timeline content, and are projected away.
19
+ */
20
+ export declare function timelineFrom(events: TimelineSource[], id?: string): TimelineEntry[];
21
+ export {};
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.timelineFrom = timelineFrom;
4
+ /**
5
+ * @implements A-SPEC-538.3
6
+ * Pure: order ledger events into a timeline, optionally narrowed to one spec. Ascending by `ts`
7
+ * (ISO strings sort lexically), tiebroken by `seq` so same-timestamp events keep chain order. When
8
+ * `id` is given, only events whose `inputs` reference it survive. Chain-integrity fields
9
+ * (hash/prevHash/seq/replicaId) are plumbing, not timeline content, and are projected away.
10
+ */
11
+ function timelineFrom(events, id) {
12
+ const selected = id ? events.filter((e) => (e.inputs ?? []).includes(id)) : events.slice();
13
+ selected.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : (a.seq ?? 0) - (b.seq ?? 0)));
14
+ return selected.map((e) => ({
15
+ ts: e.ts,
16
+ kind: e.kind,
17
+ actor: e.actor,
18
+ summary: e.summary,
19
+ inputs: e.inputs ?? [],
20
+ }));
21
+ }
@@ -20,6 +20,15 @@
20
20
  */
21
21
  /** Approval kinds that may ask in-session. Widening this set is a spec revision, not a drive-by. */
22
22
  export declare const ELICITABLE_KINDS: ReadonlySet<string>;
23
+ /**
24
+ * @implements A-SPEC-540.1
25
+ * True when the operator has opted OUT of in-client elicitation via `HOLMES_ELICIT`. Then the server
26
+ * folds every elicitation to `silent` (refuse+enqueue) — the same path a client that never advertised
27
+ * the capability takes — so claude/agy/codex converge on ONE out-of-band decision surface
28
+ * (`holmes-kit approve`) instead of depending on each client's prompt rendering. Unset, or any
29
+ * non-disabling value, keeps elicitation ON — byte-identical to before.
30
+ */
31
+ export declare function elicitationDisabled(env: NodeJS.ProcessEnv): boolean;
23
32
  export interface ElicitApprovalRequest {
24
33
  kind: string;
25
34
  target: string;
@@ -2,6 +2,7 @@
2
2
  // @implements A-SPEC-263.1
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.MAX_PRESENTATIONS = exports.ELICIT_TIMEOUT_MS = exports.ELICITABLE_KINDS = void 0;
5
+ exports.elicitationDisabled = elicitationDisabled;
5
6
  exports.elicitTimeoutMsFor = elicitTimeoutMsFor;
6
7
  exports.classifyElicitError = classifyElicitError;
7
8
  exports.expiredNotice = expiredNotice;
@@ -31,6 +32,20 @@ exports.interpretElicitResult = interpretElicitResult;
31
32
  */
32
33
  /** Approval kinds that may ask in-session. Widening this set is a spec revision, not a drive-by. */
33
34
  exports.ELICITABLE_KINDS = new Set(['spec-approve', 'review-resolve']);
35
+ /** The `HOLMES_ELICIT` values that turn the in-client prompt OFF. */
36
+ const ELICIT_DISABLED_TOKENS = new Set(['off', '0', 'false', 'no', 'disabled']);
37
+ /**
38
+ * @implements A-SPEC-540.1
39
+ * True when the operator has opted OUT of in-client elicitation via `HOLMES_ELICIT`. Then the server
40
+ * folds every elicitation to `silent` (refuse+enqueue) — the same path a client that never advertised
41
+ * the capability takes — so claude/agy/codex converge on ONE out-of-band decision surface
42
+ * (`holmes-kit approve`) instead of depending on each client's prompt rendering. Unset, or any
43
+ * non-disabling value, keeps elicitation ON — byte-identical to before.
44
+ */
45
+ function elicitationDisabled(env) {
46
+ const v = env.HOLMES_ELICIT;
47
+ return typeof v === 'string' && ELICIT_DISABLED_TOKENS.has(v.trim().toLowerCase());
48
+ }
34
49
  /**
35
50
  * The lock-holding path's timeout truth (and the stage-2 reason dialog's): review-resolve holds
36
51
  * the findings-ledger lock while the dialog waits, so its ceiling never grows (REQ-497 기아 방지).
@@ -158,6 +158,85 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
158
158
  dependents: string[];
159
159
  reason?: undefined;
160
160
  }>;
161
+ /**
162
+ * @implements A-SPEC-538.1
163
+ * The inverse of spec_approve: returns a SEALED (approved) spec to an editable `draft`, clearing
164
+ * `approved_digest` and `parent_digests` in ONE act, and records `spec-unsealed`. Hand-editing
165
+ * only `status` leaves seal residue that later trips validation; this atomizes the reverse.
166
+ *
167
+ * Un-sealing WITHDRAWS a seal, so — like retiring a sealed document — it demands a covering
168
+ * out-of-band HOLMES_APPROVAL (fail-closed): un-sealing an approved T-SPEC removes the code
169
+ * gate's demand, so an unguarded un-seal would be an approval bypass. And it refuses when an
170
+ * APPROVED spec depends on the target, because that dependent's `parent_digests` — the snapshot
171
+ * drift detection compares against — would silently go stale.
172
+ */
173
+ spec_unseal(a: {
174
+ root?: string;
175
+ id: string;
176
+ }): Promise<{
177
+ ok: boolean;
178
+ reason: string;
179
+ unsealed?: undefined;
180
+ id?: undefined;
181
+ dependents?: undefined;
182
+ } | {
183
+ ok: boolean;
184
+ unsealed: boolean;
185
+ id: string;
186
+ dependents: never[];
187
+ reason: string;
188
+ } | {
189
+ ok: boolean;
190
+ unsealed: boolean;
191
+ id: string;
192
+ dependents: string[];
193
+ reason?: undefined;
194
+ }>;
195
+ /**
196
+ * @implements A-SPEC-538.2
197
+ * Read-only: report a spec's approval/seal state — sealed?, approved_digest, each parent's seal
198
+ * state, and the concrete blockers still standing between it and approval — so a caller need not
199
+ * parse files to ask "what is the approval state right now". Reuses `sealOf` and `approvalBlockers`
200
+ * (the same predicates the code gate and spec_approve read) so the report cannot drift from the
201
+ * acts it describes. No writes, no ledger append.
202
+ */
203
+ approval_status(a: {
204
+ root?: string;
205
+ id: string;
206
+ }): Promise<{
207
+ ok: boolean;
208
+ reason: string;
209
+ } | {
210
+ id: string;
211
+ type?: string;
212
+ status: string;
213
+ sealed: boolean;
214
+ approvedDigest?: string;
215
+ parents: import("../spec/approval-status").ParentApproval[];
216
+ blockers: string[];
217
+ ok: boolean;
218
+ reason?: undefined;
219
+ }>;
220
+ /**
221
+ * @implements A-SPEC-538.3
222
+ * Read-only: return the provenance ledger's events in time order (optionally narrowed to one
223
+ * spec) so the governance history — approved / unsealed / retired / review-needed … — is legible
224
+ * at a glance without reading raw JSONL. Reuses FileLedgerStore.loadAll(); the ordering/filtering/
225
+ * projection is the pure `timelineFrom`. No writes. An absent ledger is an empty history, not an
226
+ * error.
227
+ */
228
+ ledger_timeline(a: {
229
+ root?: string;
230
+ id?: string;
231
+ }): Promise<{
232
+ ok: boolean;
233
+ reason: string;
234
+ events?: undefined;
235
+ } | {
236
+ ok: boolean;
237
+ events: import("../governance/ledger-timeline").TimelineEntry[];
238
+ reason?: undefined;
239
+ }>;
161
240
  spec_approve(a: {
162
241
  root?: string;
163
242
  id: string;
@@ -165,12 +244,21 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
165
244
  ok: boolean;
166
245
  reason: string;
167
246
  findings?: undefined;
247
+ conflict?: undefined;
168
248
  approved?: undefined;
169
249
  digest?: undefined;
170
250
  } | {
171
251
  ok: boolean;
172
252
  reason: string;
173
253
  findings: import("../spec/validator").Finding[];
254
+ conflict?: undefined;
255
+ approved?: undefined;
256
+ digest?: undefined;
257
+ } | {
258
+ ok: boolean;
259
+ reason: string;
260
+ conflict: import("../spec/version-conflict").ConflictDetail;
261
+ findings?: undefined;
174
262
  approved?: undefined;
175
263
  digest?: undefined;
176
264
  } | {
@@ -179,6 +267,7 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
179
267
  ok?: undefined;
180
268
  reason?: undefined;
181
269
  findings?: undefined;
270
+ conflict?: undefined;
182
271
  }>;
183
272
  spec_list(a: any): Promise<{
184
273
  specs: {
@@ -189,6 +189,9 @@ const spec_digest_1 = require("../spec/spec-digest");
189
189
  const spec_store_2 = require("../spec/spec-store");
190
190
  const breaking_change_1 = require("../spec/breaking-change");
191
191
  const approval_blockers_1 = require("../spec/approval-blockers");
192
+ const approval_status_1 = require("../spec/approval-status");
193
+ const ledger_timeline_1 = require("../governance/ledger-timeline");
194
+ const version_conflict_1 = require("../spec/version-conflict");
192
195
  const ledger_store_1 = require("../governance/ledger-store");
193
196
  const provenance_chain_1 = require("../governance/provenance-chain");
194
197
  const ledger_lock_1 = require("../governance/ledger-lock");
@@ -703,6 +706,11 @@ function makeRawHandlers(store, opts) {
703
706
  // stays byte-identical to the pre-elicitation one — nothing is ever worse than before the
704
707
  // channel existed.
705
708
  const tryElicit = async (kind, target, summary) => {
709
+ // @implements A-SPEC-540.1 — operator opt-out: with HOLMES_ELICIT=off the server never shows the
710
+ // in-client prompt, folding to the byte-identical `silent` (refuse+enqueue) path so claude/agy/
711
+ // codex all converge on one out-of-band surface (holmes-kit approve). Unset ⇒ unchanged.
712
+ if ((0, elicit_approval_1.elicitationDisabled)(process.env))
713
+ return { kind: 'silent' };
706
714
  if (!opts?.elicit || !elicit_approval_1.ELICITABLE_KINDS.has(kind))
707
715
  return { kind: 'silent' };
708
716
  try {
@@ -1174,6 +1182,146 @@ function makeRawHandlers(store, opts) {
1174
1182
  });
1175
1183
  return { ok: true, retired: true, id: a.id, dependents: dependents.map((s) => s.id) };
1176
1184
  },
1185
+ /**
1186
+ * @implements A-SPEC-538.1
1187
+ * The inverse of spec_approve: returns a SEALED (approved) spec to an editable `draft`, clearing
1188
+ * `approved_digest` and `parent_digests` in ONE act, and records `spec-unsealed`. Hand-editing
1189
+ * only `status` leaves seal residue that later trips validation; this atomizes the reverse.
1190
+ *
1191
+ * Un-sealing WITHDRAWS a seal, so — like retiring a sealed document — it demands a covering
1192
+ * out-of-band HOLMES_APPROVAL (fail-closed): un-sealing an approved T-SPEC removes the code
1193
+ * gate's demand, so an unguarded un-seal would be an approval bypass. And it refuses when an
1194
+ * APPROVED spec depends on the target, because that dependent's `parent_digests` — the snapshot
1195
+ * drift detection compares against — would silently go stale.
1196
+ */
1197
+ async spec_unseal(a) {
1198
+ const all = await store.list();
1199
+ if (all.filter((s) => s.id === a.id).length > 1) {
1200
+ return {
1201
+ ok: false,
1202
+ reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 봉인 해제 전에 거부합니다.`,
1203
+ };
1204
+ }
1205
+ const cur = await store.read(a.id);
1206
+ if (!cur) {
1207
+ const dir = store.specsRoot;
1208
+ const unreadable = typeof dir === 'string' ? (0, spec_store_1.unreadableSpecFiles)(dir) : [];
1209
+ return { ok: false, reason: (0, spec_store_1.notFoundReason)(a.id, unreadable) };
1210
+ }
1211
+ const spec = cur.spec;
1212
+ if (!spec.type) {
1213
+ return {
1214
+ ok: false,
1215
+ reason: `${a.id}에는 \`type:\` 선언이 없습니다(구형식 문서) — 어느 폴더에 속하는지 알 수 없어 봉인 해제할 수 없습니다.`
1216
+ + ` 먼저 spec_upgrade({ id: "${a.id}" })로 형식을 올린 뒤 다시 시도하십시오.`,
1217
+ };
1218
+ }
1219
+ // Sealed ⇔ carries approved_digest. Un-sealing an already-draft spec writes NOTHING (a second
1220
+ // un-seal is history, not graffiti) — mirrors spec_retire's idempotent no-op.
1221
+ const sealed = typeof spec.frontmatter.approved_digest === 'string';
1222
+ if (!sealed) {
1223
+ return { ok: true, unsealed: false, id: a.id, dependents: [], reason: `${a.id}은(는) 이미 미봉인(draft) 상태입니다` };
1224
+ }
1225
+ const approvalRaw = process.env.HOLMES_APPROVAL;
1226
+ let approval;
1227
+ try {
1228
+ approval = approvalRaw ? JSON.parse(approvalRaw) : undefined;
1229
+ }
1230
+ catch {
1231
+ approval = undefined;
1232
+ }
1233
+ const resolved = resolveHandlerApproval(a.root, store, approval, { kind: 'spec-approve', target: a.id }, new Date().toISOString());
1234
+ if (resolved === undefined) {
1235
+ return {
1236
+ ok: false,
1237
+ reason: `${a.id}은(는) 봉인된 문서입니다 — 봉인 해제는 이 행위를 덮는 유효한 대역외 HOLMES_APPROVAL 이 필요합니다.`
1238
+ + ' 봉인을 해제하면 코드 게이트가 요구하던 approved 스펙의 봉인이 사라지므로, 해제가 승인 우회 경로가 되지 않도록 fail-closed 로 막습니다.'
1239
+ + ' (범위를 쓰면 kind "spec-approve")'
1240
+ + refusalQueueHint(a.root, store, { kind: 'spec-approve', target: a.id, why: '봉인된 스펙의 해제' }),
1241
+ };
1242
+ }
1243
+ // @implements A-SPEC-245 — a grant that authorized breaking a seal is spent by it.
1244
+ if (resolved.source === 'grant' && resolved.root && resolved.approval.nonce) {
1245
+ (0, approval_grants_1.consumeGrantFile)(resolved.root, resolved.approval.nonce);
1246
+ }
1247
+ const dependents = all.filter((s) => s.id !== a.id && s.dependsOn.includes(a.id));
1248
+ const blocking = dependents.filter((s) => s.status === 'approved').map((s) => s.id);
1249
+ if (blocking.length > 0) {
1250
+ return {
1251
+ ok: false,
1252
+ reason: `${a.id}의 봉인을 해제하면 approved 문서 ${blocking.join(', ')}의 parent_digests 가 stale 이 됩니다 — 지금 그 사슬을 지탱하고 있으므로 거부합니다.`
1253
+ + ' 해당 문서를 먼저 해제/폐기하거나 부모를 다른 문서로 옮기십시오.',
1254
+ };
1255
+ }
1256
+ // Destination BEFORE the write (A-SPEC-188 order): a resolution failure leaves nothing behind.
1257
+ const dest = resolveLedgerRoot(a.root);
1258
+ if (!dest.ok)
1259
+ return { ok: false, reason: dest.reason };
1260
+ const ledgerRoot = dest.root;
1261
+ const fm = { ...spec.frontmatter };
1262
+ delete fm.approved_digest; // the seal this act withdraws
1263
+ delete fm.parent_digests;
1264
+ try {
1265
+ await store.write({ ...spec, status: 'draft', frontmatter: fm }, { expectedVersion: cur.version });
1266
+ }
1267
+ catch (e) {
1268
+ if (e instanceof spec_store_2.SpecVersionConflictError) {
1269
+ return { ok: false, reason: `봉인 해제 진행 중 ${a.id}이(가) 바뀌었습니다 — 확인 후 다시 시도하십시오. 이번 해제는 아무것도 쓰지 않았습니다.` };
1270
+ }
1271
+ throw e;
1272
+ }
1273
+ new ledger_store_1.FileLedgerStore(path.join(ledgerRoot, '.ax', 'ledger')).append({
1274
+ ts: new Date().toISOString(),
1275
+ actor: approval?.actor ?? 'unattributed',
1276
+ kind: 'spec-unsealed',
1277
+ summary: `unsealed ${a.id}`,
1278
+ inputs: [a.id],
1279
+ rationale: approval?.rationale ?? 'unsealed',
1280
+ ...(approval ? { authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token) } : {}),
1281
+ });
1282
+ return { ok: true, unsealed: true, id: a.id, dependents: dependents.map((s) => s.id) };
1283
+ },
1284
+ /**
1285
+ * @implements A-SPEC-538.2
1286
+ * Read-only: report a spec's approval/seal state — sealed?, approved_digest, each parent's seal
1287
+ * state, and the concrete blockers still standing between it and approval — so a caller need not
1288
+ * parse files to ask "what is the approval state right now". Reuses `sealOf` and `approvalBlockers`
1289
+ * (the same predicates the code gate and spec_approve read) so the report cannot drift from the
1290
+ * acts it describes. No writes, no ledger append.
1291
+ */
1292
+ async approval_status(a) {
1293
+ assertSpecStoreReachable('approval_status', store, a.root); // @implements A-SPEC-419
1294
+ const all = await store.list();
1295
+ if (all.filter((s) => s.id === a.id).length > 1) {
1296
+ return {
1297
+ ok: false,
1298
+ reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본의 상태를 물었는지 도구가 고를 수 없어 거부합니다.`,
1299
+ };
1300
+ }
1301
+ const cur = await store.read(a.id);
1302
+ if (!cur) {
1303
+ const dir = store.specsRoot;
1304
+ const unreadable = typeof dir === 'string' ? (0, spec_store_1.unreadableSpecFiles)(dir) : [];
1305
+ return { ok: false, reason: (0, spec_store_1.notFoundReason)(a.id, unreadable) };
1306
+ }
1307
+ return { ok: true, ...(0, approval_status_1.describeApproval)(cur.spec, resolver(all)) };
1308
+ },
1309
+ /**
1310
+ * @implements A-SPEC-538.3
1311
+ * Read-only: return the provenance ledger's events in time order (optionally narrowed to one
1312
+ * spec) so the governance history — approved / unsealed / retired / review-needed … — is legible
1313
+ * at a glance without reading raw JSONL. Reuses FileLedgerStore.loadAll(); the ordering/filtering/
1314
+ * projection is the pure `timelineFrom`. No writes. An absent ledger is an empty history, not an
1315
+ * error.
1316
+ */
1317
+ async ledger_timeline(a) {
1318
+ const dest = resolveLedgerRoot(a.root);
1319
+ if (!dest.ok)
1320
+ return { ok: false, reason: dest.reason };
1321
+ const dir = path.join(dest.root, '.ax', 'ledger');
1322
+ const events = fs.existsSync(dir) ? new ledger_store_1.FileLedgerStore(dir).loadAll() : [];
1323
+ return { ok: true, events: (0, ledger_timeline_1.timelineFrom)(events, a.id) };
1324
+ },
1177
1325
  async spec_approve(a) {
1178
1326
  const approvalRaw = process.env.HOLMES_APPROVAL;
1179
1327
  let approval;
@@ -1415,10 +1563,15 @@ function makeRawHandlers(store, opts) {
1415
1563
  }
1416
1564
  catch (e) {
1417
1565
  if (e instanceof spec_store_2.SpecVersionConflictError) {
1566
+ // @implements A-SPEC-538.4 — the refusal is unchanged (refuse, write nothing, the edit
1567
+ // wins); it now also CARRIES the conflict: the version this act read, the version now on
1568
+ // disk, and what to retry. Re-read to learn the current version (null if it vanished).
1569
+ const now = await store.read(a.id).catch(() => null);
1418
1570
  return {
1419
1571
  ok: false,
1420
1572
  reason: `승인 진행 중 ${a.id}이(가) 바뀌었습니다 — 바뀐 내용을 확인하고 다시 승인하십시오.`
1421
1573
  + ' 이번 승인은 아무것도 쓰지 않았습니다.',
1574
+ conflict: (0, version_conflict_1.conflictDetail)({ id: a.id, expected: cur.version, current: now?.version ?? null }),
1422
1575
  };
1423
1576
  }
1424
1577
  // @implements A-SPEC-188 — approval can RELOCATE the file (req_type classification moves
@@ -101,6 +101,38 @@ exports.TOOL_SCHEMAS = {
101
101
  required: ['id'],
102
102
  },
103
103
  },
104
+ spec_unseal: {
105
+ description: "The inverse of spec_approve: return ONE sealed (approved) spec to an editable `status: draft`, clearing `approved_digest` and `parent_digests` in a single ACT, and record `spec-unsealed` in the provenance ledger. Un-sealing WITHDRAWS a seal, so it requires the same out-of-band HOLMES_APPROVAL as spec_approve/spec_retire's sealed path (fail-closed): un-sealing an approved T-SPEC removes the code gate's demand, which unguarded would be an approval bypass. Refuses when an APPROVED spec depends on the target — that dependent's parent_digests would silently go stale — naming the blockers. Idempotent: un-sealing a spec that is already draft (never sealed) writes nothing and returns unsealed:false. Written only at the version this act read; a concurrent edit wins and the un-seal is refused for retry. Distinct from spec_retire (which withdraws authority to status:outdated); un-seal keeps the spec alive and editable.",
106
+ inputSchema: {
107
+ type: 'object',
108
+ properties: {
109
+ id: str('Id of the spec to un-seal (return to draft).'),
110
+ root: str('Optional when the server is bound to a file store — the ledger location is derived from the store itself; if supplied it must resolve to the SAME project.'),
111
+ },
112
+ required: ['id'],
113
+ },
114
+ },
115
+ approval_status: {
116
+ description: "Read-only: report ONE spec's approval/seal state without the caller parsing files — { sealed, approvedDigest?, status, parents: [{ id, status, sealed, resolved }], blockers: [] }. `sealed` is the same approved_digest predicate the code gate reads; `blockers` is the exact list spec_approve would refuse on (empty once approved), so the report cannot drift from the acts it describes. Each parent entry shows that parent's OWN live seal state (an unresolved depends_on id is reported as status:'missing', resolved:false — not dropped). No writes, no ledger append. Refuses a duplicate or unknown id.",
117
+ inputSchema: {
118
+ type: 'object',
119
+ properties: {
120
+ id: str('Id of the spec whose approval/seal state to report.'),
121
+ root: str('Optional when the server is bound to a file store; if supplied it must resolve to the SAME project.'),
122
+ },
123
+ required: ['id'],
124
+ },
125
+ },
126
+ ledger_timeline: {
127
+ description: "Read-only: return the provenance ledger's events in time order — { events: [{ ts, kind, actor, summary, inputs }] } ascending by ts (tiebroken by chain seq). Pass an `id` to narrow to one spec (only events whose `inputs` reference it: spec-approved, spec-unsealed, spec-retired, review-needed …). The chain-integrity fields (hash/prevHash/seq/replicaId) are plumbing and are projected away — this is the governance history, not the tamper-evidence chain. No writes. An absent ledger is an empty history, not an error.",
128
+ inputSchema: {
129
+ type: 'object',
130
+ properties: {
131
+ id: str('Optional spec id — narrow the timeline to events that reference it.'),
132
+ root: str('Optional when the server is bound to a file store; the ledger location is derived from the store, or from this root.'),
133
+ },
134
+ },
135
+ },
104
136
  spec_approve: {
105
137
  description: 'Approve a spec as a sealing ACT: confirm the ledger destination BEFORE sealing → validate (zero errors) → record approved_digest + parent_digests snapshots → status: approved (written only at the version this act read; a concurrent edit wins and the approval is refused for retry) → append spec-approved to the provenance ledger. Requires a valid out-of-band HOLMES_APPROVAL in the SERVER environment (fail-closed; nothing in the request can substitute). Refuses an unsealed approved parent — seal parents first.',
106
138
  inputSchema: {
@@ -0,0 +1,29 @@
1
+ import { Spec } from './spec-parser';
2
+ /** One parent's approval/seal state as seen from a child. */
3
+ export interface ParentApproval {
4
+ id: string;
5
+ status: string;
6
+ sealed: boolean;
7
+ /** false when the resolver could not find the parent (a dangling depends_on). */
8
+ resolved: boolean;
9
+ }
10
+ /** A spec's approval/seal state, answerable without the caller parsing any file. */
11
+ export interface ApprovalStatus {
12
+ id: string;
13
+ type?: string;
14
+ status: string;
15
+ /** carries approved_digest — the code gate treats it as sealed. */
16
+ sealed: boolean;
17
+ approvedDigest?: string;
18
+ parents: ParentApproval[];
19
+ /** the concrete reasons this spec cannot be approved right now (empty once approved). */
20
+ blockers: string[];
21
+ }
22
+ /**
23
+ * @implements A-SPEC-538.2
24
+ * Pure: describe a spec's approval/seal state from the spec object and a parent resolver. Reuses
25
+ * `sealOf` (the same seal predicate the code gate reads) and `approvalBlockers` (the exact list
26
+ * spec_approve would refuse on) so the report cannot drift from the acts it describes. Touches no
27
+ * filesystem — the handler reads the store and hands the objects in.
28
+ */
29
+ export declare function describeApproval(spec: Spec, resolve: (id: string) => Spec | null): ApprovalStatus;
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.describeApproval = describeApproval;
4
+ const spec_digest_1 = require("./spec-digest");
5
+ const approval_blockers_1 = require("./approval-blockers");
6
+ /**
7
+ * @implements A-SPEC-538.2
8
+ * Pure: describe a spec's approval/seal state from the spec object and a parent resolver. Reuses
9
+ * `sealOf` (the same seal predicate the code gate reads) and `approvalBlockers` (the exact list
10
+ * spec_approve would refuse on) so the report cannot drift from the acts it describes. Touches no
11
+ * filesystem — the handler reads the store and hands the objects in.
12
+ */
13
+ function describeApproval(spec, resolve) {
14
+ const seal = (0, spec_digest_1.sealOf)(spec);
15
+ const parents = (spec.dependsOn ?? []).map((pid) => {
16
+ const parent = resolve(pid);
17
+ return parent
18
+ ? { id: pid, status: parent.status, sealed: !!(0, spec_digest_1.sealOf)(parent).approvedDigest, resolved: true }
19
+ : { id: pid, status: 'missing', sealed: false, resolved: false };
20
+ });
21
+ // An approved spec has, by definition, cleared every blocker; recomputing would only re-report
22
+ // the placeholder/parent checks against a document that already passed them.
23
+ const blockers = spec.status === 'approved' ? [] : (0, approval_blockers_1.approvalBlockers)(spec, resolve);
24
+ return {
25
+ id: spec.id,
26
+ type: spec.type,
27
+ status: spec.status,
28
+ sealed: !!seal.approvedDigest,
29
+ approvedDigest: seal.approvedDigest,
30
+ parents,
31
+ blockers,
32
+ };
33
+ }
@@ -0,0 +1,21 @@
1
+ /** A structured report of an optimistic-concurrency conflict during a sealing act. */
2
+ export interface ConflictDetail {
3
+ id: string;
4
+ /** the version this act READ (and tried to write at). */
5
+ expectedVersion: string;
6
+ /** the version now on disk — null when the spec was removed between read and write. */
7
+ currentVersion: string | null;
8
+ /** one non-empty sentence: what happened and what to do next. */
9
+ retry: string;
10
+ }
11
+ /**
12
+ * @implements A-SPEC-538.4
13
+ * Pure: describe a version conflict so the refusal names what won and what to retry. The act's
14
+ * refusal behaviour (refuse, write nothing, the edit wins) is unchanged — this only exposes the
15
+ * detail. `current` is null when the spec vanished between read and write.
16
+ */
17
+ export declare function conflictDetail(a: {
18
+ id: string;
19
+ expected: string;
20
+ current: string | null;
21
+ }): ConflictDetail;
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ // @implements A-SPEC-538.4
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.conflictDetail = conflictDetail;
5
+ /**
6
+ * @implements A-SPEC-538.4
7
+ * Pure: describe a version conflict so the refusal names what won and what to retry. The act's
8
+ * refusal behaviour (refuse, write nothing, the edit wins) is unchanged — this only exposes the
9
+ * detail. `current` is null when the spec vanished between read and write.
10
+ */
11
+ function conflictDetail(a) {
12
+ const what = a.current === null
13
+ ? `${a.id} was removed since this act read it (read ${a.expected})`
14
+ : `${a.id} changed since this act read it (read ${a.expected}, now ${a.current})`;
15
+ return {
16
+ id: a.id,
17
+ expectedVersion: a.expected,
18
+ currentVersion: a.current,
19
+ retry: `${what} — nothing was written; re-read ${a.id} and try the approval again.`,
20
+ };
21
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@implements A-SPEC-209",
3
3
  "name": "@holmes-lab/holmes-kit",
4
- "version": "0.9.0",
4
+ "version": "0.10.1",
5
5
  "description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
6
6
  "main": "dist/holmes/mcp/server.js",
7
7
  "types": "dist/holmes/mcp/server.d.ts",