leerness 1.36.185 → 1.36.187

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.ko.md +5 -0
  3. package/README.md +14 -4
  4. package/bin/leerness.js +291 -57
  5. package/docs/e2e-observation-contract.md +51 -0
  6. package/docs/runtime-compatibility-api.md +183 -0
  7. package/docs/state-paths-api.md +92 -0
  8. package/docs/state-scopes.md +266 -0
  9. package/docs/worktree-runtime-migration.md +278 -0
  10. package/lib/claims-baseline.js +1 -1
  11. package/lib/file-leases.js +2 -2
  12. package/lib/git.js +64 -15
  13. package/lib/io.js +9 -3
  14. package/lib/migrate.js +1 -1
  15. package/lib/preview-serve.js +5 -1
  16. package/lib/pure-utils.js +20 -0
  17. package/lib/role-fallback.js +3 -3
  18. package/lib/role-store.js +2 -2
  19. package/lib/runtime-layout.js +395 -0
  20. package/lib/runtime-writes.js +166 -0
  21. package/lib/session-close.js +1 -1
  22. package/lib/state-git.js +138 -0
  23. package/lib/state-inspect.js +39 -0
  24. package/lib/state-inventory.js +69 -0
  25. package/lib/state-paths.js +59 -0
  26. package/lib/workspace-dir.js +34 -10
  27. package/package.json +10 -6
  28. package/scripts/command-flags-probe.js +1 -1
  29. package/scripts/dead-flags-probe.js +1 -1
  30. package/scripts/e2e-child-diagnostics-probe.js +143 -0
  31. package/scripts/e2e-child-diagnostics.js +33 -0
  32. package/scripts/e2e-temp-scope-probe.js +127 -0
  33. package/scripts/e2e.js +108 -27
  34. package/scripts/encoding-selftest-probe.js +222 -0
  35. package/scripts/false-claim-probe.js +34 -3
  36. package/scripts/file-lease-probe.js +5 -2
  37. package/scripts/legacy-runtime-negative-probe.js +72 -0
  38. package/scripts/mutation-integrity-probe.js +3 -0
  39. package/scripts/role-fallback-probe.js +67 -6
  40. package/scripts/runtime-admission-probe.js +282 -0
  41. package/scripts/runtime-git-write-probe.js +116 -0
  42. package/scripts/runtime-layout-probe.js +1198 -0
  43. package/scripts/runtime-repl-probe.js +271 -0
  44. package/scripts/runtime-replacement-probe.js +190 -0
  45. package/scripts/runtime-write-probe.js +458 -0
  46. package/scripts/selftest-cleanup-probe.js +168 -0
  47. package/scripts/state-inspect-cli-probe.js +209 -0
  48. package/scripts/state-scopes-probe.js +590 -0
  49. package/scripts/workspace-dir-lock-order-probe.js +310 -35
  50. package/scripts/workspace-dir-migration-probe.js +17 -2
  51. package/scripts/workspace-selection-probe.js +230 -0
@@ -0,0 +1,278 @@
1
+ # Worktree runtime migration: compatibility before relocation
2
+
3
+ Status: **P-0021 approved; T-0180 compatibility implementation under verification** (2026-09-06).
4
+ T-0179 records the original source audit. T-0175 / M-0015 remain incomplete.
5
+ Only A is authorized: diagnosis and observed-layout rejection. Storage relocation,
6
+ descriptor creation, activation, common control, finalize and cleanup are not implemented.
7
+
8
+ Source baseline: v1.36.186, commit `1805da9d2611a852be6a1b8636efa2977dd081f1`
9
+ (product source identical to release tag v1.36.186). Source references below use function
10
+ names so that line-number drift does not silently change the claimed boundary.
11
+ The [five-scope design](state-scopes.md) and [implemented inspection API](state-paths-api.md)
12
+ remain authoritative for the existing resolver. B/C below remain unimplemented proposals.
13
+ The [compatibility API](runtime-compatibility-api.md) describes the implemented A boundary.
14
+
15
+ ## 1. Why a compatibility release comes first
16
+
17
+ `lib/state-paths.js:resolveStatePaths` currently returns `activeLayout: legacy`,
18
+ `runtimeActivated: false`, `migrationAvailable: false` as constants. Existing writers
19
+ continue to use `.leerness`. A new manifest is invisible to v1.36.186 and older clients;
20
+ removing `state.json` lets `_loadLeernessState` create a new legacy state instead of stopping them.
21
+
22
+ `bin/leerness.js:main` performs automatic workspace migration and usage recording before
23
+ ordinary handlers. `_recordRun` writes with `fs.appendFileSync`; execution-ledger code
24
+ also writes through file descriptors. Guarding only a handler or `writeUtf8` misses writes.
25
+ `_handoffVersionSkew` warns about older CLIs; it is not a write barrier. Installed Git
26
+ hooks read legacy freshness paths directly. These are source-audit findings, not executed
27
+ mixed-version or interruption tests.
28
+
29
+ The release sequence is therefore:
30
+
31
+ 1. **A — compatibility only (P-0021 / T-0180):** strict layout reader, read-only diagnosis
32
+ and observed-layout write rejection. Legacy storage stays authoritative; no activation API.
33
+ 2. **B — explicit migration (separate preview/approval):** prove participating clients are
34
+ upgraded and stopped, specify a writer-admission protocol, snapshot/copy/validate, then
35
+ activate one worktree and a named migration unit. Unknown participants mean defer activation.
36
+ 3. **C — later units and integration:** migrate other coupled stores; connect M-0016 control,
37
+ M-0017 immutable records and M-0018 views. No claim that A completes these milestones.
38
+
39
+ A does not retroactively fence old or already-admitted writers. Even a check immediately
40
+ before a write has a check/write race; activation must not run concurrently with A-only
41
+ clients. Presence counts, elapsed time, PID probes and an empty lock directory do not prove
42
+ quiescence. The operator's maintenance declaration is a prerequisite, not an unbypassable
43
+ technical guarantee. Unmanaged clients, other clones and other hosts remain outside it.
44
+
45
+ ## 2. Source-backed inventory and ownership
46
+
47
+ Paths in this table use the current canonical `.leerness/` spelling. Many writers hardcode
48
+ that directory; the configurable workspace selector does not redirect all of them. The 22
49
+ known surface names match `lib/state-inventory.js:SURFACES`; its current classification tags
50
+ are not field-level migration authority. This is **not a complete inventory of every cache or
51
+ mutating command**. The reader/writer examples identify coupling, not an exhaustive call graph.
52
+ Before B, each migration unit needs a closed writer/reader registry and tests for all entries.
53
+
54
+ | Surface(s) | Current readers / writers | Field ownership and disposition |
55
+ |---|---|---|
56
+ | `cache/sessions/` | CLI `_sessionPresenceRecord`, `_readSessionEntries`, `_sessionSignal`, `sessionsCmd`; handoff, session close, hook session-start; `lib/session-presence.js` identity helpers | Per-session observations plus `.host-salt`. Preserve original address, source, timestamps and close tombstones. Not a live-agent registry. Smallest candidate unit: this entire directory and its consumers. |
57
+ | `cache/handoffs/` | `_recordHandoffFreshness`, `_latestHandoffFreshness`, `_getLastHandoffGap`; `enforceCmd` generated pre-commit and self-probe | Per-session freshness plus the reserved `unaddressed` marker. The hook uses filesystem mtime; copying must not manufacture a recent handoff. CLI and installed hooks must switch together. |
58
+ | `cache/agent-sessions/` | REPL `sessionPath` / `saveSession` | Raw conversation history, currently timestamp-addressed. Private; never automatically promote messages, prompts or history to tracked records. Owner mapping needs an explicit contract. |
59
+ | `cache/agent-runs/` | `_runsDir`, `_recordRun`, run-history consumers | Mixed/private intent and observations: one-shot records include raw user `task` text (up to 200 characters), provider/model, error and timing. Preserve task text and original IDs privately; not immutable reviewed completion evidence or cleanup-eligible disposable cache. |
60
+ | `cache/file-leases.json` | `lib/file-leases.js`, CLI `fileLeaseCmd` | Session owner, physical target identity, lease ID and expiry for this worktree. Keep distinct from repository task claims; do not move live leases into common control or silently renew TTL. |
61
+ | `state.json`, `runs/`, `handoff/` | `_loadLeernessState`, `_saveLeernessState`, `_loadRun`, `_saveRun`, `_updateRun`, `stateCmd` start/record/verify/handoff branches; MCP child calls | Coupled counter/current-run/session-owner map, per-run evidence and handoff artifact. Move only as one validated unit. Completed runs remain mutable today; M-0017 promotion is separate. |
62
+ | `execution-ledger.jsonl` | `lib/role-fallback.js`, agent history/record consumers | Mixed runtime provenance and potential durable-result inputs. Bind actual provider/model/fallback and reviewed revision when promoting later; the existing ignored ledger is not a finalize receipt. |
63
+ | `decisions.json`, `decisions.md` | `_loadDecisions`, `_saveDecisions` | Valid JSON array is the current authority; Markdown is a view/fallback. Strict import must reject invalid/non-array JSON rather than trust that fallback. Defer immutable import to M-0017. |
64
+ | `lessons.json`, `lessons.md` | `_loadLessons`, `_saveLessons` | Same strict-source boundary; validated lessons become separate immutable records only in M-0017. Preserve original Markdown text. |
65
+ | `progress-tracker.md` | Task parser/writers; `lib/session-close.js` | Task IDs, request, evidence, status and next action include project authority/user intent. Not disposable runtime. Keep unchanged until M-0018 has an accepted-input model. |
66
+ | `current-state.md` | Handoff readers; `lib/session-close.js:_upsertAutoLines` | User-written context plus generated auto lines. Preserve non-auto text; do not call the whole file a generated view. |
67
+ | `session-handoff.md` | Handoff readers; `lib/session-close.js` | Project instructions/context plus generated task summaries and session material. Split by ownership with original-byte retention, not by filename alone. |
68
+ | `active-wakeups.json` | `_loadActiveWakeups`, `_writeActiveWakeups`, `_recordWakeup`, `_analyzeWakeupStatus` | Requested schedule/source and runtime registration/status timestamps are mixed. Preserve scheduling intent; no scheduler changes or automatic execution in this migration. |
69
+ | `auto-resume-plan.json` | `_loadAutoResumePlan`, `_buildAutoResumePlan`, `_writeAutoResumePlan`, `resumeCmd` | `focus`, next-action titles/commands and note carry intent; `savedAt`, expected-fire data and context snapshot describe execution. Current task selection is not session ownership. Defer splitting. |
70
+ | `next-action-queue.json` | `_loadNextActionQueue`, `_writeNextActionQueue`, `_compactNextActionQueue` | Proposed titles/commands and IDs may encode intent. Preserve order, provenance and raw input; migration must not execute, silently deduplicate or rewrite these commands. |
71
+ | `pre-wake-report.json` | `_runPreWakeAudit`, pre-wake reporting, session-close integration | Mixed user-request copy and observations: `findings.critical[].items[].text` retains request text (up to 80 characters) beside IDs/counts/timestamps. Preserve the copy and its canonical request link; not proof of scheduled execution, liveness or safe disposal. |
72
+ | `last-handoff.json` | `_lastHandoffPath`, `_getLastHandoffGap`, legacy hook fallback | Legacy compatibility stamp, not a per-session ownership authority. Do not borrow another session's freshness. Include fallback retirement in the freshness unit. |
73
+ | `routing-log.json` | `lib/routing.js:routeCmd`, `_loadLog`, `_appendLog`, `_showLog`; dashboard reader | Mixed user intent and approval provenance: redacted original `task`, `declaredTier`, `riskDowngrade`, `requested` and `approvedBy` alongside assignments/observations. Bounded mutable list (200 records), not immutable approval evidence. Preserve fields and invalid-store rejection; no automatic cleanup or public promotion. |
74
+
75
+ Additional mutation paths outside the 22-surface metadata inventory include usage/update
76
+ caches, installation/migration metadata, hook installation, locks and session-close generated
77
+ artifacts. A's write-guard coverage must include these before any compatibility claim. Merely
78
+ adding 22 path checks is insufficient. Existing `check`/`audit`/`health` use
79
+ `lib/state-integrity.js:findCorruptedStateJson`, which only parses immediate workspace JSON,
80
+ skips some unreadable/empty cases and does not validate nested runtime layouts or schemas.
81
+
82
+ ### Identity and minimum migration units
83
+
84
+ - Reuse `deriveSessionKey` and `normalizePresenceEnv`; keep case folding, explicit MCP
85
+ addresses and reserved-key rules. Do not derive ownership from PID, model, branch or newest run.
86
+ - Presence without a valid address remains unregistered. Existing anonymous structured-state
87
+ behavior is compatibility data, not permission to attribute it to the currently calling agent.
88
+ Import unknown ownership as unresolved; concurrent mutation needs an explicitly accepted owner.
89
+ - Preserve presence `.host-salt` privately with the directory; no public salt or identity export.
90
+ Preserve ambiguous case variants and invalid close records; do not prune during migration.
91
+ - Unit B1 candidate is presence alone. It still requires A and the B admission/maintenance
92
+ contract; it cannot be sold as full session/runtime isolation. Freshness with installed hooks
93
+ is B2; `state.json + runs + handoff` is B3. Unit selection is part of B's separate approval.
94
+ - A completed run's status does not make its bytes immutable. Existing verify-claim and
95
+ handoff evidence must keep resolving throughout B3. Do not reassign run counters or owner maps.
96
+ - `_leernessStateDir` also serves project `policy.json` via `_policyFile` / `_savePolicy`.
97
+ Replacing that helper wholesale would relocate policy with runtime; split the callers first.
98
+ - `state verify` records a supplied result; it does not execute tests. `session close` updates
99
+ tracked summaries and presence, but does not call `state handoff` or finalize immutable runs.
100
+ `runs list/show` instead reads `cache/agent-runs`, not the structured `runs/` directory.
101
+ - MCP `callLeerness` supplies a validated per-call session address when present; otherwise
102
+ inherited environment identity or anonymous state can still be shared. A stdio connection
103
+ ID is not a replacement ownership key. Invalid explicit CLI addresses may fall back to an
104
+ ambient address today; do not silently carry that behavior into a strict migration owner map.
105
+ - Agent-run task text, pre-wake request copies and routing approval provenance require a
106
+ retained private copy outside any checkout/private Git directory scheduled for removal, or
107
+ separately approved durable promotion that preserves their meaning and privacy. An existing
108
+ request/task link must be verified, not assumed to contain the whole original. Unresolved
109
+ retention blocks cleanup; M-0017 must not auto-publish raw task/approver/conversation data.
110
+ The old inspector's `runtime` tags do not authorize movement, deletion or lossy partitioning.
111
+
112
+ ## 3. Approved compatibility slice (P-0021)
113
+
114
+ ### Descriptor location and conservative decoding
115
+
116
+ Git compatibility descriptor: `<GIT_DIR>/leerness/layout.json`. This is a fixed,
117
+ worktree-wide admission location, deliberately **not keyed by projectKey**. An incompatible
118
+ descriptor conservatively blocks A's Leerness metadata writes for every subproject in that
119
+ worktree; it does not select a project's runtime. The runtime payload paths stay under
120
+ `<GIT_DIR>/leerness/projects/<projectKey>/runtime/` as proposed by the existing resolver.
121
+
122
+ `projectKey` is a path-derived namespace, not a persistent logical project identity. A branch
123
+ that renames a monorepo folder gets a new key but consults the same compatibility descriptor.
124
+ The descriptor is private metadata, not tracked knowledge or `.leerness/manifest.json`.
125
+ B still needs stable project binding or an explicitly approved relocation registry before
126
+ activating movable subprojects; unresolved moves must not create a new legacy authority.
127
+ Main-worktree Git directories can equal the common Git directory; the private `layout.json`
128
+ and `projects/…` remain distinct from `control/…`. Git documents linked worktrees' private
129
+ and common metadata separately. [Git worktree details](https://git-scm.com/docs/git-worktree#_details)
130
+
131
+ For genuinely non-Git projects the descriptor is the selection-independent
132
+ `<projectRoot>/.leerness/cache/state-layout.json`, the canonical sibling of the resolver's
133
+ `<projectRoot>/.leerness/cache/state-runtime/`. Do not root it in the selected `.harness`.
134
+ Changing `LEERNESS_WORKSPACE_DIR` must not choose another guard for the same writer target.
135
+ An alternate `.harness/cache/state-layout.json` or `.harness/cache/state-runtime` is an
136
+ ambiguous layout and blocks writes, even if a canonical descriptor also exists. No dual
137
+ authority or preference-based fallback. Workspace migration checks this before copying;
138
+ it must preserve or refuse, not relocate the descriptor as an ordinary cache file.
139
+ There is no common coordination store for non-Git. Introducing Git must also inspect the
140
+ canonical non-Git descriptor/runtime indicators and refuse an unresolved backend transition.
141
+ Git errors, inaccessible parents and damaged gitfiles must not choose a non-Git backend.
142
+ Ordinary non-Git use without a Git executable must remain supported: extract/reuse the
143
+ existing bounded repository-marker discovery semantics rather than catching all Git errors
144
+ as non-Git. Git-backed writes fail closed if their layout cannot be resolved. This discovery
145
+ change requires its own baseline regression tests and must not alter `state inspect` silently.
146
+ Caller workspace/Git-discovery overrides must not select a different admission authority for
147
+ identical canonical write targets. Unknown or conflicting topology/ownership blocks writes;
148
+ do not read foreign workspace content or fall back around a workspace conflict.
149
+
150
+ Descriptor fields: `schema: leerness.runtime-layout/v1`, `schemaVersion: 1`,
151
+ `scope: worktree` (Git) or `project-local` (non-Git), positive safe-integer `generation`, `layout: legacy`, and
152
+ `requiredWriterProtocol: 1`. A supports only the legacy layout/protocol combination.
153
+ Generation is provenance, **not an implemented fencing token**. A does not create descriptors;
154
+ it accepts externally supplied fixtures during tests and diagnoses future transition states.
155
+ No preview/apply/activate/rollback command is delivered in A.
156
+
157
+ | Observed descriptor/runtime state | A behavior |
158
+ |---|---|
159
+ | Descriptor absent and no new/alternate layout indicators | Legacy backend, no files created by diagnosis. |
160
+ | Strictly valid supported legacy descriptor; no new/alternate layout indicators | Keep legacy writes and location. Revalidate at mutation admission. |
161
+ | Git `leerness/projects/` exists (even empty), but fixed descriptor is absent or legacy | Ambiguous new/old-key layout: block the whole worktree without a recursive scan. A new projectKey must not bypass this check. |
162
+ | Non-Git proposed runtime, alternate descriptor/runtime or unresolved backend transition exists | Block writes; never pick whichever descriptor agrees with the current selection. |
163
+ | Invalid, empty, truncated, oversized, unreadable, linked or scope-mismatched descriptor | Structured bounded error; preserve bytes, no repair/fallback. |
164
+ | Future schema/protocol/layout | Incompatible: block writes before side effects; diagnostic reports the incompatibility. |
165
+
166
+ Reject unsafe descriptor parent links, use bounded regular-file reads (maximum
167
+ 16 KiB), validate exact fields/types, and return reason codes rather than raw descriptor text.
168
+ Do not copy secrets, arbitrary paths or supplied commands into errors. A deleted descriptor
169
+ and deleted runtime cannot be recovered by inference; arbitrary external deletion is not fenced.
170
+
171
+ ### Entry points and honest limits
172
+
173
+ Read-only CLI: `leerness state compatibility [path] --json`.
174
+ It reports compatibility separately from the existing metadata-only `state inspect` contract.
175
+ Diagnostic fields should include schema, observed layout, supported protocol, reason code,
176
+ write disposition and `activationSupported: false`; no mutation/counter/cache/salt/lock writes.
177
+ Missing data is not presented as a completed migration. No raw conversation is returned.
178
+
179
+ Use one domain-specific reader/guard (`lib/runtime-layout.js`), not a speculative
180
+ all-purpose StateManager. CLI and MCP execution must share it. Admission checks go before
181
+ auto workspace migration, usage, stale-update caching, lock creation and hook installation,
182
+ as well as at actual module/REPL/FD/log writer boundaries. Resolve the actual target project,
183
+ not the unrelated current directory. Global-only commands must not mutate or block that cwd.
184
+ Do not let existing `catch {}` best-effort logging turn an incompatible-layout rejection into
185
+ success. Recheck after waiting for a data lock and at each later mutation of a long-lived process.
186
+
187
+ This is **observed-layout rejection**, not atomic check/write fencing. A in-flight operation
188
+ can pass its check before an external descriptor changes. B must specify writer admission
189
+ and stop/drain all older participating clients before activation; it cannot infer safety from
190
+ A being installed somewhere. Initialization/update/downgrade/hook refresh must preserve or
191
+ refuse an incompatible descriptor, never reset it to legacy. A does not promise to modify
192
+ an already-installed old executable, external editor, Git hook or arbitrary worktree removal.
193
+
194
+ ### Reuse and optimization constraints
195
+
196
+ - Reuse `lib/git.js` and the canonical project/worktree resolver. Do not shell-parse gitfiles
197
+ or add an independent Git subprocess wrapper. Scope/path discovery can share one immutable
198
+ invocation snapshot, but mutable compatibility decisions cannot be cached across writes,
199
+ lock waits, MCP requests or REPL turns.
200
+ - `writeUtf8` skips identical bytes and uses temp/rename. It is not create-only publication,
201
+ portable revision CAS, a multi-file transaction or a proven power-loss durability protocol.
202
+ - `writeBufferIfUnchanged` is Windows-only metadata-preserving replacement; POSIX currently
203
+ throws `E_METADATA_PRESERVATION_UNAVAILABLE`. Do not reuse it as a portable ControlStore CAS.
204
+ - `_withLock` serializes cooperating writers to one canonical target. Keep the existing
205
+ no-automatic-takeover policy for owner-bearing locks. It does not freeze another path or client.
206
+ - Existing `.harness` migration's inventory/hash/recheck/stage patterns are references only;
207
+ its lock is not taken by all writers, and its backup restore is not post-activation rollback.
208
+ - Node >=18, zero runtime dependencies, no network/provider call from diagnosis or admission.
209
+ Measure real-entry-point filesystem/Git calls and byte/mtime invariance before claiming an
210
+ optimization. Do not trade a fresh safety check for a faster stale cache.
211
+
212
+ ## 4. Future B activation and recovery gates (not P-0021 implementation)
213
+
214
+ 1. Enumerate the chosen unit's writers/readers, client versions, installed hooks, tracked
215
+ files and owners. Stop/upgrade participants and require an explicit maintenance declaration;
216
+ unknown old clients or unverifiable scope block activation. Recheck actual versions at use.
217
+ Establish the fixed compatibility boundary before any project-keyed staging/publication;
218
+ establish stable project bindings or refuse project moves. A metadata path hash is not a
219
+ logical identity. Do not activate B solely because A's observed-layout checks passed.
220
+ 2. Read strict original bytes/schema, source file identity, timestamps, permissions and Git
221
+ tracking. Record a digest-bound preview and unresolved owners. Preserve invalid data;
222
+ do not use permissive loaders, repair, prune or merge-by-text as an import shortcut.
223
+ 3. Stage copies in an exclusive private transaction directory; validate counts, IDs, counters,
224
+ ownership, provenance, content digests and required metadata. Source changes after preview
225
+ invalidate it. ACL/ADS/hard-link or cross-filesystem preservation unsupported by a backend
226
+ must cause refusal, not an unnoticed metadata loss. No runtime content enters a tracked archive.
227
+ 4. Under the future admission protocol, revalidate inputs and switch one versioned authority
228
+ descriptor. No dual writable legacy/new truth. Crash injection is required before/after each
229
+ publication point; uncertain commit means recovery-required, not automatic success.
230
+ 5. Before activation, rollback discards only owned staging after identity validation and leaves
231
+ originals untouched. After activation/new writes, first quiesce and reconcile newer events;
232
+ never copy an old backup over them. Preserve an explicit recovery receipt and artifacts.
233
+ 6. Separately preview any Git index/ignore change and retain original files. Preserve user text
234
+ in mixed documents; no blanket untrack/delete or merge=union. Worktree removal remains gated
235
+ on M-0017 durable finalization and M-0018 adapter support, not merely a successful copy.
236
+
237
+ Filesystem rename alone is not evidence of durable multi-file commit. Node's fsync API
238
+ documents OS/device-dependent flush behavior; a future backend must state and test its actual
239
+ durability support instead of promising recovery from every power failure.
240
+ [Node 18 fsync documentation](https://nodejs.org/docs/latest-v18.x/api/fs.html#fsfsyncsyncfd)
241
+
242
+ ## 5. Acceptance and delivery evidence
243
+
244
+ P-0021 approval authorizes A only. Implementation acceptance must include:
245
+
246
+ - A legacy control with no descriptor has unchanged command output/bytes apart from its
247
+ already documented writes. Main/two linked worktrees, sibling monorepo projects, detached
248
+ HEAD, aliases and non-Git remain distinct as specified by the resolver.
249
+ - Missing, valid legacy, malformed, empty, oversized, permission-denied, symlink/junction,
250
+ unknown protocol and deleted-descriptor-with-runtime cases give deterministic dispositions.
251
+ - A cross-branch monorepo subproject rename keeps the fixed worktree guard; old-key runtime
252
+ with a missing descriptor blocks even under a new key. Different genuine worktrees stay
253
+ isolated. The coarse whole-worktree block is explicit in diagnostics, not labelled per-project.
254
+ - Non-Git `.harness`/`.leerness` forced-selection variants consult the same canonical guard;
255
+ alternate descriptors/dual runtimes, foreign paths, Git discovery overrides and introducing
256
+ Git cannot reopen legacy writes against an incompatible or unresolved target.
257
+ - Fixtures with agent-run task text, pre-wake copied request text and routing approver/risk
258
+ provenance remain private and byte-preserved. A retains the current inspector tags as
259
+ metadata-only hints; future migration needs strict field classification and retention proof.
260
+ - A pre-existing incompatible descriptor produces zero writes through actual CLI startup,
261
+ MCP calls, REPL, direct module invocation, FD append, usage/update cache, locks, init/update
262
+ and hook-install paths. Unsupported writer paths block the compatibility release, not only B.
263
+ - Descriptor changes while a command waits on a lock are re-read. A changes-after-final-check
264
+ race is documented and not passed off as fencing. No activation under A-only writers.
265
+ - Old v1.36.186 and an already-running legacy writer are negative controls: the tests must
266
+ demonstrate they do not honor the new guard. This is a known limit, not a suppressed failure.
267
+ - `state inspect` retains metadata-only/no-content/no-write behavior. The new diagnostic is
268
+ independently tested for bounded content, errors, no outputs of secrets and byte/mtime stability.
269
+ - Existing presence, handoff, structured-state, MCP, lease and fallback probes; installed
270
+ cleanroom; supported Node and Windows/POSIX CI; independent Codex review; then public release
271
+ verification. No test count or release claim from v1.36.186 is reused as evidence for A.
272
+
273
+ The original T-0179 planning round ran document/reference checks and source review only.
274
+ The approved T-0180 round adds implementation and regression evidence, including real old
275
+ v1.36.186 CLI and already-loaded writer negative controls. Those clients ignore the descriptor;
276
+ this is an observed limit, not evidence that activation is safe. Final cross-platform/full
277
+ release validation is tracked separately in review-evidence.md; it is not implied by this design.
278
+ The broader UR-0097 and migration B/C remain open.
@@ -267,6 +267,6 @@ module.exports = {
267
267
  validateBaseline,
268
268
  loadBaseline,
269
269
  buildBaseline,
270
- saveBaseline,
270
+ saveBaseline: require('./runtime-writes').projectWriter(saveBaseline),
271
271
  applyBaseline,
272
272
  };
@@ -838,8 +838,8 @@ module.exports = {
838
838
  normalizeTtlSeconds,
839
839
  resolveTarget,
840
840
  readStore,
841
- acquire,
842
- release,
841
+ acquire: require('./runtime-writes').projectWriter(acquire),
842
+ release: require('./runtime-writes').projectWriter(release),
843
843
  list,
844
844
  check,
845
845
  };
package/lib/git.js CHANGED
@@ -50,31 +50,80 @@ const _shouldDrop = (name) => { const u = String(name).toUpperCase(); return _DR
50
50
  const _GIT_MUTATING = new Set(['add', 'rm', 'mv', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch',
51
51
  'restore', 'branch', 'tag', 'push', 'fetch', 'pull', 'clone', 'init', 'worktree', 'stash', 'apply', 'cherry-pick',
52
52
  'revert', 'clean', 'gc', 'prune', 'update-ref', 'symbolic-ref', 'notes', 'submodule', 'config', 'remote']);
53
- // 조회성 하위명령은 이름이 같아도 플래그로 갈라진다 — 읽기만 하는 형태는 통과시킨다.
53
+ // Parse only explicit read options. Unknown/negated action flags remain writes;
54
+ // option values and tokens after -- must never masquerade as a read selector.
55
+ function _gitReadArgs(rest, switches, values = new Set(), optional = new Set()) {
56
+ const operands = [], flags = new Set();
57
+ for (let i = 0; i < rest.length; i++) {
58
+ const token = rest[i];
59
+ if (token === '--') { operands.push(...rest.slice(i + 1)); break; }
60
+ if (!token.startsWith('-')) { operands.push(token); continue; }
61
+ const name = token.split('=', 1)[0];
62
+ if (values.has(name)) {
63
+ if (!token.includes('=')) {
64
+ if (rest[i + 1] !== undefined && (!optional.has(name) || !rest[i + 1].startsWith('-'))) i++;
65
+ else if (!optional.has(name)) return null;
66
+ }
67
+ } else if (!switches.test(token)) return null;
68
+ flags.add(name);
69
+ }
70
+ return { operands, flags };
71
+ }
72
+ function _gitRefReadForm(sub, rest) {
73
+ const filters = new Set(['--contains', '--no-contains', '--merged', '--no-merged', '--points-at']);
74
+ const values = new Set([...filters, '--sort', '--format']);
75
+ const optional = new Set([...filters].filter(value => sub === 'tag' || value !== '--points-at'));
76
+ const common = /^(?:--list|--(?:no-)?(?:color|column)(?:=.*)?|--(?:no-)?(?:ignore-case|omit-empty)|-[li])$/;
77
+ const extra = sub === 'branch'
78
+ ? /^(?:-[alrvi]+|--(?:all|remotes|show-current|verbose|no-verbose|no-abbrev)|--abbrev(?:=\d+)?)$/
79
+ : /^(?:-[lvi]+|-n\d*|--verify)$/;
80
+ const parsed = _gitReadArgs(rest, { test: token => common.test(token) || extra.test(token) }, values, optional);
81
+ if (!parsed) return false;
82
+ const listing = [...parsed.flags].some(flag => filters.has(flag) || flag === '--list'
83
+ || (sub === 'branch' ? /^(?:--all|--remotes|--show-current|-[alrvi]*[alr][alrvi]*)$/ : /^(?:--verify|-n\d*|-[lvi]*[lv][lvi]*)$/).test(flag));
84
+ return listing || parsed.operands.length === 0;
85
+ }
86
+ // 조회성 하위명령은 이름이 같아도 플래그/인자 수로 갈라진다.
54
87
  const _GIT_READ_ONLY_FORM = (sub, rest) => {
55
- if (sub === 'branch') return !rest.some((x) => /^-(d|D|m|M|c|C)$|^--(delete|move|copy|set-upstream|unset-upstream|edit-description)/.test(x));
56
- if (sub === 'config') return rest.includes('--get') || rest.includes('--get-all') || rest.includes('--list') || rest.includes('--show-scope') || rest.includes('-l');
57
- if (sub === 'symbolic-ref') return rest.includes('-q') || rest.includes('--short') || rest.length <= 1;
88
+ if (sub === 'branch' || sub === 'tag') return _gitRefReadForm(sub, rest);
89
+ if (sub === 'config') {
90
+ const parsed = _gitReadArgs(rest,
91
+ /^(?:--(?:get|get-all|get-regexp|get-urlmatch|get-color|get-colorbool|list|local|global|system|worktree|null|name-only|show-origin|show-scope|includes|no-includes|fixed-value|bool|int|bool-or-int|path|expiry-date)|-[lz])$/,
92
+ new Set(['--file', '-f', '--blob', '--type', '--default']));
93
+ return !!parsed && [...parsed.flags].some(flag => /^(?:--get(?:-all|-regexp|-urlmatch|-color|-colorbool)?|--list|-l)$/.test(flag));
94
+ }
95
+ if (sub === 'symbolic-ref') {
96
+ const parsed = _gitReadArgs(rest, /^(?:-q|--(?:no-)?(?:quiet|short|recurse))$/);
97
+ return !!parsed && parsed.operands.length === 1;
98
+ }
58
99
  if (sub === 'worktree') return rest[0] === 'list';
59
100
  if (sub === 'stash') return rest[0] === 'list' || rest[0] === 'show';
60
- if (sub === 'tag') return !rest.some((x) => /^-(d|a|s|f)$|^--(delete|annotate|sign|force)/.test(x));
61
101
  if (sub === 'notes') return rest[0] === 'list' || rest[0] === 'show';
62
102
  if (sub === 'submodule') return rest[0] === 'status' || rest[0] === 'summary';
63
- if (sub === 'remote') return rest.length === 0 || rest[0] === '-v' || rest[0] === 'show' || rest[0] === 'get-url';
103
+ if (sub === 'remote') {
104
+ let i = 0;
105
+ while (rest[i] === '-v' || rest[i] === '--verbose') i++;
106
+ return i === rest.length || rest[i] === 'show' || rest[i] === 'get-url';
107
+ }
64
108
  return false;
65
109
  };
66
110
  let _gitDry = false;
67
111
  function setGitDryRun(on) { _gitDry = !!on; }
68
112
  function gitSpawn(args, opts) {
69
- if (_gitDry) {
70
- // `-C <path>` 같은 선행 옵션을 건너뛰고 첫 하위명령을 찾는다.
71
- const a = Array.isArray(args) ? args.map(String) : [];
72
- let i = 0;
73
- while (i < a.length && (a[i].startsWith('-') || (i > 0 && a[i - 1] === '-C'))) i++;
74
- const sub = a[i] || '';
75
- if (_GIT_MUTATING.has(sub) && !_GIT_READ_ONLY_FORM(sub, a.slice(i + 1))) {
76
- throw Object.assign(new Error(`--dry-run 인데 git ${sub} 를 실행하려 했습니다`), { code: 'E_DRY_RUN_WRITE', file: `git ${sub}` });
77
- }
113
+ // Git도 index/hook 설정 등 메타데이터를 쓸 수 있다. 관측 경계 안에서 실행되는
114
+ // 변경 명령은 fs writer와 동일하게 다시 확인한다 (topology 조회는 제외).
115
+ const candidate = Array.isArray(args) ? args.map(String) : [];
116
+ let cursor = 0;
117
+ while (cursor < candidate.length && candidate[cursor].startsWith('-')) {
118
+ cursor += ['-C', '-c', '--git-dir', '--work-tree', '--namespace'].includes(candidate[cursor]) ? 2 : 1;
119
+ }
120
+ const verb = candidate[cursor] || '';
121
+ const mutating = _GIT_MUTATING.has(verb) && !_GIT_READ_ONLY_FORM(verb, candidate.slice(cursor + 1));
122
+ if (mutating) {
123
+ require('./runtime-writes').assertCurrentRuntimeWrite();
124
+ }
125
+ if (_gitDry && mutating) {
126
+ throw Object.assign(new Error(`--dry-run 인데 git ${verb} 를 실행하려 했습니다`), { code: 'E_DRY_RUN_WRITE', file: `git ${verb}` });
78
127
  }
79
128
  const _argv = ['--no-optional-locks', ...args]; // 사용자 저장소의 `.git/index` 재기록을 막는다(실측)
80
129
  // ⚠ 키를 **접어서** 비교한다 — Windows 환경변수는 대소문자를 구분하지 않는데 JS 객체 키는 구분해서,
package/lib/io.js CHANGED
@@ -179,7 +179,7 @@ function setDryRunGuard(on) { _dryGuard = !!on; if (on) _patchFsForDryRun(); }
179
179
  // File-descriptor based writers cannot be intercepted by the fs path mutator
180
180
  // wrappers. Call this before opening a descriptor so --dry-run retains its
181
181
  // process-wide zero-persistent-write contract.
182
- function assertWriteAllowed(p) { _dryCheck(p); }
182
+ function assertWriteAllowed(p) { _dryCheck(p); require('./runtime-writes').assertCurrentRuntimeWrite(p); }
183
183
  // ⚠ `mkdirp` 은 가드하지 **않는다**. 한 번 넣었다가 부작용을 재서 뺀다 —
184
184
  // `_withLock` 이 락 디렉토리를 만드는 것까지 막혀 락 획득이 실패하고 **무보호 진행**으로 떨어졌다
185
185
  // (실측: `state start --dry-run` → "락 획득 실패 — 보호 없이 진행합니다"). 빈 디렉토리는 사용자 데이터가
@@ -355,10 +355,11 @@ function _replaceWindowsWithBackup(from, to, backup) {
355
355
  let attempts = 0;
356
356
  for (;;) {
357
357
  attempts++;
358
+ assertWriteAllowed(to); // spawn-based replacement is outside fs interception; retries need fresh admission too.
358
359
  const r = cp.spawnSync(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], {
359
360
  encoding: 'utf8',
360
361
  windowsHide: true,
361
- timeout: 15000,
362
+ timeout: 30000,
362
363
  env: {
363
364
  ...process.env,
364
365
  LEERNESS_REPLACE_FROM: from,
@@ -635,4 +636,9 @@ function detachCommittedHardLink(aliasPath, livePath, expectedBytes) {
635
636
  };
636
637
  }
637
638
 
638
- module.exports = { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, writeBufferIfUnchanged, append, rel, setDryRunGuard, assertWriteAllowed, mkdirpRaw, detachCommittedHardLink };
639
+ const { pathWriter } = require('./runtime-writes');
640
+ module.exports = { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf,
641
+ mkdirp: pathWriter(mkdirp), writeUtf8: pathWriter(writeUtf8),
642
+ writeBufferIfUnchanged: pathWriter(writeBufferIfUnchanged), append: pathWriter(append),
643
+ rel, setDryRunGuard, assertWriteAllowed: pathWriter(assertWriteAllowed),
644
+ mkdirpRaw: pathWriter(mkdirpRaw), detachCommittedHardLink: pathWriter(detachCommittedHardLink) };
package/lib/migrate.js CHANGED
@@ -142,4 +142,4 @@ function migratePlanCmd(root, opts = {}, deps = {}) {
142
142
  log(`\n 전체 적용: leerness update --yes --path ${root} · canonical만: leerness migrate apply --path ${root} --yes`);
143
143
  }
144
144
 
145
- module.exports = { migrateAuditCmd, migrateApplyCmd, migratePlanCmd };
145
+ module.exports = { migrateAuditCmd, migrateApplyCmd: require('./runtime-writes').projectWriter(migrateApplyCmd), migratePlanCmd };
@@ -278,4 +278,8 @@ function previewModeCmd(root, val, deps = {}) {
278
278
  }
279
279
 
280
280
  // _listenWithFallback 공개(1.36.90): 대시보드가 **같은 런타임**을 쓴다 — 두 번째 서버 구현을 만들지 않는다.
281
- module.exports = { LEERNESS_PREVIEW_PORT, previewServeCmd, previewModeCmd, loadPreviewConfig, savePreviewConfig, cleanupServeDir, serveDir, modeQuestionLines, buildServeWorkspace, _listenWithFallback };
281
+ const { projectWriter } = require('./runtime-writes');
282
+ module.exports = { LEERNESS_PREVIEW_PORT, previewServeCmd: projectWriter(previewServeCmd),
283
+ previewModeCmd, loadPreviewConfig, savePreviewConfig: projectWriter(savePreviewConfig),
284
+ cleanupServeDir: projectWriter(cleanupServeDir), serveDir, modeQuestionLines,
285
+ buildServeWorkspace: projectWriter(buildServeWorkspace), _listenWithFallback };
package/lib/pure-utils.js CHANGED
@@ -275,6 +275,23 @@ function _parseSlashFromHelp(text, invoke = 'slash') {
275
275
  // 1.9.283 (UR-0025 2단계): 권한 등급(permission tiers) 순수 로직 — capabilities/policy 공유.
276
276
  const PERMISSION_TIERS = ['read-only', 'safe-write', 'project-write', 'shell-read', 'shell-write', 'git-write', 'network', 'publish'];
277
277
  function _tierRank(t) { const i = PERMISSION_TIERS.indexOf(String(t || '')); return i < 0 ? PERMISSION_TIERS.length : i; }
278
+ // A narrow literal-text check for the inspection exemption, not a shell parser.
279
+ // Quoted path punctuation is data; operators outside quotes and expansion syntax
280
+ // retain the ordinary conservative classification below.
281
+ function _isLiteralInspectionText(text) {
282
+ let quote = null;
283
+ for (let i = 0; i < text.length; i++) {
284
+ const ch = text[i], next = text[i + 1] || '';
285
+ if (/[\r\n]/.test(ch)) return false;
286
+ if (quote) {
287
+ if (ch === quote) quote = null;
288
+ else if (quote === '"' && (ch === '`' || (ch === '$' && /[a-z0-9_{(]/i.test(next))
289
+ || (ch === '\\' && next === '"'))) return false;
290
+ } else if (ch === '"' || ch === "'") quote = ch;
291
+ else if (/[;&|<>()$`^]/.test(ch) || (ch === '\\' && /["']/.test(next))) return false;
292
+ }
293
+ return quote === null && !/%[^%\r\n]+%/.test(text);
294
+ }
278
295
  // 명령/capability → 요구 등급 (순수 매핑)
279
296
  function _requiredTier(cmd) {
280
297
  const c = String(cmd || '').toLowerCase();
@@ -284,6 +301,9 @@ function _requiredTier(cmd) {
284
301
  const leaseCommand = c.match(/^\s*(?:(?:npx(?:\s+(?:-y|--yes))?\s+)?leerness(?:@[^\s]+)?\s+)?lease\s+(list|check|acquire|release)(?:\s|$)/);
285
302
  if (leaseCommand && (leaseCommand[1] === 'list' || leaseCommand[1] === 'check')) return 'read-only';
286
303
  if (leaseCommand) return 'safe-write';
304
+ // This exemption describes one inspect invocation, never composite shell text.
305
+ if (/^\s*(?:(?:npx(?:\s+(?:-y|--yes))?\s+)?leerness(?:@[^\s]+)?\s+)?state\s+inspect(?:\s|$)/.test(c)
306
+ && _isLiteralInspectionText(c)) return 'read-only';
287
307
  if (/release\s+publish|npm\s+publish|\bpublish\b/.test(c)) return 'publish';
288
308
  if (/\bweb\b/.test(c)) return 'network';
289
309
  if (/git\s+push|sync-main/.test(c)) return 'git-write';
@@ -1597,8 +1597,8 @@ module.exports = {
1597
1597
  buildCandidatePool,
1598
1598
  normalizeAvailability,
1599
1599
  normalizeAvailabilityObservation,
1600
- appendAvailabilityObservation,
1601
- appendAvailabilityClear,
1600
+ appendAvailabilityObservation: require('./runtime-writes').projectWriter(appendAvailabilityObservation),
1601
+ appendAvailabilityClear: require('./runtime-writes').projectWriter(appendAvailabilityClear),
1602
1602
  readAvailabilityObservations,
1603
1603
  availabilityExtrasForCandidate,
1604
1604
  sessionIdentityFromEnv,
@@ -1606,6 +1606,6 @@ module.exports = {
1606
1606
  selectFallbackOption,
1607
1607
  executionLedgerPath,
1608
1608
  normalizeExecutionEvent,
1609
- appendExecutionEvent,
1609
+ appendExecutionEvent: require('./runtime-writes').projectWriter(appendExecutionEvent),
1610
1610
  readExecutionEvents,
1611
1611
  };
package/lib/role-store.js CHANGED
@@ -720,8 +720,8 @@ module.exports = {
720
720
  revisionForBytes,
721
721
  readRoleStore,
722
722
  loadRoles,
723
- saveRoles,
724
- updateRoles,
723
+ saveRoles: require('./runtime-writes').projectWriter(saveRoles),
724
+ updateRoles: require('./runtime-writes').projectWriter(updateRoles),
725
725
  setRoleAssignment,
726
726
  resolveRole,
727
727
  validationSummary,