@kontextmind/kxm 0.7.141 → 0.7.143

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.
@@ -11,7 +11,7 @@
11
11
  "name": "kxm",
12
12
  "source": "./plugins/kxm",
13
13
  "description": "Durable workflows, peer agents, and kxm tui",
14
- "version": "0.7.141",
14
+ "version": "0.7.143",
15
15
  "category": "development",
16
16
  "tags": ["kxm", "multi-agent", "workflows", "mcp"]
17
17
  }
package/CHANGELOG.md CHANGED
@@ -6,8 +6,6 @@ All notable user-facing changes are documented here. The project follows [Semant
6
6
 
7
7
  ### Added
8
8
 
9
- - **Playwright uses Obscura by default.** `resolveBrowserCdpEndpoint()` returns `OBSCURA_CDP_URL` or `http://127.0.0.1:${OBSCURA_PORT:-9222}`. `KXM_BROWSER=steel` still returns the Steel session CDP URL. `node scripts/obscura.mjs` downloads pinned Obscura v0.2.3 and serves it with `--allow-private-network`. `npm run e2e` runs the Playwright smoke test in `test/e2e/` over CDP and does not run `playwright install`. Steel remains the path for human takeover, MFA, and the live session viewer. See [ADR-0005](docs/adr/ADR-0005-obscura-default-playwright.md) and [How do I connect Playwright to Obscura?](docs/kb/how-to-connect-playwright-to-obscura.md).
10
-
11
9
  - **A live agent step uses a configurable one-shot timeout, and a cancelling run recovers when its child has already exited.**
12
10
  The bound is the step `timeoutMs`, or the project `limits.agentStepTimeoutMs`
13
11
  when the step omits it (minimum 60 seconds, default one hour). A wider step
@@ -33,6 +31,7 @@ All notable user-facing changes are documented here. The project follows [Semant
33
31
  A required review is reported. The `land` workflow in `.kxm/workflows/land.yaml`
34
32
  runs the same gates and may be refused until gate-only workflows are supported.
35
33
  See the [CLI reference](docs/reference/cli-reference.md#kxm-land).
34
+
36
35
  - **`kxm assign` is the entry to the developer assignment runner.**
37
36
  `run`, `witness`, `plan-current`, `attribute`, `observe-cost`, `accept` and
38
37
  `change-report` spawn `scripts/assignment-run.mjs` with the same flags as the
@@ -138,19 +137,10 @@ All notable user-facing changes are documented here. The project follows [Semant
138
137
  hub check and still lose run history, the prompts that explain it, the project definition,
139
138
  and the bindings that make the box reproducible.
140
139
 
140
+ - **Playwright uses Obscura by default.** `resolveBrowserCdpEndpoint()` returns `OBSCURA_CDP_URL` or `http://127.0.0.1:${OBSCURA_PORT:-9222}`. `KXM_BROWSER=steel` still returns the Steel session CDP URL. `node scripts/obscura.mjs` downloads pinned Obscura v0.2.3 and serves it with `--allow-private-network`. `npm run e2e` runs the Playwright smoke test in `test/e2e/` over CDP and does not run `playwright install`. Steel remains the path for human takeover, MFA, and the live session viewer. See [ADR-0005](docs/adr/ADR-0005-obscura-default-playwright.md) and [How do I connect Playwright to Obscura?](docs/kb/how-to-connect-playwright-to-obscura.md).
141
+
141
142
  ### Changed
142
143
 
143
- - **Steel clients authenticate to Authentik with `Authorization: Basic`.**
144
- `STEEL_AUTH_BASIC`, or `STEEL_AUTH_USER` and `STEEL_AUTH_TOKEN`, set that
145
- header on Steel HTTP requests and on the CDP options from `formatCDPConnect()`.
146
- `KXM_BROWSER=steel` passes those headers through `connectBrowserOverCdp()`
147
- into `chromium.connectOverCDP`. Obscura stays the default and sends no Steel
148
- headers. `STEEL_AUTH_HEADER` overrides the value. The CDP URL omits the credential when
149
- those variables are set. A 302 to the identity provider fails closed and does
150
- not follow the login redirect. `STEEL_API_KEY` still sends the legacy
151
- `x-steel-api-key` header and `apiKey` query parameter for the temporary proxy
152
- shim, and warns once. See
153
- [Browser automation](docs/guides/browser-automation.md).
154
144
  - **Dispatch reads role and model files, and agents bind a role.**
155
145
  `scripts/roster-policy.mjs` builds the developer policy from
156
146
  `.kxm/models/*.yaml` and `.kxm/roles/*.yaml` at `refs/remotes/origin/main`.
@@ -162,6 +152,7 @@ All notable user-facing changes are documented here. The project follows [Semant
162
152
  admitted and named by no roster, so it is absent from `routes` and the
163
153
  lineups. `gemini-agy` is in the writer lineup. An agent `tools.preset`
164
154
  may only narrow its role preset; that rule is recorded and enforced in P3.
155
+
165
156
  - **Role and model files are live `kxm.role.v2` and `kxm.model.v2`.**
166
157
  `schemas/role.schema.json` and `schemas/model.schema.json` are the files
167
158
  `kxm config` validates. Each admitted roster route is a
@@ -219,6 +210,7 @@ All notable user-facing changes are documented here. The project follows [Semant
219
210
  the example project and the unsupported-gate fixture now route gate failures on
220
211
  `implementation-failure`. **Check your workflows:** a gate step that routes failures only
221
212
  on `failed` no longer loads.
213
+
222
214
  - **`kxm run` prints how to drive the run it created, and drive refusals say why.** The
223
215
  text output's second line is
224
216
  `drive it model-free: kxm runs drive <runId> --simulated --wait (or cancel: kxm runs cancel <runId>)`,
@@ -230,11 +222,13 @@ All notable user-facing changes are documented here. The project follows [Semant
230
222
  a workflow that declares `limits.maxAgentTimeMs`, for example, reports `limit_unsupported`
231
223
  on that field. Top-level help names the product KXM instead of KontextMind,
232
224
  and `kxm init` text output lists each validation issue as `file: code: message`.
225
+
233
226
  - **`kxm suggest` recommends only KXM command skills.** Suggested skills come from the
234
227
  command skills shipped in `plugins/kxm/skills` (such as `kxm-workflow`, `kxm-runs`,
235
228
  `kxm-peer` and `kxm-context-memory`), never from skills that do not ship
236
229
  (`troubleshooting`, `modern-web-guidance`) or from the KontextMind knowledge-plane
237
230
  skills.
231
+
238
232
  - **The Claude plugin's MCP errors name the user's next step, and a session appears to
239
233
  peers before its first tool call.** An unreachable hub names the URL and `kxm hub start`
240
234
  or `/plugin configure kxm@kxm`; `invalid_auth` names the project token; a
@@ -246,6 +240,7 @@ All notable user-facing changes are documented here. The project follows [Semant
246
240
  server registers right after the MCP handshake instead of at the first tool call, and it
247
241
  leaves the hub when stdin closes. The server instructions point Claude at `kxm_context`
248
242
  and at telling the user the next step, in under 800 characters.
243
+
249
244
  - **The Claude plugin README is rewritten, and its tool table is pinned to the MCP
250
245
  server.** It covers requirements (`node` on `PATH`, a hub, and the `kxm` CLI for the
251
246
  operator only), installing from Claude Code or the shell, each `userConfig` option and
@@ -255,6 +250,7 @@ All notable user-facing changes are documented here. The project follows [Semant
255
250
  be entered again), and troubleshooting for each user-directed error. A test fails when
256
251
  the README's `## MCP tools` rows and the server's `tools/list` disagree in either
257
252
  direction. The configuration docs now say that `kxm_await` waits at most 60 seconds.
253
+
258
254
  - **The skill suite is rescoped: every command has one owning skill, and `kxm-setup` is
259
255
  renamed `kxm-mind-setup` with no alias.** `skill-suite.json` declares all 29 bundled
260
256
  skills (13 KXM command skills, 7 browser skills, 9 KontextMind knowledge-plane skills),
@@ -269,6 +265,7 @@ All notable user-facing changes are documented here. The project follows [Semant
269
265
  stopping where the user reviews and commits `.kxm` changes, and `kxm session brief`
270
266
  (which saves a 24-hour operator token) appears only under its operator steps. **Rename:**
271
267
  anything that names the `kxm-setup` skill must name `kxm-mind-setup`.
268
+
272
269
  - **Context packets rank by deterministic task relevance.** `kxm context get`,
273
270
  `kxm_context` and Runtime dispatch order eligible items by nine keys: open
274
271
  contradictions first, project before `_shared` defaults, items that share a word with
@@ -280,6 +277,7 @@ All notable user-facing changes are documented here. The project follows [Semant
280
277
  stops smaller ones from fitting, and non-current state and proposed skills no longer
281
278
  consume budget. `audit.relevance` reports numbers only (`taskTokens`,
282
279
  `matchedCandidates`, and a rounded score per selected item).
280
+
283
281
  - **`kxm_improvement_report` returns ranked, redacted cross-run signals.** Alongside the
284
282
  per-area reports, `GET /v1/improvements` returns `signals`: journal entries from the
285
283
  project's runs merged by evidence class, then an error's stage, then a normalized
@@ -289,14 +287,17 @@ All notable user-facing changes are documented here. The project follows [Semant
289
287
  is labelled `unknown`, never 0; security signals rank first. The journal and
290
288
  retrospective loop covers hub webhook runs only; `kxm run` (Runtime) runs have no
291
289
  journal yet.
290
+
292
291
  - **Recall ranks exact phrases, then token relevance, then id, and returns a relevance
293
292
  per item.** `kxm context recall` and `kxm_recall` previously returned substring matches
294
293
  in id order. Items that neither contain the query nor share a word with it are still
295
294
  left out, and results still carry metadata only, never summaries.
295
+
296
296
  - **The hub logs task and query sizes, not their text.** `context_packet_assembled` now
297
297
  records `taskChars`, `taskTokens` and `matchedCandidates`, and `context_recall` records
298
298
  `queryChars` and `queryTokens`. The caller still receives its own request in the
299
299
  response.
300
+
300
301
  - **Engine routing records carry an ask identity and only gate-negative outcomes.** Every
301
302
  `routing.attempt.recorded` record carries four engine-reserved `providerMetadata` keys,
302
303
  written after the producer's so a producer cannot spoof them: `workflowId`, `askSha256`
@@ -306,6 +307,7 @@ All notable user-facing changes are documented here. The project follows [Semant
306
307
  only as `blocked` (a back edge) or `failed` (a producer error, an undeclared outcome or a
307
308
  failing terminal); acceptance is resolved later from the event log. Records written
308
309
  before this change are not backfilled.
310
+
309
311
  - **`improvement.promotionPolicy` reports review readiness and never authorizes.**
310
312
  `kxm improve` now reads `improvement.*` and reports, per candidate, `readyForReview` and a
311
313
  reason under the configured policy: `manual_pr` is always ready for an operator PR,
@@ -317,12 +319,14 @@ All notable user-facing changes are documented here. The project follows [Semant
317
319
  out-of-range thresholds fall back to 10, 0.95 and 0.5.
318
320
  `improvement.telemetryHalfLifeDays` orders report rows through `weightedRecurrence` and
319
321
  never decides candidacy.
322
+
320
323
  - **`kxm routing report` reads Runtime records by default and counts only event-log
321
324
  acceptance as a Runtime pass.** Without `--file` it reads the current project's Runtime
322
325
  event store and then `.kxm/logs/telemetry.jsonl` (the same sources as `kxm improve`),
323
326
  and `--json` output gains `sources`. A Runtime attempt counts as a pass only when its run
324
327
  completed and the step was not re-entered. The ranking code is unchanged, and the rework
325
328
  column still reads `transitions`, which Runtime records do not set.
329
+
326
330
  - **`kxm improve --target` is removed.** It was accepted and never applied. Passing it
327
331
  is now an unknown-option error. `KXM_IMPROVE_TARGET` still labels telemetry when it is
328
332
  written; no report reads that label.
@@ -423,6 +427,7 @@ All notable user-facing changes are documented here. The project follows [Semant
423
427
  in both the JSON payload and the prose line, so `--json` consumers are not left with a bare
424
428
  code and no way forward; a stored-but-unusable binding otherwise reads later like a
425
429
  network fault and gets debugged as one.
430
+
426
431
  - **`kxm hub view` and the session brief label the binding `loopback` or `remote`.**
427
432
  "Attached across a network" and "attached on this box" looked identical before, and only
428
433
  one of them puts a bearer on a wire. `localhost`, `127.0.0.1`, `::1` and `*.localhost` are
@@ -438,10 +443,23 @@ All notable user-facing changes are documented here. The project follows [Semant
438
443
  folder names dropped the `kxm-` prefix. `docs/vnext/` is `docs/contracts/`,
439
444
  `examples/vnext/` is `examples/project/`. Dated evidence under `plans/` and
440
445
  `.kxm/logs/` keeps its original wording.
446
+
441
447
  - **Read-only run projection:** `GET /v1/runs/:id` folds the event log without
442
448
  persisting a projection write, so a read cannot mutate run state or surface a
443
449
  false `run_projection_divergent`.
444
450
 
451
+ - **Steel clients authenticate to Authentik with `Authorization: Basic`.**
452
+ `STEEL_AUTH_BASIC`, or `STEEL_AUTH_USER` and `STEEL_AUTH_TOKEN`, set that
453
+ header on Steel HTTP requests and on the CDP options from `formatCDPConnect()`.
454
+ `KXM_BROWSER=steel` passes those headers through `connectBrowserOverCdp()`
455
+ into `chromium.connectOverCDP`. Obscura stays the default and sends no Steel
456
+ headers. `STEEL_AUTH_HEADER` overrides the value. The CDP URL omits the credential when
457
+ those variables are set. A 302 to the identity provider fails closed and does
458
+ not follow the login redirect. `STEEL_API_KEY` still sends the legacy
459
+ `x-steel-api-key` header and `apiKey` query parameter for the temporary proxy
460
+ shim, and warns once. See
461
+ [Browser automation](docs/guides/browser-automation.md).
462
+
445
463
  ### Removed
446
464
 
447
465
  - **The developer roster file and the transport just recipes.**
@@ -452,6 +470,7 @@ All notable user-facing changes are documented here. The project follows [Semant
452
470
  Transfer policy from `.kxm/roster.yaml` to the role and model files and
453
471
  delete the retired roster before running KXM. KXM refuses a leftover
454
472
  `.kxm/roster.yaml`.
473
+
455
474
  - **`.kxm/template-provenance.yaml` was removed from this project, a repository
456
475
  change rather than a product change,** because the installed kxm no longer
457
476
  recognizes its recorded revision and a project without the file validates as
@@ -459,8 +478,10 @@ All notable user-facing changes are documented here. The project follows [Semant
459
478
 
460
479
  ### Fixed
461
480
 
462
- - **`kxm vision` is owned by the browser-verify skill.** `plugins/kxm/skill-suite.json` lists `vision` on `kxm-browser-verify`, the skill that already teaches `kxm vision assert`.
481
+ - **A committed checkout counts as authored work, and a one-shot outcome must be a standalone JSON object.** The authoring witness includes `HEAD` with porcelain status and both diffs, so a write that commits its edits is `changed` and can stay `passed`. A `rev-parse` failure keeps that empty head term only when the repository has no commits; any other git failure is `unwitnessed`. A one-shot outcome is accepted when the whole reply is one JSON object, or when that object stands alone on the final line. A closing code fence around the final object is allowed. An object followed by prose, a truncated reply, and an ambiguous tail settle `failed`.
482
+
463
483
  - **The test suite no longer passes `--test-timeout`.** Under `node --test` that flag bounds each file, so coverage on CI timed out `test/core/engine.test.ts` at three minutes. The wall clock in `scripts/run-bounded.mjs` still bounds each script.
484
+
464
485
  - **A git worktree lane registers under its project.** The Runtime registry
465
486
  keeps the home row and adds the lane as its own control root and event
466
487
  store, with the same project id and home runtime (`lane_of` on the lane
@@ -475,11 +496,13 @@ All notable user-facing changes are documented here. The project follows [Semant
475
496
  unsettled run in that lane's event store unless `--force` is set. Unregister
476
497
  answers 409 `runtime_project_busy` or `runtime_project_has_lanes` unless the
477
498
  body sets `force`.
499
+
478
500
  - **`npm test` exits when the suite finishes.** The script passes
479
501
  `--test-force-exit`, so a green run does not sit in the event loop and a
480
502
  red run still prints its failures. Two full runs of the suite without the
481
503
  flag also exited once the files finished; the handle that kept an earlier
482
504
  run alive was not reproduced in this tree.
505
+
483
506
  - **`kxm land` names the pull request from the first commit subject and matches the Release run by time.**
484
507
  `--title` sets the title; otherwise the subject of the first commit on the
485
508
  branch is used, and a missing subject refuses `land_pr_title_missing`. The
@@ -496,14 +519,17 @@ All notable user-facing changes are documented here. The project follows [Semant
496
519
  writes nothing. A `--file` in the shape written through 0.7.92 is refused, and
497
520
  `--overwrite` repairs a file left in that shape. See
498
521
  `docs/reference/cli-reference.md#kxm-workflow-add`.
522
+
499
523
  - **`kxm workflow add --pick <global-id>` copies the global definition.** In local scope,
500
524
  picking a global definition wrote the one-step scaffold under its id and reported
501
525
  success. It now writes the global definition's content, with `--description` replacing
502
526
  its description, and the loader check refuses one the project cannot load.
527
+
503
528
  - **`kxm role add --pick <global-id>` copies the global role.** In local scope, picking a
504
529
  global role wrote an empty `Role <id>` with no skills or roster under its ID. It now
505
530
  writes the global role's content, with `--description`, `--skills` and `--model`
506
531
  replacing those fields the way they do for a built-in template.
532
+
507
533
  - **`kxm role add` no longer leaves a project that refuses to load.** A local add now needs
508
534
  a KXM project (`project_not_found` otherwise, and no stray `.kxm/` that would make
509
535
  `kxm init` refuse), writes under the project root from any subdirectory, and is checked
@@ -511,6 +537,7 @@ All notable user-facing changes are documented here. The project follows [Semant
511
537
  `writer` role whose roster leaves out the `implementer` agent's model, including the
512
538
  built-in `writer` template for such a project, is refused with `role_invalid`, exit 2,
513
539
  and nothing is written, also under `--dry-run`.
540
+
514
541
  - **Live `kxm runs drive` can author on an audited writer profile.** A write-repository
515
542
  step on pi (`-a`, with extensions, skills, and the session off) or grok
516
543
  (`--always-approve`, with subagents and web search off) runs against the checkout.
@@ -565,18 +592,23 @@ All notable user-facing changes are documented here. The project follows [Semant
565
592
  run (`webhook_payload_replayed`). A reused delivery ID with a different body is
566
593
  refused with 409 `webhook_delivery_conflict`, and a duplicate start returns only
567
594
  `duplicate`, `runId` and `status`. Update any custom sender to the new contract.
595
+
568
596
  - **Agents never borrow the hub admin token.** With `KXM_AUTH_TOKEN` unset, the Pi
569
597
  extension and the `kxm peer` / `kxm workflow` agent commands used the persisted admin
570
598
  token. They now use only this project's saved project token, as the Claude MCP server
571
599
  does, and otherwise stop with a message naming the fix (`project_token_missing`, exit 2,
572
600
  on the CLI).
601
+
573
602
  - **The hop limit bounds agent forwarding chains.** `kxm_send` and `kxm_fanout` from Pi
574
603
  or the Claude MCP server send one hop past the inbound request being handled, so a
575
604
  chain of agents forwarding to each other stops at `hop_limit_reached`.
605
+
576
606
  - **Workflow prompts no longer point agents at `.kxm/config`**, a path KXM refuses.
607
+
577
608
  - **`kxm gate signal`, `kxm workflow wait` and `kxm role resume` inside a KXM project reach
578
609
  the hub for hub runs.** They go to the local Runtime only for a run its store holds, and
579
610
  the lookup leaves no files behind, so `--dry-run` changes nothing.
611
+
580
612
  - **`kxm peer inbox` lists the requests waiting for a named CLI agent.** It returned
581
613
  `{"messages":[]}` every time. The hub now serves `GET /v1/agents/:id/inbox`
582
614
  (agent-authenticated, project-scoped): the caller's queued and delivered requests,
@@ -584,6 +616,7 @@ All notable user-facing changes are documented here. The project follows [Semant
584
616
  `codex`) to list requests peers queued for it while it was offline, then answer them
585
617
  with `kxm peer reply`. The Pi extension's `kxm_inbox` tool now refuses instead of
586
618
  returning an empty list, because Pi activates each inbound request as a turn itself.
619
+
587
620
  - **A restarted Claude Code session keeps the requests it acknowledged but never answered.**
588
621
  The MCP server acknowledges a request on arrival, and the hub pushes only unacknowledged
589
622
  requests again on reconnect, so a session that restarted under the same agent name (the
@@ -606,11 +639,13 @@ All notable user-facing changes are documented here. The project follows [Semant
606
639
  user-directed fixes) followed by the unchanged memory brief. The plugin version stays
607
640
  0.7.1, so an existing install gets the hook only after the reinstall described in the
608
641
  plugin README.
642
+
609
643
  - **The Claude plugin's MCP server never authenticates with the hub admin token.** With a
610
644
  blank `auth_token` it fell back to the admin token saved in `hub-env.json` and registered
611
645
  the agent in a project nobody had issued it a token for. It now uses `KXM_AUTH_TOKEN` or
612
646
  this project's saved project token, and with neither it refuses before contacting the
613
647
  hub. The operator CLI and the Runtime supervisor resolve credentials as before.
648
+
614
649
  - **`kxm workflow add` writes workflows that load.** The one-step scaffold and the three
615
650
  built-in templates used `role:` where an agent step needs `agent:`, the scaffold added a
616
651
  top-level `id`, and the templates' gate steps named a `verify-gate` no project defines
@@ -635,6 +670,7 @@ All notable user-facing changes are documented here. The project follows [Semant
635
670
  Workflow-step candidates now propose a `kind: gate` step and a `gates.yaml` entry with a
636
671
  placeholder command, and skill candidates are labelled consolidation. Candidates remain
637
672
  proposals; nothing is applied.
673
+
638
674
  - **Runtime-dispatched agents receive committed, pinned project memory and hash-verified
639
675
  promoted skills.** The engine built each agent's context packet with no project items, so
640
676
  `.kxm/memory` and promoted skills never reached a `kxm run` agent. Now, when either
@@ -644,6 +680,7 @@ All notable user-facing changes are documented here. The project follows [Semant
644
680
  memory revision. Otherwise the context is withheld with a `dispatch_context_*` gap in the
645
681
  packet (never in the prompt) and the step still runs. Promoted skills render under a new
646
682
  `### Active Skills` heading. No hub call is made at dispatch.
683
+
647
684
  - **Journal entries accept all ten categories and stage provenance.** The shared
648
685
  `kxm_workflow_record` tool (MCP, Pi and `kxm workflow record`) offered 5 of the 10
649
686
  categories, required an area and dropped `stageId`. It now takes every category and an
@@ -654,11 +691,13 @@ All notable user-facing changes are documented here. The project follows [Semant
654
691
  approval and premature settlement) carry the stage and attempt; the checkpoint and
655
692
  premature-settlement cases are the ones under test. `kxm workflow record` gained
656
693
  `--stage-id` and accepts `record <runId> <category> <summary>` when area is omitted.
694
+
657
695
  - **Late journal entries and promotions refresh the exported retrospective.** A terminal
658
696
  run's retrospective is re-exported when an entry is recorded or a promotion decided
659
697
  afterwards. Retrospectives also count only error entries as recurring error classes and
660
698
  propose up to 12 ranked error and lesson signals. A promotion now publishes its update to
661
699
  the run's project rather than to the run id.
700
+
662
701
  - **Context packets deliver the evidence they select.** Evidence items could be selected
663
702
  and budgeted but no packet section carried them; packets now have an `evidence` section,
664
703
  and the repro and implementer roles receive evidence, so the error, observation and
@@ -695,6 +734,7 @@ All notable user-facing changes are documented here. The project follows [Semant
695
734
  - **`kxm hub start` no longer generates and persists an admin token when it is
696
735
  about to refuse** because another hub already owns the claim. A refused start
697
736
  used to leave behind credentials the running hub never issued.
737
+
698
738
  - **Runtime intake contract fixes (released in 0.7.46, found in review):**
699
739
  a lost intake insert race accepted different content under an already-used
700
740
  idempotency key; resume drained only one page, so held intent beyond 500
@@ -707,6 +747,7 @@ All notable user-facing changes are documented here. The project follows [Semant
707
747
  arrival order, so a backdated timestamp cannot jump the queue; persisted records
708
748
  are cross-checked against every duplicated column on read; and the intake schema
709
749
  no longer admits contradictory states.
750
+
710
751
  - **A coordinator rebind could widen a tool ceiling by clearing its allow list.**
711
752
  An absent or empty allow list imposes no restriction, so dropping a populated one
712
753
  is now refused (`coordinator_rebind_clears_allowlist`). Resume drains held intent
@@ -716,6 +757,7 @@ All notable user-facing changes are documented here. The project follows [Semant
716
757
  require a policy rebind after upgrade — and the equivalence test is shared, so a
717
758
  row written by 0.7.46 is not a `coordinator_write_lost` conflict just because it
718
759
  was found by losing an insert race instead of reading the slot.
760
+
719
761
  - **A failed `BEGIN` poisoned the database connection.** The transaction marker was
720
762
  claimed before `BEGIN` and the statement sat outside the `try/finally`, so a
721
763
  `BEGIN` that gave up on a busy writer left every later transaction failing with a
@@ -750,6 +792,8 @@ All notable user-facing changes are documented here. The project follows [Semant
750
792
  object in this process — it is not cross-process, and it does not leak to another
751
793
  connection to the same database.
752
794
 
795
+ - **`kxm vision` is owned by the browser-verify skill.** `plugins/kxm/skill-suite.json` lists `vision` on `kxm-browser-verify`, the skill that already teaches `kxm vision assert`.
796
+
753
797
  ## 0.7.0 - 2026-09-11
754
798
 
755
799
  ### Added
@@ -105,16 +105,13 @@ created → accepted → dispatched → executing → result_recorded → termin
105
105
  | `blocked_uncertain` | A dependent effect cannot be reconciled safely |
106
106
  | `terminal` | The logical assignment outcome is final and immutable: passed, failed, or cancelled |
107
107
 
108
- A producer reply becomes a terminal outcome **only** through a declared result: the reply is
109
- one JSON object, or it carries a `{"outcome": "…"}` object **on a line of its own**. With more
110
- than one such line the last one is the answer; if anything after it still looks like an outcome
111
- key — an inline `Actual result: {"outcome": "failed"}`, a pretty-printed object, a second
112
- mention — the reply is **ambiguous and settles `failed`**, because guessing which declaration
113
- was meant is the behaviour this rule removes. Ordinary trailing prose (a sign-off, a token
114
- count) does not disturb a declared result.
115
-
116
- Naming an outcome *word* anywhere in a reply is not a result — `"the gate did not pass, so I
117
- would not call this passed"` must not advance a step — and an empty or unstructured reply is
108
+ A producer reply becomes a terminal outcome **only** through a declared result: the whole
109
+ reply is one JSON object, or the object stands alone on the final line. A closing code fence
110
+ around that final object is allowed. If the tail is ambiguous, or prose follows the object,
111
+ the outcome is `failed`.
112
+
113
+ Naming an outcome *word* anywhere in a reply is not a result. `"the gate did not pass, so I
114
+ would not call this passed"` must not advance a step, and an empty or unstructured reply is
118
115
  never treated as success. A declared outcome outside the step's declared set is not accepted
119
116
  either:
120
117
  the assignment is recorded `outcome_unknown` and terminates as `failed`, which is also what
@@ -1689,7 +1689,7 @@ kxm runs status --dry-run refused: the Runtime supervisor is not running and --d
1689
1689
  kxm runs drive <runId> [--simulated] [--wait] [--timeout-ms <n>] [--lane <unit>]
1690
1690
  ```
1691
1691
 
1692
- Opens a drive of the run. With `--simulated`, a model-free producer reports every agent step as passed. Without `--simulated` the drive runs in live mode: each agent step invokes its harness through a one-shot producer, and the agent's model must be an admitted route (otherwise `producer_route_not_admitted`; there is no fallback model). A read-only step runs with the harness's read-only flags. A step with `write` access runs with an audited writer profile, which only `pi` and `grok` have; it must be a single assignment in a project whose `limits.maxConcurrentRuns` is 1, and its route must be on the writer roster in `.kxm/roles/writer.yaml`. Otherwise the drive hands the run off with `step_unsupported`. Around each live attempt the Runtime fingerprints the checkout with `git status` and `git diff`: a write step settles `passed` only when the checkout changed (routing metadata `authored: true`), and a read-only step that changed it settles `failed` (`authoringWitness: readonly_mutated`).
1692
+ Opens a drive of the run. With `--simulated`, a model-free producer reports every agent step as passed. Without `--simulated` the drive runs in live mode: each agent step invokes its harness through a one-shot producer, and the agent's model must be an admitted route (otherwise `producer_route_not_admitted`; there is no fallback model). A read-only step runs with the harness's read-only flags. A step with `write` access runs with an audited writer profile, which only `pi` and `grok` have; it must be a single assignment in a project whose `limits.maxConcurrentRuns` is 1, and its route must be on the writer roster in `.kxm/roles/writer.yaml`. Otherwise the drive hands the run off with `step_unsupported`. Around each live attempt the Runtime fingerprints the checkout with `git rev-parse HEAD`, `git status`, and `git diff`: a write step settles `passed` only when the checkout changed (routing metadata `authored: true`), and a read-only step that changed it settles `failed` (`authoringWitness: readonly_mutated`).
1693
1693
 
1694
1694
  | Option | Argument | Default | Description |
1695
1695
  |---|---|---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontextmind/kxm",
3
- "version": "0.7.141",
3
+ "version": "0.7.143",
4
4
  "description": "KXM local-first multi-agent orchestration and operator dashboard",
5
5
  "type": "module",
6
6
  "author": "KontextMind",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "kxm",
4
4
  "displayName": "KXM",
5
- "version": "0.7.141",
5
+ "version": "0.7.143",
6
6
  "description": "Headless multi-agent orchestration, durable workflows, and a live operator dashboard for Pi and Claude Code",
7
7
  "author": {
8
8
  "name": "KontextMind",
@@ -17313,7 +17313,7 @@ function sessionTokenFixHint(policy) {
17313
17313
  }
17314
17314
 
17315
17315
  // plugins/kxm/src/mcp-server.ts
17316
- var VERSION = "0.7.141";
17316
+ var VERSION = "0.7.143";
17317
17317
  var CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
17318
17318
  var inbox = /* @__PURE__ */ new Map();
17319
17319
  var notifiedInbox = /* @__PURE__ */ new Set();
@@ -24508,14 +24508,23 @@ function gitText(cwd, args) {
24508
24508
  if (result.error || result.status !== 0) return void 0;
24509
24509
  return result.stdout ?? "";
24510
24510
  }
24511
+ function repositoryHasNoCommits(cwd) {
24512
+ const listed = gitText(cwd, ["rev-list", "--max-count=1", "--all"]);
24513
+ return listed !== void 0 && listed.trim() === "";
24514
+ }
24511
24515
  function captureWorktreeWitness(cwd) {
24516
+ const headRun = gitText(cwd, ["rev-parse", "HEAD"]);
24517
+ if (headRun === void 0 && !repositoryHasNoCommits(cwd)) {
24518
+ return { unwitnessed: true, fingerprint: "" };
24519
+ }
24520
+ const head = headRun ?? "";
24512
24521
  const porcelain = gitText(cwd, ["status", "--porcelain=v1", "-uall"]);
24513
24522
  const diff = gitText(cwd, ["diff", "--no-ext-diff"]);
24514
24523
  const staged = gitText(cwd, ["diff", "--cached", "--no-ext-diff"]);
24515
24524
  if (porcelain === void 0 || diff === void 0 || staged === void 0) {
24516
24525
  return { unwitnessed: true, fingerprint: "" };
24517
24526
  }
24518
- return { unwitnessed: false, fingerprint: `${porcelain}\0${diff}\0${staged}` };
24527
+ return { unwitnessed: false, fingerprint: `${head}\0${porcelain}\0${diff}\0${staged}` };
24519
24528
  }
24520
24529
  function worktreeChanged(before, after) {
24521
24530
  if (before.unwitnessed || after.unwitnessed) return false;
@@ -29890,16 +29899,84 @@ function recoverKxmRun(context, runId, request) {
29890
29899
  }
29891
29900
 
29892
29901
  // plugins/kxm/src/oneshot-producer.ts
29893
- function determineOutcome(text, allowedOutcomes) {
29894
- try {
29895
- const result = JSON.parse(text.trim());
29896
- if (result && typeof result === "object" && !Array.isArray(result)) {
29897
- const outcome = result.outcome;
29898
- if (typeof outcome === "string" && allowedOutcomes.includes(outcome)) return outcome;
29902
+ function lastBalancedJsonObject(text) {
29903
+ let depth = 0;
29904
+ let inString = false;
29905
+ let escape2 = false;
29906
+ let start = -1;
29907
+ let lastStart = -1;
29908
+ let lastEnd = -1;
29909
+ for (let i = 0; i < text.length; i++) {
29910
+ const ch = text[i];
29911
+ if (inString) {
29912
+ if (escape2) {
29913
+ escape2 = false;
29914
+ continue;
29915
+ }
29916
+ if (ch === "\\") {
29917
+ escape2 = true;
29918
+ continue;
29919
+ }
29920
+ if (ch === '"') inString = false;
29921
+ continue;
29899
29922
  }
29923
+ if (ch === '"') {
29924
+ inString = true;
29925
+ continue;
29926
+ }
29927
+ if (ch === "{") {
29928
+ if (depth === 0) start = i;
29929
+ depth++;
29930
+ continue;
29931
+ }
29932
+ if (ch === "}") {
29933
+ if (depth === 0) continue;
29934
+ depth--;
29935
+ if (depth === 0) {
29936
+ lastStart = start;
29937
+ lastEnd = i;
29938
+ }
29939
+ }
29940
+ }
29941
+ if (lastStart < 0) return void 0;
29942
+ return text.slice(lastStart, lastEnd + 1);
29943
+ }
29944
+ function standaloneObjectText(text) {
29945
+ const trimmed = text.trim();
29946
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return void 0;
29947
+ const slice = lastBalancedJsonObject(trimmed);
29948
+ return slice === trimmed ? slice : void 0;
29949
+ }
29950
+ function outcomeOfObjectText(text) {
29951
+ const slice = standaloneObjectText(text);
29952
+ if (slice === void 0) return void 0;
29953
+ try {
29954
+ const result = JSON.parse(slice);
29955
+ if (!result || typeof result !== "object" || Array.isArray(result)) return void 0;
29956
+ const outcome = result.outcome;
29957
+ return typeof outcome === "string" ? outcome : void 0;
29900
29958
  } catch {
29959
+ return void 0;
29901
29960
  }
29902
- return "failed";
29961
+ }
29962
+ function isClosingFence(line) {
29963
+ return /^(`{3,}|~{3,})\s*$/.test(line.trim());
29964
+ }
29965
+ function determineOutcome(text, allowedOutcomes) {
29966
+ const trimmed = text.trim();
29967
+ if (!trimmed) return "failed";
29968
+ const whole = outcomeOfObjectText(trimmed);
29969
+ if (whole !== void 0) return allowedOutcomes.includes(whole) ? whole : "failed";
29970
+ const lines = trimmed.split(/\r?\n/);
29971
+ while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
29972
+ if (lines.length > 0 && isClosingFence(lines[lines.length - 1])) {
29973
+ lines.pop();
29974
+ while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
29975
+ }
29976
+ if (lines.length === 0) return "failed";
29977
+ const declared = outcomeOfObjectText(lines[lines.length - 1]);
29978
+ if (declared === void 0 || !allowedOutcomes.includes(declared)) return "failed";
29979
+ return declared;
29903
29980
  }
29904
29981
  function createKxmOneShotProducer(options = {}) {
29905
29982
  const running = /* @__PURE__ */ new Map();
@@ -25235,14 +25235,23 @@ function gitText(cwd, args) {
25235
25235
  if (result.error || result.status !== 0) return void 0;
25236
25236
  return result.stdout ?? "";
25237
25237
  }
25238
+ function repositoryHasNoCommits(cwd) {
25239
+ const listed = gitText(cwd, ["rev-list", "--max-count=1", "--all"]);
25240
+ return listed !== void 0 && listed.trim() === "";
25241
+ }
25238
25242
  function captureWorktreeWitness(cwd) {
25243
+ const headRun = gitText(cwd, ["rev-parse", "HEAD"]);
25244
+ if (headRun === void 0 && !repositoryHasNoCommits(cwd)) {
25245
+ return { unwitnessed: true, fingerprint: "" };
25246
+ }
25247
+ const head = headRun ?? "";
25239
25248
  const porcelain = gitText(cwd, ["status", "--porcelain=v1", "-uall"]);
25240
25249
  const diff = gitText(cwd, ["diff", "--no-ext-diff"]);
25241
25250
  const staged = gitText(cwd, ["diff", "--cached", "--no-ext-diff"]);
25242
25251
  if (porcelain === void 0 || diff === void 0 || staged === void 0) {
25243
25252
  return { unwitnessed: true, fingerprint: "" };
25244
25253
  }
25245
- return { unwitnessed: false, fingerprint: `${porcelain}\0${diff}\0${staged}` };
25254
+ return { unwitnessed: false, fingerprint: `${head}\0${porcelain}\0${diff}\0${staged}` };
25246
25255
  }
25247
25256
  function worktreeChanged(before, after) {
25248
25257
  if (before.unwitnessed || after.unwitnessed) return false;
@@ -30638,16 +30647,84 @@ function recoverKxmRun(context, runId, request) {
30638
30647
  }
30639
30648
 
30640
30649
  // plugins/kxm/src/oneshot-producer.ts
30641
- function determineOutcome(text, allowedOutcomes) {
30642
- try {
30643
- const result = JSON.parse(text.trim());
30644
- if (result && typeof result === "object" && !Array.isArray(result)) {
30645
- const outcome = result.outcome;
30646
- if (typeof outcome === "string" && allowedOutcomes.includes(outcome)) return outcome;
30650
+ function lastBalancedJsonObject(text) {
30651
+ let depth = 0;
30652
+ let inString = false;
30653
+ let escape2 = false;
30654
+ let start = -1;
30655
+ let lastStart = -1;
30656
+ let lastEnd = -1;
30657
+ for (let i = 0; i < text.length; i++) {
30658
+ const ch = text[i];
30659
+ if (inString) {
30660
+ if (escape2) {
30661
+ escape2 = false;
30662
+ continue;
30663
+ }
30664
+ if (ch === "\\") {
30665
+ escape2 = true;
30666
+ continue;
30667
+ }
30668
+ if (ch === '"') inString = false;
30669
+ continue;
30670
+ }
30671
+ if (ch === '"') {
30672
+ inString = true;
30673
+ continue;
30674
+ }
30675
+ if (ch === "{") {
30676
+ if (depth === 0) start = i;
30677
+ depth++;
30678
+ continue;
30647
30679
  }
30680
+ if (ch === "}") {
30681
+ if (depth === 0) continue;
30682
+ depth--;
30683
+ if (depth === 0) {
30684
+ lastStart = start;
30685
+ lastEnd = i;
30686
+ }
30687
+ }
30688
+ }
30689
+ if (lastStart < 0) return void 0;
30690
+ return text.slice(lastStart, lastEnd + 1);
30691
+ }
30692
+ function standaloneObjectText(text) {
30693
+ const trimmed = text.trim();
30694
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return void 0;
30695
+ const slice = lastBalancedJsonObject(trimmed);
30696
+ return slice === trimmed ? slice : void 0;
30697
+ }
30698
+ function outcomeOfObjectText(text) {
30699
+ const slice = standaloneObjectText(text);
30700
+ if (slice === void 0) return void 0;
30701
+ try {
30702
+ const result = JSON.parse(slice);
30703
+ if (!result || typeof result !== "object" || Array.isArray(result)) return void 0;
30704
+ const outcome = result.outcome;
30705
+ return typeof outcome === "string" ? outcome : void 0;
30648
30706
  } catch {
30707
+ return void 0;
30649
30708
  }
30650
- return "failed";
30709
+ }
30710
+ function isClosingFence(line) {
30711
+ return /^(`{3,}|~{3,})\s*$/.test(line.trim());
30712
+ }
30713
+ function determineOutcome(text, allowedOutcomes) {
30714
+ const trimmed = text.trim();
30715
+ if (!trimmed) return "failed";
30716
+ const whole = outcomeOfObjectText(trimmed);
30717
+ if (whole !== void 0) return allowedOutcomes.includes(whole) ? whole : "failed";
30718
+ const lines = trimmed.split(/\r?\n/);
30719
+ while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
30720
+ if (lines.length > 0 && isClosingFence(lines[lines.length - 1])) {
30721
+ lines.pop();
30722
+ while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
30723
+ }
30724
+ if (lines.length === 0) return "failed";
30725
+ const declared = outcomeOfObjectText(lines[lines.length - 1]);
30726
+ if (declared === void 0 || !allowedOutcomes.includes(declared)) return "failed";
30727
+ return declared;
30651
30728
  }
30652
30729
  function createKxmOneShotProducer(options = {}) {
30653
30730
  const running = /* @__PURE__ */ new Map();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.141",
3
+ "version": "0.7.143",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
11
11
  import type { HubEvent, MessageRecord } from "./protocol.ts";
12
12
  import { sessionTokenFixHint } from "./session-token-hint.ts";
13
13
 
14
- const VERSION = "0.7.141";
14
+ const VERSION = "0.7.143";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -51,15 +51,99 @@ export interface KxmOneShotProducer extends KxmProducer {
51
51
  close(): Promise<void>;
52
52
  }
53
53
 
54
- function determineOutcome(text: string, allowedOutcomes: readonly string[]): string {
55
- try {
56
- const result: unknown = JSON.parse(text.trim());
57
- if (result && typeof result === "object" && !Array.isArray(result)) {
58
- const outcome = (result as Record<string, unknown>).outcome;
59
- if (typeof outcome === "string" && allowedOutcomes.includes(outcome)) return outcome;
54
+ // The last complete top-level `{...}`. One forward pass tracks strings,
55
+ // escapes, and brace depth. A `{` that lifts depth from 0 to 1 opens a
56
+ // candidate; the matching `}` that returns depth from 1 to 0 closes it.
57
+ // The last such span wins. Callers accept the span only when it is the
58
+ // entire trimmed reply or the entire final line; an inner object is not
59
+ // an outcome.
60
+ function lastBalancedJsonObject(text: string): string | undefined {
61
+ let depth = 0;
62
+ let inString = false;
63
+ let escape = false;
64
+ let start = -1;
65
+ let lastStart = -1;
66
+ let lastEnd = -1;
67
+ for (let i = 0; i < text.length; i++) {
68
+ const ch = text[i];
69
+ if (inString) {
70
+ if (escape) {
71
+ escape = false;
72
+ continue;
73
+ }
74
+ if (ch === "\\") {
75
+ escape = true;
76
+ continue;
77
+ }
78
+ if (ch === '"') inString = false;
79
+ continue;
80
+ }
81
+ if (ch === '"') {
82
+ inString = true;
83
+ continue;
84
+ }
85
+ if (ch === "{") {
86
+ if (depth === 0) start = i;
87
+ depth++;
88
+ continue;
60
89
  }
61
- } catch { /* Prose, substring matches, and missing results cannot imply success. */ }
62
- return "failed";
90
+ if (ch === "}") {
91
+ if (depth === 0) continue;
92
+ depth--;
93
+ if (depth === 0) {
94
+ lastStart = start;
95
+ lastEnd = i;
96
+ }
97
+ }
98
+ }
99
+ if (lastStart < 0) return undefined;
100
+ return text.slice(lastStart, lastEnd + 1);
101
+ }
102
+
103
+ // A standalone object is one balanced `{...}` covering the entire trimmed text.
104
+ function standaloneObjectText(text: string): string | undefined {
105
+ const trimmed = text.trim();
106
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return undefined;
107
+ const slice = lastBalancedJsonObject(trimmed);
108
+ return slice === trimmed ? slice : undefined;
109
+ }
110
+
111
+ function outcomeOfObjectText(text: string): string | undefined {
112
+ const slice = standaloneObjectText(text);
113
+ if (slice === undefined) return undefined;
114
+ try {
115
+ const result: unknown = JSON.parse(slice);
116
+ if (!result || typeof result !== "object" || Array.isArray(result)) return undefined;
117
+ const outcome = (result as Record<string, unknown>).outcome;
118
+ return typeof outcome === "string" ? outcome : undefined;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ function isClosingFence(line: string): boolean {
125
+ return /^(`{3,}|~{3,})\s*$/.test(line.trim());
126
+ }
127
+
128
+ // The whole reply is one JSON object, or the object stands alone on the final
129
+ // line. A closing code fence after that line is allowed. Prose after the
130
+ // object, an ambiguous tail, a truncated reply, and a missing or disallowed
131
+ // outcome settle failed.
132
+ function determineOutcome(text: string, allowedOutcomes: readonly string[]): string {
133
+ const trimmed = text.trim();
134
+ if (!trimmed) return "failed";
135
+ const whole = outcomeOfObjectText(trimmed);
136
+ if (whole !== undefined) return allowedOutcomes.includes(whole) ? whole : "failed";
137
+ const lines = trimmed.split(/\r?\n/);
138
+ while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop();
139
+ if (lines.length > 0 && isClosingFence(lines[lines.length - 1]!)) {
140
+ lines.pop();
141
+ while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop();
142
+ }
143
+ if (lines.length === 0) return "failed";
144
+ const declared = outcomeOfObjectText(lines[lines.length - 1]!);
145
+ if (declared === undefined || !allowedOutcomes.includes(declared)) return "failed";
146
+ return declared;
63
147
  }
64
148
 
65
149
  export function createKxmOneShotProducer(options: KxmOneShotProducerOptions = {}): KxmOneShotProducer {
@@ -3,8 +3,10 @@ import type { KxmProducerResult } from "./engine.ts";
3
3
 
4
4
  /**
5
5
  * Fingerprint of a checkout taken around a live producer spawn.
6
- * Porcelain alone misses a content edit that keeps the same status line, so the
7
- * witness also includes unstaged and staged diffs.
6
+ * The fingerprint covers HEAD plus the working tree. Porcelain alone misses a
7
+ * content edit that keeps the same status line, and a commit leaves porcelain
8
+ * and both diffs empty, so the witness is `head`, porcelain, unstaged diff,
9
+ * and staged diff.
8
10
  */
9
11
  export interface WorktreeWitness {
10
12
  readonly unwitnessed: boolean;
@@ -21,14 +23,30 @@ function gitText(cwd: string, args: readonly string[]): string | undefined {
21
23
  return result.stdout ?? "";
22
24
  }
23
25
 
26
+ function repositoryHasNoCommits(cwd: string): boolean {
27
+ // `rev-parse HEAD` prints the same failure for an unborn branch and for a
28
+ // HEAD that names a missing ref. An empty `rev-list --all` is the repository
29
+ // with no commits. Any other failure is not that case.
30
+ const listed = gitText(cwd, ["rev-list", "--max-count=1", "--all"]);
31
+ return listed !== undefined && listed.trim() === "";
32
+ }
33
+
24
34
  export function captureWorktreeWitness(cwd: string): WorktreeWitness {
35
+ // No commits yet: keep an empty head term so porcelain and the diffs still
36
+ // witness the tree. A rev-parse failure in a repository that has commits, or
37
+ // any failure to ask git whether commits exist, is unwitnessed.
38
+ const headRun = gitText(cwd, ["rev-parse", "HEAD"]);
39
+ if (headRun === undefined && !repositoryHasNoCommits(cwd)) {
40
+ return { unwitnessed: true, fingerprint: "" };
41
+ }
42
+ const head = headRun ?? "";
25
43
  const porcelain = gitText(cwd, ["status", "--porcelain=v1", "-uall"]);
26
44
  const diff = gitText(cwd, ["diff", "--no-ext-diff"]);
27
45
  const staged = gitText(cwd, ["diff", "--cached", "--no-ext-diff"]);
28
46
  if (porcelain === undefined || diff === undefined || staged === undefined) {
29
47
  return { unwitnessed: true, fingerprint: "" };
30
48
  }
31
- return { unwitnessed: false, fingerprint: `${porcelain}\0${diff}\0${staged}` };
49
+ return { unwitnessed: false, fingerprint: `${head}\0${porcelain}\0${diff}\0${staged}` };
32
50
  }
33
51
 
34
52
  export function worktreeChanged(before: WorktreeWitness, after: WorktreeWitness): boolean {