memoir-cli 3.14.0 → 3.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,14 +8,14 @@ The reliability changes are described in [the remediation record](docs/AUDIT-REM
8
8
 
9
9
  ## Continue between Codex and Cursor on this computer
10
10
 
11
- This branch adds a separate, project-only handoff. It carries answered questions,
11
+ Memoir includes a separate, project-only handoff. It carries answered questions,
12
12
  decisions, next actions and receipts from checks actually run through Memoir. It
13
13
  does not import personal memory or transcripts.
14
14
 
15
- The project handoff and browser view require version 3.14.0 or later:
15
+ The project handoff, browser view and recovery commands below require version 3.16.0 or later:
16
16
 
17
17
  ```sh
18
- npm install -g memoir-cli@3.14.0
18
+ npm install -g memoir-cli@3.16.0
19
19
  cd /path/to/your/project
20
20
  memoir work setup
21
21
  memoir work resume
@@ -51,9 +51,14 @@ sync those files through GitHub or bridge different checkouts. See the
51
51
  privacy boundaries and when a check needs to run again. The feature runs locally even when installed from npm; publishing the package
52
52
  does not upload your project ledger.
53
53
 
54
+ Project handoffs now have automatic local recovery snapshots. Run `memoir work
55
+ doctor` to check them, or `memoir work backup --output /path/to/backup.memoir`
56
+ for an encrypted copy outside the project. Recovery previews changes before
57
+ applying them and preserves the original file. See [backup and recovery](docs/PROJECT-RECOVERY.md).
58
+
54
59
  ## Existing memory and backup workflow
55
60
 
56
- Node.js 18 or later is required.
61
+ Node.js 18 or later can run the CLI. For production use, choose a maintained LTS release (Node 22 or 24); Node 18/20 remain compatibility-test targets. See the [Node release policy](https://nodejs.org/en/about/previous-releases).
57
62
 
58
63
  ```sh
59
64
  npm install -g memoir-cli
@@ -168,6 +173,6 @@ npm audit --omit=dev
168
173
  npm pack --ignore-scripts
169
174
  ```
170
175
 
171
- Tests use synthetic homes and local Git remotes. CI declares macOS/Linux/Windows with Node 18/20/22; shell suites skip Windows. See [remediation status](docs/AUDIT-REMEDIATION.md) and [release gates](docs/RELIABILITY-ROLLOUT.md) for verification limits.
176
+ Tests use synthetic homes and local Git remotes. CI declares macOS/Linux/Windows with Node 18/20/22/24; shell suites skip Windows. See [remediation status](docs/AUDIT-REMEDIATION.md) and [release gates](docs/RELIABILITY-ROLLOUT.md) for verification limits.
172
177
 
173
178
  [MIT license](LICENSE)
@@ -0,0 +1,71 @@
1
+ # Local handoff hardening — September 6, 2026
2
+
3
+ This change targets the single-user CLI/MCP workflow and its loopback browser
4
+ companion. It does not turn the companion into a public multi-user service.
5
+ Publication is subject to the release workflow; registry availability and CI
6
+ results must be checked against the actual release rather than this document.
7
+
8
+ ## What changed
9
+
10
+ - Automatic bounded recovery snapshots protect every acknowledged project save.
11
+ Existing version-1 ledgers are checkpointed on the updated resume path.
12
+ - `work doctor`, `work backup` and `work recover` expose health, encrypted export
13
+ and preview-bound recovery. Current bytes are preserved before replacement.
14
+ - A recovery generation prevents stale CLI/MCP/browser sessions and in-flight
15
+ checks from overwriting a restored handoff. Normal revision guards remain.
16
+ - Publication now depends on the entire reusable CI workflow. Node 24 is added
17
+ to the Node 18/20/22 matrix across Linux, macOS and Windows. Node 22/24 are the
18
+ maintained LTS targets for production; older versions are compatibility tests.
19
+
20
+ ## Observed local evidence
21
+
22
+ | Exercise | Result |
23
+ | --- | --- |
24
+ | Full local suite | 25 suites passed on macOS/Node 26.7.0; subsequent focused checks cover the added crash and browser-generation cases |
25
+ | Recovery regression | 15 groups cover acknowledged snapshots, old-ledger upgrade, damaged/missing ledgers, exact preview conflicts, stale writers, concurrent recovery/check execution, all-branch encrypted round trips, malformed/secret-bearing imports, symlinks, failed writes, process crash, retention, CLI errors and Windows delete-pending retries |
26
+ | Browser harness | 31 scenarios pass, including retaining an open draft's old generation until the recovered record is explicitly reviewed |
27
+ | Existing regressions | 17 handoff groups, 21 adversarial scenarios and 14 browser API/security groups pass |
28
+ | Installed tarball | CLI, project MCP, local view, encrypted export, damaged-ledger recovery, unchanged client settings and post-recovery MCP continuation passed in a synthetic home |
29
+ | Dependency advisory audit | No known production-dependency advisories were returned at test time |
30
+
31
+ The actual Cursor desktop connection was reloaded using its existing **Reload**
32
+ control. Direct `memoir_work_resume` returned the current delivery and privacy
33
+ answers and identified `src/work/store.js` as changed. Cursor saved
34
+ `decision.cursor-production-resume` at revision 119 through MCP and read it back.
35
+ Returning Codex read that record. No fallback, repeated question, check rerun or
36
+ source edit was requested from Cursor in that bounded exercise.
37
+
38
+ In an actual in-app browser, a synthetic project answer was opened for editing.
39
+ The project was recovered while that draft stayed open. Saving was refused;
40
+ the draft remained intact. **Review latest version → Keep my draft and continue
41
+ → Save correction** saved the deliberate correction. A separate CLI reader
42
+ recovered that answer at revision 2, and `doctor` reported a valid current copy.
43
+ The temporary project and browser were removed after validation.
44
+
45
+ A separate local drill restored a snapshot of the real project ledger at
46
+ revision 120 into a temporary folder after deliberately damaging only that
47
+ copy. All 64 record-history entries, 54 check receipts and two retractions
48
+ matched the source. The real ledger remained unchanged. No real project data
49
+ was uploaded, and no encrypted export passphrase was created for the user.
50
+
51
+ ## What these results establish
52
+
53
+ The new failure handling was exercised in source tests, an installed artifact,
54
+ the actual browser, Cursor MCP and a real-data copy. Local protection is
55
+ enabled for this checkout, and the original app settings remain in place.
56
+ The release pipeline must separately establish cross-platform results and
57
+ validate the published npm artifact.
58
+
59
+ These tests are not an independent security certification, a power-cut test of
60
+ every filesystem, or proof of productivity gains over multiple days. Receipts
61
+ remain local and unauthenticated; project text is untrusted. Local snapshots
62
+ and quarantine are plaintext, and secret screening is heuristic. Losing the
63
+ entire folder/disk needs a separately stored export. See the
64
+ [recovery guide](PROJECT-RECOVERY.md) for downgrade restrictions, capacity,
65
+ retention, filesystem boundaries and the remaining manual lock-repair case.
66
+
67
+ The first expanded CI run caught a Windows/Node 18 delete-pending race on
68
+ `work.lock`. Path inspection and exclusive lock acquisition now retry transient
69
+ Windows access failures without skipping validation or entering without a lock.
70
+ A deterministic denial/concurrency regression accompanies the fix. Manual
71
+ publication also requires a version tag matching the package.
@@ -18,6 +18,52 @@ This documents the local project handoff in `src/work/` and `bin/memoir-work.js`
18
18
 
19
19
  ## See and correct what Memoir remembers
20
20
 
21
+ Memoir remains a CLI and tool integration. This optional browser companion reads
22
+ the same project record; it is not a separate memory service. You can keep using
23
+ the CLI, Codex or Cursor without opening the page.
24
+
25
+ The default **Records** view shows open actions, saved answers and check evidence.
26
+ **Records** and **Map** share one workspace, search box and category navigation.
27
+ Switching views keeps the search and category. Records use readable rows with
28
+ short headings and expandable text; source history stays available on each entry.
29
+
30
+ The **Map** view connects the current branch's project entries. Select a
31
+ node to read its full text, source and history, and inspect connected entries.
32
+ Solid lines show project membership, explicit record references, or a named file
33
+ that a check declares as input. A file link does not prove the entry's claims.
34
+ Suggested links are off by default. Enable **Suggested links** to include possible
35
+ shared topics around a selected entry; dashed lines distinguish these word
36
+ matches from recorded references. Each suggestion explains its words and is not
37
+ saved as a relationship. This prototype does not infer
38
+ causes, automatically determine affected work, or use personal memory.
39
+
40
+ Search and category filters narrow the map. The overview shows up to six entries
41
+ around the project; selecting an entry centers it and shows up to six direct
42
+ neighbors, with recorded references first. Lines connect only to that center.
43
+ It computes connections within up to 120 entries, prioritizing matches and the
44
+ selected entry. Search covers all active entries, including older ones, but the
45
+ visible map and connection list are not exhaustive. No text leaves the browser
46
+ to generate these connections.
47
+
48
+ **Records** provides the overview and category lists for editing. The overview
49
+ shows every open action as a short row; **Details** opens its explanation and
50
+ controls. Saved answers and recent decisions start collapsed. Matching checks
51
+ remain available from the summary and Checks category; only checks needing
52
+ review appear in the overview. Long entries in category lists expand with
53
+ **Read full entry**; completed actions remain under **Next actions**. Both views
54
+ use the same correction controls and project record. **Connections** opens a
55
+ record in the map; **Open in Records** returns to that entry's category. Removed
56
+ items use the Records recovery list and do not enter the active map.
57
+
58
+ Selecting a map entry opens its details beside the map in wide windows and below
59
+ it in narrow windows. Keyboard focus follows the selected context. Saving from
60
+ either view selects the saved entry, clears the old search and opens its category
61
+ so a filter cannot hide a successful save. Both views search covered file paths as
62
+ well as record text and sources. Overview search includes all matching records,
63
+ including completed actions and goals, without the overview's two-per-group limit.
64
+ See [the project map trial](PROJECT-MAP-TRIAL.md) for
65
+ tested behavior and the limits of suggested connections.
66
+
21
67
  Run `memoir work view` in your project (or `node bin/memoir.js work view`
22
68
  from a source checkout), or ask the agent
23
69
  “Open my Memoir project view.” The browser shows the current branch's answers,
@@ -253,3 +299,7 @@ The saved `check.project-handoff` result (revision 5) still matched its declared
253
299
  A later resume carried forward `decision.client-route` (revision 10), whose source reports that Cursor's project connection was enabled, showed four tools, and successfully called `memoir_work_resume` on 2026-09-06. This is saved project evidence from the local client test; the documentation review did not repeat that test or inspect external app settings.
254
300
 
255
301
  In this Codex session, the configured MCP resume call returned “MCP tool call requires approval, but approval policy is never.” The documented CLI fallback succeeded. No policy or app setting was changed by this review. Successful CLI continuation does not establish successful Codex MCP use. Source labels are claims, not authentication, and saved client observations do not verify current external settings. Those settings and current client acceptance need verification before claiming they still work; such verification was outside this documentation-only continuation.
302
+
303
+ ## Backup and recovery
304
+
305
+ Saves now include automatic local snapshots. Run `memoir work doctor` to check them. See [the recovery guide](PROJECT-RECOVERY.md) for encrypted exports, recovery previews, and the required post-recovery `expected_recovery` value.
@@ -0,0 +1,149 @@
1
+ # Project map trial — 2026-09-06
2
+
3
+ The map is functional, but this trial does not establish that it is faster than
4
+ the Records view. It is useful for following explicit references to check
5
+ evidence. Direct answers are already easy to find in Records, and keyword
6
+ suggestions still include weak connections.
7
+
8
+ ## Actual browser tasks
9
+
10
+ Tested against this checkout's real project record in the Codex in-app browser.
11
+ The starting ledger was revision 70, with 27 active entries. Existing answers and
12
+ check evidence were read before starting; no setup questions were repeated.
13
+
14
+ | Task | Map | Records | Finding |
15
+ | --- | --- | --- | --- |
16
+ | Find the saved privacy boundary | One node selection from the default map | Answer already present in Overview | No advantage for the map on this direct lookup |
17
+ | Read the complete saved publication blocker | One node selection | One **Read full entry** activation from Overview | Both expose the same saved blocker; external account state was not reverified |
18
+ | Find checks covering `docs/PROJECT-HANDOFF.md` | Evidence filter plus file search | Checks category plus file search | Records initially returned no result; fixed to search covered files too |
19
+ | Follow the documentation decision to its check | Search for the document, select the decision, follow **Recorded link** | Category/file search finds the check separately | The map exposes the reference and explains the file relationship |
20
+ | Decide which checks need another look | Linked receipt named changed UI files | The same receipt named the same changes | Changed inputs were visible; unchanged CLI/MCP receipts were reused |
21
+
22
+ These are observed control activations, excluding scrolling, switching views and
23
+ test resets. They are not timed human measurements or a randomized comparison.
24
+ The tester already knew the project. Repeated questions: zero in this task;
25
+ that observation is not proof of an improvement against an unassisted baseline.
26
+
27
+ ## Problems reproduced and fixed
28
+
29
+ 1. **Detail panel blocked the map and Reset.** At the actual 742px window width,
30
+ the fixed panel overlapped Reset; a hit test returned the panel. Below 950px,
31
+ details now follow the map in normal flow. Selecting a node focuses its
32
+ heading; Back returns focus to the project. Reset was retested successfully.
33
+ 2. **Filtering lost keyboard focus.** After a category click, focus was on the
34
+ document body because the filter had been replaced. Focus now moves to the
35
+ replacement active filter. Verified in the real browser and regression test.
36
+ 3. **File search disagreed between views.** Records omitted check input paths and
37
+ source text. Both views now use the same searchable fields. The actual
38
+ documentation-file search returns the same check in both views.
39
+ 4. **A newly saved entry disappeared into the overview.** A real browser save
40
+ succeeded but did not select the new record; focus fell back to the body.
41
+ A regression test reproduced it. Map saves now select the saved entry and
42
+ return focus to its visible correction control. A second real creation and
43
+ correction verified this behavior. Long entries exposed another case: the
44
+ first button was a hidden expansion control. Focus now targets card actions
45
+ explicitly; the regression test includes a long entry.
46
+ 5. **Confirmation text had poor contrast.** The dark map inherited light text
47
+ on the old pale notification background. Map success/error notices now have
48
+ explicit dark backgrounds and contrasting text. The success message's
49
+ computed text/background contrast was 10.70:1 in the actual browser.
50
+
51
+ ## Persistence and boundaries
52
+
53
+ Two project-only trial observations were saved through the actual editor. The
54
+ result record was corrected, temporarily removed and restored through Records.
55
+ A separate process read agent resume after removal and confirmed the result was
56
+ absent. After restoration it recovered the corrected version and preserved
57
+ history. The original privacy and delivery answers remained at revisions 3 and
58
+ 50. Existing project decisions, settings and private memory were not rewritten
59
+ for this test.
60
+
61
+ The map was checked at 390px, the normal 742px window, and 1280px. Details no
62
+ longer overlap the map, and no horizontal overflow was observed. Wide windows
63
+ retain a side panel. This is a focused layout/keyboard check, not a full
64
+ accessibility audit.
65
+
66
+ At this initial trial, the UI regression suite contained 21 scenarios, including map focus, saving,
67
+ consistent search, exact versus suggested links, older-entry search, and literal
68
+ rendering of synthetic hostile markup. The existing 14 API/security groups cover
69
+ authentication, cross-origin access, stale edits, removal/restore, secret/scope
70
+ rejection and unavailable execution routes. These targeted checks and the guide
71
+ check were captured in the local ledger after the relevant files changed.
72
+ Matching CLI/MCP and adversarial handoff receipts were reused. This was not a new
73
+ production penetration test or a fresh Cursor round trip.
74
+
75
+ ## What remains to establish
76
+
77
+ - Keyword suggestions are noisy. The privacy answer was linked through generic
78
+ words such as “user”, “evidence” and “handoff”; the labels explain the match but
79
+ cannot make it useful. Evaluate suggestions against human-labeled relevant
80
+ pairs before treating the map as an intelligent project model.
81
+ - The graph computes relationships within at most 120 entries.
82
+ Search covers all active entries, but connections are not exhaustive.
83
+ - There is no saved relationship editor, automatic impact analysis, natural
84
+ language retrieval, or measured reduction in time/repeated work from the map.
85
+ - Keep both views available. Next, test unfamiliar users on real project
86
+ questions and count successful answers, mistaken interpretations and time.
87
+
88
+ The trial used local, uncommitted changes. No publication, server deployment,
89
+ new account setup or personal-memory import was needed to perform it.
90
+
91
+ ## Follow-up: reduce graph clutter
92
+
93
+ The user found the graph messy after this trial. The observed overview contained
94
+ 24 lines and 11 nodes. The revised view shows six entries around the project,
95
+ with six straight membership lines and no lines between the surrounding entries.
96
+ Selecting an entry moves it to the center and shows only its direct neighbors,
97
+ with at most six links. Labels have larger type and a two-line limit; full text
98
+ remains in the inspector. Suggested links are now off by default and explicitly
99
+ enabled with **Suggested links**. The graph and inspector apply the same filter.
100
+
101
+ The actual browser showed six lines and seven nodes in the new overview. A check
102
+ with eight recorded connections displayed six; selecting an answer with no
103
+ recorded references showed zero links, then six dashed links after enabling
104
+ suggestions. At 390px, the seven node boxes did not overlap and the page had no
105
+ horizontal overflow. Two new regression cases cover the overview limit and
106
+ direct-neighbor filtering with opt-in suggestions, bringing the UI suite to 23
107
+ scenarios. The underlying keyword-matching quality is unchanged by this cleanup.
108
+ # Shared workspace follow-up — 6 September 2026
109
+
110
+ Further feedback was that even the unified page required too much reading.
111
+ The overview now shows all open actions as short rows with a **Details** button.
112
+ Saved answers and recent decisions start collapsed; matching check receipts are
113
+ available through the summary and Checks category. Only checks needing review
114
+ appear in the overview. Existing action text was shortened and the explanation
115
+ moved into its rationale; previous full text remains in local history. The npm
116
+ action now reflects the observed password-confirmation screen in Chrome.
117
+
118
+ The user reported that Map and Records still looked incoherent. The browser now
119
+ opens Records first and uses one header, search, category navigation, color
120
+ palette and correction dialog for both views. Records have a single column of
121
+ readable rows. The former separate sidebar, duplicate introductions and light
122
+ card grid are removed.
123
+
124
+ Switching views preserves search and category. **Connections** opens the same
125
+ record in the map; **Open in Records** returns to its category. A successful save
126
+ reveals the saved entry and clears filters that could hide it. Overview search
127
+ now includes every matching entry, including goals and completed actions.
128
+
129
+ Observed in the actual local browser:
130
+
131
+ - Found the existing privacy answer, opened its correction dialog and cancelled
132
+ without changing the answer, followed Connections and returned to Records.
133
+ - Searched `src/work/ui/style.css` and found the same three check receipts in
134
+ Records and Map, including specific changed-input warnings.
135
+ - A search with no results stayed empty after switching views.
136
+ - Created the actual workspace-design decision while an empty search was active.
137
+ Saving cleared the search, opened Decisions and showed the saved entry.
138
+ - Corrected that decision from Map and confirmed keyboard focus returned to
139
+ the visible Correct button. Returning to Records showed the correction and
140
+ its earlier version. Focus restoration now scopes itself to the active view.
141
+ - Inspected Records at the normal window width and 390px, Map at 1280px, and the
142
+ shared dark editor at 1280px. At 390px the seven overview node boxes did not
143
+ overlap; the document had no horizontal overflow. Narrow category navigation
144
+ scrolls horizontally, and map details follow the graph in normal flow.
145
+
146
+ The changed UI requires targeted UI/API and documentation rechecks. Unchanged
147
+ CLI/MCP handoff and adversarial receipts remain reusable. This is local design
148
+ and functional validation; it adds no independent-user or timed utility study.
149
+ The npm publishing step remains separate from this local preview.
@@ -0,0 +1,139 @@
1
+ # Project handoff backup and recovery
2
+
3
+ Memoir protects `.memoir/work.json` automatically. You keep using the same
4
+ project folder and branch in Codex and Cursor; no extra command is needed for
5
+ each save. The optional browser uses the same protection.
6
+
7
+ ## Check that it is protected
8
+
9
+ ```sh
10
+ memoir work doctor
11
+ ```
12
+
13
+ The result reports ledger health, whether its current contents have a verified
14
+ snapshot, damaged copies and the available snapshot IDs. An unhealthy existing
15
+ handoff exits with status 1 so scripts can detect problems. `empty` describes a
16
+ project with no saved records and exits 0. This checks local data, not account
17
+ security, remote services or the accuracy of stored claims.
18
+
19
+ Existing handoffs are protected on the first updated `resume` or next write.
20
+ You can also create a copy explicitly:
21
+
22
+ ```sh
23
+ memoir work backup
24
+ ```
25
+
26
+ ## Keep a copy outside this project
27
+
28
+ ```sh
29
+ memoir work backup --output /path/to/backups/project-handoff.memoir
30
+ ```
31
+
32
+ Choose an existing destination folder and a **new filename**. The interactive
33
+ terminal asks for a passphrase twice. Use a strong passphrase of at least 12
34
+ characters, kept separately in your password manager. Memoir cannot recover it.
35
+ An export never overwrites an existing file.
36
+
37
+ For automation, a secret manager can inject `MEMOIR_WORK_PASSPHRASE` into the
38
+ environment. There is no command-line password flag. Do not put that value in
39
+ shell history, project files, a handoff record, or a conversation with an agent.
40
+ The older `MEMOIR_PASSPHRASE` setting is separate and is not changed.
41
+
42
+ The encrypted file includes **all branches and complete history** of this
43
+ project ledger, including retracted records and check receipts. It excludes
44
+ personal memory, transcripts, raw command output, client settings, other files
45
+ inside `.memoir/`, and recovery quarantine. AES-256-GCM with the existing Memoir
46
+ scrypt passphrase derivation protects the export; import authenticates it before
47
+ parsing. Secret screening remains heuristic. Review project records before
48
+ deliberately sharing an export with anyone else.
49
+
50
+ Store the export on a separate backed-up device or destination you control.
51
+ Copies inside `.memoir/` cannot protect against loss of the entire folder or
52
+ disk. Git commits, GitHub pushes and the older `memoir push` command do **not**
53
+ transport this ledger. To move to another machine, transfer the encrypted file,
54
+ recover it there, open the matching branch and run `memoir work setup` there.
55
+ Changed files and runtime still cause targeted check warnings.
56
+
57
+ ## Recover a damaged, missing or mistaken handoff
58
+
59
+ Start with a preview. It does not replace the current ledger:
60
+
61
+ ```sh
62
+ memoir work recover
63
+ ```
64
+
65
+ This selects the newest valid local copy. Review its revision, branch list and
66
+ record counts. To choose another copy, pass an ID returned by `doctor`:
67
+
68
+ ```sh
69
+ memoir work recover SNAPSHOT_ID
70
+ ```
71
+
72
+ Apply the exact reviewed source and fingerprint:
73
+
74
+ ```sh
75
+ memoir work recover SNAPSHOT_ID --apply --expect FINGERPRINT_FROM_PREVIEW
76
+ ```
77
+
78
+ For an encrypted export, use its filename in both steps:
79
+
80
+ ```sh
81
+ memoir work recover --from /path/to/backups/project-handoff.memoir
82
+ memoir work recover --from /path/to/backups/project-handoff.memoir --apply --expect FINGERPRINT_FROM_PREVIEW
83
+ memoir work resume
84
+ memoir work doctor
85
+ ```
86
+
87
+ Recovery replaces the **whole project ledger**, across all branches, rather
88
+ than merging selected records. Review an imported backup in an empty recovery
89
+ folder first if you need to inspect its contents. The fingerprint binds the
90
+ preview to the destination folder, exact current file and source file. A
91
+ concurrent save, changed export, or different target requires a fresh preview.
92
+ Wrong keys, tampering, unsupported formats, invalid history, detected secrets
93
+ and unsafe paths are rejected before replacement.
94
+
95
+ The original bytes are preserved under `.memoir/work-quarantine/`, including a
96
+ damaged original. Quarantine is private local data and is never exported by this
97
+ command. Do not publish it; malformed originals might contain secrets. If local
98
+ quarantine cannot be written, recovery stops. A missing ledger with surviving
99
+ snapshots also stops normal writes instead of silently starting over.
100
+
101
+ After recovery, **resume in every tool before saving again**. The handoff
102
+ returns a `recovery_id`; record and retract calls must pass it as
103
+ `expected_recovery` in addition to the existing revision guard. The browser
104
+ handles this itself: stale drafts remain available, and reviewing the restored
105
+ version allows a deliberate correction. CLI retraction takes `--recovery ID`.
106
+ Checks that were still running during recovery cannot overwrite restored
107
+ evidence. New checks capture the current generation automatically.
108
+
109
+ ## Storage, failure handling and upgrades
110
+
111
+ - Before acknowledging a save, Memoir writes verified-content snapshots of the
112
+ previous and proposed states, flushes file contents and atomically replaces
113
+ the ledger. An interrupted save can leave a proposed snapshot that was not
114
+ acknowledged. The recovery preview says so; it is never restored silently.
115
+ - Normal retention keeps the newest 20 snapshots, plus at most two protected
116
+ boundary copies. Each contains complete history. Pruning happens only after
117
+ a successful write; a cleanup problem never makes a committed write look
118
+ like a failed save. `doctor` reports damaged copies or excess retention.
119
+ - Files are created owner-readable/writable and directories owner-only on
120
+ POSIX. Local ledgers, snapshots and quarantine are plaintext. Directory
121
+ flushes run on POSIX; Windows uses flushed files and atomic replacement but
122
+ cannot guarantee directory power-loss persistence through Node. Filesystem,
123
+ hardware and same-user compromise remain outside this protection.
124
+ - The ledger limit remains 2 MiB. Reaching it refuses additional writes without
125
+ dropping history. Keep an encrypted export and start a separate project
126
+ handoff when the working ledger is full; automatic compaction is not provided.
127
+ - Upgrade all connected clients before recovery. Ordinary version-1 ledgers
128
+ remain readable; a recovered ledger adds a recovery generation. Older Memoir
129
+ versions reject that field rather than safely participating. Keep a
130
+ pre-upgrade export if you need a downgrade. Restart existing MCP connections
131
+ after upgrading so they load the current server; preserve their approvals.
132
+ - A busy lock does not justify deleting it while another process is running.
133
+ Retry once that operation ends. A leftover `.reaper` lock after a crash needs
134
+ local inspection with all Memoir writers stopped; this release does not
135
+ provide a general filesystem lock repair tool.
136
+
137
+ Recovery preserves local observations; it does not authenticate their authors
138
+ or certify their conclusions. Existing check-input comparisons still apply.
139
+ External settings need fresh verification independently of any backup.
@@ -43,7 +43,7 @@ Random vault-key wrapping, enrollment, cross-device rotation, hardware-backed st
43
43
  - CI confirms the declared OS/Node matrix; a local macOS run cannot establish Windows readiness.
44
44
  - Real Claude Code, Codex, and Cursor installations accept configuration and complete save/recall/restart.
45
45
  - Hosted migration and account-isolation tests pass.
46
- - The package owner configures npm's trusted publisher for `camgitt/memoir`, workflow `publish.yml`. The old publication auth failure is not fixed by this source branch.
46
+ - The npm trusted publisher for `camgitt/memoir`, workflow `publish.yml`, was verified for releases 3.14.0 and 3.15.0. Subsequent publication must pass the full CI matrix and retain provenance.
47
47
  - Select and synchronize the release version across package.json, lockfile, and server.json. Publish only the reviewed commit/tag, then repeat the smoke check against the registry artifact.
48
48
 
49
49
  The release gates above also apply to future deployments. The linked validation report records what was actually observed for this release. No independent security certification is implied.
@@ -55,3 +55,8 @@ Local locks fail when busy. A crash while reaping can leave a `.reaper` file req
55
55
  Cloud merges retained states, increasing download cost. It cannot recover versions already removed by retention. Strong concurrent-cloud consistency needs a server-side conditional commit/merge protocol and race-tested retention.
56
56
 
57
57
  Legacy scope migration, renamed projects, unusual remote aliases, and profile isolation remain work. Concurrent hostile parent-directory replacement requires further filesystem review: current validation/no-follow opens are not an openat-based sandbox.
58
+
59
+ ## Project handoff recovery
60
+
61
+ The project-only ledger now has automatic local snapshots and encrypted export/recovery.
62
+ These are separate from legacy/cloud backup storage. See [the project recovery guide](PROJECT-RECOVERY.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.14.0",
3
+ "version": "3.16.0",
4
4
  "mcpName": "io.github.camgitt/memoir",
5
5
  "description": "Portable, project-scoped memory and session handoffs for coding agents. Readable files, MCP recall, and optional user-passphrase encrypted backups.",
6
6
  "main": "src/index.js",
@@ -7,6 +7,18 @@ export const MAX_FILE_BYTES = 16 * 1024 * 1024;
7
7
  export const MAX_SNAPSHOT_BYTES = 256 * 1024 * 1024;
8
8
  export const MAX_SNAPSHOT_FILES = 50_000;
9
9
 
10
+ // Windows can briefly report access denied while another process deletes a
11
+ // file (notably the project lock). Retry the inspection, never skip it.
12
+ export async function inspectFile(file) {
13
+ for (let attempt = 0; ; attempt++) {
14
+ try { return await fs.lstat(file); }
15
+ catch (error) {
16
+ if (process.platform !== 'win32' || !['EPERM', 'EACCES'].includes(error.code) || attempt >= 5) throw error;
17
+ await new Promise(resolve => setTimeout(resolve, 20));
18
+ }
19
+ }
20
+ }
21
+
10
22
  // Validate both Windows and POSIX paths regardless of the restoring OS.
11
23
  export function relativeFile(value) {
12
24
  if (typeof value !== 'string' || !value || /[\x00-\x1f:]/.test(value) || path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) throw new Error('Invalid relative memory path');
@@ -31,12 +43,12 @@ export async function safePath(root, relative, { createParents = false } = {}) {
31
43
  for (let i = 0; i < parts.length; i++) {
32
44
  current = path.join(current, parts[i]);
33
45
  let st;
34
- try { st = await fs.lstat(current); }
46
+ try { st = await inspectFile(current); }
35
47
  catch (err) {
36
48
  if (err.code !== 'ENOENT') throw err;
37
49
  if (createParents && i < parts.length - 1) {
38
50
  await fs.mkdir(current, { mode: 0o700 }).catch(err => { if (err.code !== 'EEXIST') throw err; });
39
- st = await fs.lstat(current);
51
+ st = await inspectFile(current);
40
52
  }
41
53
  }
42
54
  if (st?.isSymbolicLink()) throw new Error('Symlinks are not allowed in memory paths');
@@ -1,6 +1,9 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import crypto from 'crypto';
4
+ import { inspectFile } from '../security/files.js';
5
+
6
+ const windowsBusy = error => process.platform === 'win32' && ['EPERM', 'EACCES'].includes(error.code);
4
7
 
5
8
  function alive(pid) {
6
9
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -19,12 +22,12 @@ export async function withSessionLock(lockPath, fn, { maxWaitMs = 5000, staleMs
19
22
  fs.writeSync(fd, String(process.pid));
20
23
  } catch (err) {
21
24
  if (fd !== undefined) { fs.closeSync(fd); await fs.remove(lockPath); throw err; }
22
- if (err.code !== 'EEXIST') throw err;
25
+ if (err.code !== 'EEXIST' && !windowsBusy(err)) throw err;
23
26
  let reaper;
24
27
  const reaperPath = lockPath + '.reaper';
25
28
  try {
26
29
  reaper = await fs.open(reaperPath, 'wx', 0o600);
27
- const st = await fs.lstat(lockPath);
30
+ const st = await inspectFile(lockPath);
28
31
  if (st.isSymbolicLink()) throw new Error('Session lock must not be a symlink');
29
32
  const owner = Number((await fs.readFile(lockPath, 'utf8')).trim());
30
33
  if (Date.now() - st.mtimeMs > staleMs && !alive(owner)) {
@@ -32,7 +35,7 @@ export async function withSessionLock(lockPath, fn, { maxWaitMs = 5000, staleMs
32
35
  await fs.rename(lockPath, abandoned);
33
36
  await fs.remove(abandoned);
34
37
  }
35
- } catch (err) { if (!['ENOENT', 'EEXIST'].includes(err.code)) throw err; }
38
+ } catch (err) { if (!['ENOENT', 'EEXIST'].includes(err.code) && !windowsBusy(err)) throw err; }
36
39
  finally {
37
40
  if (reaper !== undefined) {
38
41
  await fs.close(reaper);
@@ -49,9 +52,18 @@ export async function withSessionLock(lockPath, fn, { maxWaitMs = 5000, staleMs
49
52
  }
50
53
  try { return await fn(); }
51
54
  finally {
52
- let ours = false;
53
- try { const a = fs.fstatSync(fd), b = fs.lstatSync(lockPath); ours = a.ino === b.ino && a.dev === b.dev; } catch {}
55
+ let ours = false, owned;
56
+ try { owned = fs.fstatSync(fd); const current = await inspectFile(lockPath); ours = owned.ino === current.ino && owned.dev === current.dev; } catch {}
54
57
  fs.closeSync(fd);
55
- if (ours) await fs.unlink(lockPath).catch(() => {});
58
+ if (ours) for (let attempt = 0; ; attempt++) {
59
+ try {
60
+ if (attempt) { const current = await inspectFile(lockPath); if (owned.ino !== current.ino || owned.dev !== current.dev) break; }
61
+ await fs.unlink(lockPath); break;
62
+ }
63
+ catch (error) {
64
+ if (!windowsBusy(error) || attempt >= 5) break;
65
+ await new Promise(resolve => setTimeout(resolve, 20));
66
+ }
67
+ }
56
68
  }
57
69
  }
package/src/work/cli.js CHANGED
@@ -1,8 +1,20 @@
1
1
  import { Command } from 'commander';
2
2
  import { recordWork, runWorkCheck, retractWork, refreshWork, formatWork } from './store.js';
3
3
  import { setupWork } from './setup.js';
4
+ import { backupWork, doctorWork, recoverWork } from './recovery.js';
4
5
  import { readSafeFile } from '../security/files.js';
5
6
 
7
+ async function recoveryPassphrase(confirm = false) {
8
+ if (process.env.MEMOIR_WORK_PASSPHRASE) return process.env.MEMOIR_WORK_PASSPHRASE;
9
+ if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('Set MEMOIR_WORK_PASSPHRASE through your secret manager, or run this command in an interactive terminal. Never put the passphrase in a command argument or project record.');
10
+ const { default: inquirer } = await import('inquirer');
11
+ const questions = [{ type: 'password', name: 'passphrase', message: 'Recovery passphrase (at least 12 characters):', mask: '*' }];
12
+ if (confirm) questions.push({ type: 'password', name: 'confirmation', message: 'Repeat recovery passphrase:', mask: '*' });
13
+ const answer = await inquirer.prompt(questions);
14
+ if (confirm && answer.confirmation !== answer.passphrase) throw new Error('Passphrases did not match. Nothing was exported.');
15
+ return answer.passphrase;
16
+ }
17
+
6
18
  export async function workCli(argv) {
7
19
  const program = new Command('memoir work').description('Local project continuity for Codex and Cursor')
8
20
  .option('--project <path>', 'Project directory', process.env.MEMOIR_PROJECT_ROOT || process.cwd());
@@ -14,6 +26,25 @@ export async function workCli(argv) {
14
26
  const view = await refreshWork(project());
15
27
  console.log(options.json ? JSON.stringify(view, null, 2) : formatWork(view));
16
28
  });
29
+ program.command('doctor').description('Check the project handoff and recovery snapshots').action(async () => {
30
+ const result = await doctorWork(project());
31
+ console.log(JSON.stringify(result, null, 2));
32
+ if (!result.healthy && result.state !== 'empty') process.exitCode = 1;
33
+ });
34
+ program.command('backup').description('Save a local snapshot, or export an encrypted project handoff')
35
+ .option('--output <path>', 'New encrypted backup file; never overwrites an existing file').action(async options => {
36
+ const passphrase = options.output ? await recoveryPassphrase(true) : undefined;
37
+ console.log(JSON.stringify(await backupWork(project(), { output: options.output, passphrase }), null, 2));
38
+ });
39
+ program.command('recover [snapshot]').description('Preview recovery first; apply only the reviewed fingerprint')
40
+ .option('--from <path>', 'Encrypted project handoff export')
41
+ .option('--apply', 'Apply the reviewed recovery and preserve the original')
42
+ .option('--expect <fingerprint>', 'Fingerprint returned by the recovery preview').action(async (snapshot, options) => {
43
+ const passphrase = options.from ? await recoveryPassphrase() : undefined;
44
+ const result = await recoverWork(project(), { ...options, snapshot, passphrase });
45
+ if (result.applied) await refreshWork(project());
46
+ console.log(JSON.stringify(result, null, 2));
47
+ });
17
48
  program.command('view').description('Review and correct project memory in a local browser')
18
49
  .option('--no-open', 'Print the local link without opening a browser').option('--port <number>', 'Local port; 0 chooses an available port', '0').action(async options => {
19
50
  const { startWorkView } = await import('./view.js');
@@ -54,8 +85,8 @@ export async function workCli(argv) {
54
85
  console.log(JSON.stringify(result, null, 2));
55
86
  if (result.exit_code !== 0 || result.timed_out || !result.inputs_stable) process.exitCode = 1;
56
87
  });
57
- program.command('retract <id>').requiredOption('--revision <number>', 'Current record revision').option('--category <name>', 'record or check', 'record').action(async (id, options) => {
58
- const result = await retractWork(project(), { id, expected_revision: Number(options.revision), category: options.category });
88
+ program.command('retract <id>').requiredOption('--revision <number>', 'Current record revision').option('--category <name>', 'record or check', 'record').option('--recovery <id>', 'Recovery generation returned by resume').action(async (id, options) => {
89
+ const result = await retractWork(project(), { id, expected_revision: Number(options.revision), category: options.category, expected_recovery: options.recovery });
59
90
  await refreshWork(project());
60
91
  console.log(JSON.stringify(result));
61
92
  });