sshlg-skills 0.22.2 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,76 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.23.0 — 2026-08-06
4
+
5
+ ### Fixed
6
+
7
+ - **The second `routers` run destroyed the block the first one wrote.** The
8
+ block's own heading is `## Роутинг работы — семья ssheleg`; migration's
9
+ pattern for the hand-written rule is `## Роутинг работы`. On the second run
10
+ it took the block's heading for a hand-written rule and cut from there to the
11
+ next H1 or end of file — carrying the closing sentinel, and every rule the
12
+ operator kept below it, out of `~/.claude/CLAUDE.md`. The command then
13
+ reported the block malformed and refused to touch it again. Shipped in
14
+ v0.22.0 and reproduced against it.
15
+
16
+ It hid because idempotence was proven on `upsert`, which is pure, while the
17
+ damage happened one layer up in the command that runs migration first. On
18
+ this machine it cost nothing only because the block had never been written.
19
+ Migration now never reads inside the managed block, and **skips past it
20
+ rather than stopping at it**, so a hand-written rule *below* the block is
21
+ still found.
22
+
23
+ - **A section ended at the next `## `, and an `# ` heading did not stop it.**
24
+ In the operator's real file the section to be cut is followed by exactly
25
+ that, so the cut ran to end of file. A `### ` subheading still does not end
26
+ its parent.
27
+
28
+ - **`npm test` did not exist.** Eight suites and two validators ran only in
29
+ CI; locally the standard command failed with `Missing script: "test"`.
30
+
31
+ ### Added
32
+
33
+ - **Eight routers, declared once each.** A router used to be written twice —
34
+ its text in `router-texts.js`, its table row in `routers.js` — with nothing
35
+ comparing the halves. One registry entry now carries what a router is: the
36
+ members it needs, its two table cells, its text. Key order is table order.
37
+ - New: `sheleg-design` (how it looks and moves), `make-skill` (how the skill
38
+ itself is built), `agent-sync` (who is holding this file).
39
+ - New and backed by **no skill at all**: `seo-llmo` and `evidence-docs` are
40
+ rules rather than tools, so they hold whether or not anything is
41
+ installed. That is why a router stopped being a property of a member.
42
+ - `task-pipeline`'s text now claims **planning** as its own stages 2–4,
43
+ which retires the competing planning rule beside it: a superseded heading
44
+ is removed only when the router replacing it is actually written, and its
45
+ body is kept rather than dropped.
46
+ - **`sshlg-skills config`** — every router on or off by name. Switching one
47
+ off removes its section and its table row; switching it back on restores the
48
+ **exact bytes**, including wording of the operator's that migration had
49
+ moved in. `~/.claude/CLAUDE.md` has no version control behind it, so a
50
+ toggle that regenerated the packaged text would erase a person's words
51
+ silently.
52
+ - Settings store **deviations only**, so a router added in a later release
53
+ arrives switched on rather than silently off.
54
+ - Sections are kept in registry order, so the block's reading order cannot
55
+ drift from the table it renders.
56
+
57
+ ### Changed
58
+
59
+ - `npm test` is the one entry point, and it **discovers** `test/*_test.js`
60
+ instead of listing them. An empty run fails rather than reporting green, and
61
+ `validate.py` fails if CI stops calling `npm test` — a guard that shipped
62
+ wrong the first time (it matched the substring anywhere in the workflow,
63
+ which the negative self-tests satisfy) and was tightened after being planted
64
+ and watched.
65
+ - `node --check` on the launcher is replaced by running the launcher, per the
66
+ retrospective's first standing instruction.
67
+ - **179 fixtures across eight suites, up from 75 across five** — both numbers
68
+ counted by running the suites, not carried over. The previous release notes
69
+ claimed 71 and its acceptance record claimed 74; the count at that commit is
70
+ 75. Three numbers, one measurement, and the two that were restated are the
71
+ two that were wrong. That is the `evidence-docs` router's entire argument,
72
+ arriving from this repository's own paperwork.
73
+
3
74
  ## v0.22.2 — 2026-08-05
4
75
 
5
76
  ### Fixed
package/README.md CHANGED
@@ -117,6 +117,59 @@ installed, so it takes no `--agent`.
117
117
  > plugin. The launcher passes the agent list explicitly and prunes those copies
118
118
  > after every run.
119
119
 
120
+ ## Routing — making the family engage by default
121
+
122
+ A skill's `description` influences whether a model reaches for it. It does not
123
+ oblige. So the pack writes a **managed routing block** into your global agent
124
+ instructions (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`), and the rules
125
+ engage in every project instead of only when someone remembers to ask.
126
+
127
+ ```bash
128
+ npx sshlg-skills routers # write it (asks once)
129
+ npx sshlg-skills routers --dry-run # show the diff, change nothing
130
+ npx sshlg-skills routers --update # refresh an existing block, never create one
131
+ ```
132
+
133
+ Eight routers, and they are **different axes rather than competing
134
+ priorities** — a landing page passes several, an internal script passes none:
135
+
136
+ | Router | Answers | When | Needs installed |
137
+ |---|---|---|---|
138
+ | `super-ux` | what the interface must do | there is user-facing behaviour | super-ux |
139
+ | `sheleg-design` | how it looks and moves | there is a visual layer | sheleg-design |
140
+ | `copywriting` | how it sounds | text a product user will read | super-ux |
141
+ | `seo-llmo` | whether a machine will find it | a logged-out reader can see it | — |
142
+ | `evidence-docs` | what proves it | something is stated as true | — |
143
+ | `task-pipeline` | how the change reaches the repo | the repository changes | task-pipeline |
144
+ | `make-skill` | how the skill itself is built | a skill or plugin changes shape | make-skill |
145
+ | `agent-sync` | who is holding this file | the project has coordination on | agent-sync |
146
+
147
+ Two of them need no skill behind them. `seo-llmo` and `evidence-docs` are
148
+ rules, not tools, so they hold whether or not anything is installed.
149
+
150
+ **Your own wording wins.** Where you already wrote a rule by hand, migration
151
+ moves *your* text in verbatim — asides included — and the packaged default is
152
+ used only for a router you never wrote.
153
+
154
+ **Consent is asked once**, recorded, and never asked again. Declining leaves an
155
+ `SSHLG:ROUTERS:OPTOUT` marker, which the block's own header names as the way
156
+ out and which survives a reinstall and a restored dotfile. Everything outside
157
+ the sentinels is preserved byte for byte.
158
+
159
+ ### Turning routers off
160
+
161
+ ```bash
162
+ npx sshlg-skills config # what this machine wants
163
+ npx sshlg-skills config set routers.seo-llmo off
164
+ npx sshlg-skills routers --update # apply it
165
+ ```
166
+
167
+ Switching a router off **removes its section** and drops its table row.
168
+ Switching it back on restores the exact bytes that were there — including
169
+ wording of yours that migration had moved in. Settings live in
170
+ `~/.sshlg-skills/config.json` (mode 0600) and store deviations only, so a
171
+ router added in a later release arrives switched on rather than silently off.
172
+
120
173
  ## Other commands
121
174
 
122
175
  ```bash
@@ -136,8 +189,14 @@ get wrong: one channel per agent, exact agent ids, repeated `--agent` flags, ful
136
189
  ```
137
190
  skills.json registry — repos, plugin ids, skill names, pins
138
191
  skills/* the six skills as pinned git submodules
139
- bin/sshlg-skills.js the launcher (install / update / list / agents)
192
+ bin/sshlg-skills.js the launcher (install / update / routers / config / list / agents)
193
+ lib/routers-registry.js the eight routers — text, table row and required members, in one entry
194
+ lib/routers.js block parsing and rendering; touches no file, by construction
195
+ lib/apply.js the only module that writes to the instruction files
196
+ lib/migrate.js moves hand-written rules in; never reads inside the block
197
+ lib/config.js, lib/store.js the pack's settings, and the 0600 discipline they share
140
198
  install.sh POSIX fallback (macOS/Linux; use npx on Windows)
199
+ test/run.js `npm test` — the validator, then every discovered suite
141
200
  test/validate.py registry / submodules / version validation
142
201
  .github/workflows validation on push and PR + tag-driven release
143
202
  ```
@@ -150,13 +209,22 @@ commit installs exactly the skill versions that commit was tested with.
150
209
  ## Development
151
210
 
152
211
  ```bash
153
- python3 test/validate.py
212
+ npm test
154
213
  ```
155
214
 
215
+ One entry point: it runs the structural validator, then **discovers** every
216
+ `test/*_test.js` rather than listing them — a list would live in
217
+ `package.json` and in the workflow at once, and drift the first time a suite
218
+ was added to one side. An empty run fails rather than reporting green, and
219
+ `validate.py` fails if CI stops calling `npm test`.
220
+
156
221
  ```bash
157
- node --check bin/sshlg-skills.js
222
+ python3 test/check_pins.py
158
223
  ```
159
224
 
225
+ Kept out of `npm test` on purpose: it queries the npm registry, and the rest
226
+ must work offline.
227
+
160
228
  Releases are tag-driven: bump `skills.json`, `package.json` and the top
161
229
  `CHANGELOG.md` entry together, tag `vX.Y.Z`, and the release workflow cuts the
162
230
  GitHub release from the matching changelog section.
@@ -92,6 +92,9 @@ Skills: ${SKILLS.map(s => s.name).join(', ')}
92
92
  Usage:
93
93
  npx sshlg-skills install [--agent a,b | --all] [--no-claude] [--claude-only]
94
94
  npx sshlg-skills update [--agent a,b | --all] [--no-claude] [--claude-only] [--bump-pins]
95
+ npx sshlg-skills routers [--member <name>] [--update] [--dry-run]
96
+ npx sshlg-skills config [list]
97
+ npx sshlg-skills config set routers.<name> on|off
95
98
  npx sshlg-skills list
96
99
  npx sshlg-skills agents
97
100
 
@@ -208,7 +211,8 @@ function cmdAgents() {
208
211
  * worse than no table.
209
212
  */
210
213
  function cmdRouters(f) {
211
- const routersLib = require('../lib/router-texts.js');
214
+ const registry = require('../lib/routers-registry.js');
215
+ const configLib = require('../lib/config.js');
212
216
  const apply = require('../lib/apply.js');
213
217
  const consent = require('../lib/consent.js');
214
218
  const migrate = require('../lib/migrate.js');
@@ -216,9 +220,31 @@ function cmdRouters(f) {
216
220
  const path = require('path');
217
221
  const home = process.env.HOME || require('os').homedir();
218
222
 
219
- const members = f.member ? [f.member] : manifest.skills.map((s) => s.name);
220
- const packaged = routersLib.forMembers(members);
221
- if (!Object.keys(packaged).length) {
223
+ // The settings decide which routers this machine wants; the registry decides
224
+ // which ones it may speak for. With --member, only that member's own — a
225
+ // lone installer speaks for itself and never for the family's rules.
226
+ const config = configLib.readConfig(home);
227
+ const isEnabled = (n) => configLib.isEnabled(config, n);
228
+ const scopeOpts = f.member
229
+ ? { member: f.member, isEnabled }
230
+ : { installed: manifest.skills.map((s) => s.name), isEnabled };
231
+
232
+ const packaged = registry.resolve(scopeOpts);
233
+ const remove = registry.disabled(scopeOpts);
234
+
235
+ // A stashed body outranks the packaged text: it is what was in the block
236
+ // before the router was switched off, which on this machine may be the
237
+ // operator's own wording that migration moved in.
238
+ const restored = [];
239
+ for (const name of Object.keys(packaged)) {
240
+ const stashed = configLib.stashGet(config, name);
241
+ if (stashed !== undefined) {
242
+ packaged[name] = stashed;
243
+ restored.push(name);
244
+ }
245
+ }
246
+
247
+ if (!Object.keys(packaged).length && !remove.length) {
222
248
  log('routers: ни один установленный участник не даёт роутера — нечего писать');
223
249
  return true;
224
250
  }
@@ -243,6 +269,8 @@ function cmdRouters(f) {
243
269
 
244
270
  // Hand-written rules win over the packaged text, so migration runs first and
245
271
  // its result is what gets written.
272
+ const superseded = {};
273
+ const sources = {};
246
274
  for (const t of apply.TARGETS) {
247
275
  const file = path.join(home, t.dir, t.file);
248
276
  if (!fs.existsSync(file)) continue;
@@ -251,18 +279,103 @@ function cmdRouters(f) {
251
279
  if (moved.text !== src && decision === 'yes' && !f.dryRun) {
252
280
  fs.writeFileSync(file, moved.text, 'utf8');
253
281
  }
282
+ // Hand the migrated text on rather than letting apply re-read the file:
283
+ // on a dry run nothing was written, and re-reading would preview the
284
+ // additions without the removals that come with them.
285
+ sources[file] = decision === 'yes' ? moved.text : src;
254
286
  Object.assign(packaged, moved.routers);
287
+ Object.assign(superseded, moved.superseded);
288
+ }
289
+
290
+ const res = apply.apply({
291
+ home, mode, consent: decision, routers: packaged,
292
+ remove, sources, dryRun: f.dryRun, log,
293
+ });
294
+
295
+ // Whatever left the block is kept, not dropped. A switch that loses the
296
+ // operator's wording on the way out is a switch nobody dares use twice, and
297
+ // the file it was taken from has no version control behind it.
298
+ if (!f.dryRun && decision === 'yes') {
299
+ for (const r of res.targets) {
300
+ for (const [name, body] of Object.entries(r.removed || {})) {
301
+ configLib.stashSet(home, name, body);
302
+ }
303
+ }
304
+ for (const [id, body] of Object.entries(superseded)) {
305
+ configLib.stashSet(home, 'superseded:' + id, body);
306
+ }
307
+ // A restored section is live again, so its copy in the settings is now a
308
+ // stale duplicate of text that has a home.
309
+ for (const name of restored) configLib.stashClear(home, name);
255
310
  }
256
311
 
257
- const res = apply.apply({ home, mode, consent: decision, routers: packaged, dryRun: f.dryRun, log });
258
312
  for (const r of res.targets) {
259
313
  if (r.action === 'agent-absent') continue;
260
314
  log(`routers: ${r.file} — ${r.action}`);
261
315
  if (r.diff) log(r.diff);
262
316
  }
317
+ if (remove.length) log(`routers: выключены настройкой — ${remove.join(', ')}`);
318
+ for (const id of Object.keys(superseded)) {
319
+ log(`routers: вытеснен рукописный раздел "${id}" — тело сохранено в ${configLib.configPath(home)}`);
320
+ }
263
321
  return true;
264
322
  }
265
323
 
324
+ /**
325
+ * `config` — the pack's settings.
326
+ *
327
+ * Kept out of `parseFlags` on purpose: its arguments are positional, and that
328
+ * parser exits on anything that is not a known flag.
329
+ */
330
+ function cmdConfig(argv) {
331
+ const registry = require('../lib/routers-registry.js');
332
+ const configLib = require('../lib/config.js');
333
+ const home = process.env.HOME || require('os').homedir();
334
+ const [sub, key, value] = argv;
335
+
336
+ if (!sub || sub === 'list') {
337
+ const config = configLib.readConfig(home);
338
+ log(`Настройки пака — ${configLib.configPath(home)}\n`);
339
+ for (const name of registry.order()) {
340
+ log(` routers.${name.padEnd(16)} ${configLib.isEnabled(config, name) ? 'on' : 'off'}`);
341
+ }
342
+ log('\nПоменять: npx sshlg-skills config set routers.<имя> on|off');
343
+ return 0;
344
+ }
345
+
346
+ if (sub !== 'set') {
347
+ log(`config: неизвестная подкоманда "${sub}" — есть list и set`);
348
+ return 2;
349
+ }
350
+
351
+ if (!key || key.indexOf('routers.') !== 0) {
352
+ log('config set: ключ должен начинаться с "routers." — других разделов пока нет');
353
+ return 2;
354
+ }
355
+
356
+ const name = key.slice('routers.'.length);
357
+ if (!Object.prototype.hasOwnProperty.call(registry.REGISTRY, name)) {
358
+ log(`config set: роутера "${name}" нет. Валидные имена:`);
359
+ for (const n of registry.order()) log(` routers.${n}`);
360
+ return 2;
361
+ }
362
+
363
+ if (configLib.STATES.indexOf(value) === -1) {
364
+ log(`config set: состояние должно быть on или off — получено "${value === undefined ? '' : value}"`);
365
+ return 2;
366
+ }
367
+
368
+ const was = configLib.isEnabled(configLib.readConfig(home), name) ? 'on' : 'off';
369
+ if (was === value) {
370
+ log(`routers.${name}: ${value} (без изменений)`);
371
+ return 0;
372
+ }
373
+ configLib.setRouter(home, name, value);
374
+ log(`routers.${name}: ${was} → ${value}`);
375
+ log('Запусти `npx sshlg-skills routers --update`, чтобы применить.');
376
+ return 0;
377
+ }
378
+
266
379
  function readLineSync() {
267
380
  const fs = require('fs');
268
381
  const buf = Buffer.alloc(1024);
@@ -279,6 +392,9 @@ function main(argv) {
279
392
  if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') { usage(); return 0; }
280
393
  if (cmd === 'list' || cmd === 'ls') { cmdList(); return 0; }
281
394
  if (cmd === 'agents') { cmdAgents(); return 0; }
395
+ // Before parseFlags: `config` takes positional arguments, and that parser
396
+ // exits on any token it does not recognise as a flag.
397
+ if (cmd === 'config') return cmdConfig(rest);
282
398
  const f = parseFlags(rest);
283
399
  if (cmd === 'install' || cmd === 'i') return cmdInstall(f) ? 0 : 1;
284
400
  if (cmd === 'update' || cmd === 'up') return cmdUpdate(f) ? 0 : 1;
package/lib/apply.js CHANGED
@@ -53,9 +53,30 @@ function writeOptOut(file) {
53
53
  }
54
54
 
55
55
 
56
+ /**
57
+ * `opts.sources[file]` is the text to work FROM, when the caller already has
58
+ * a version the disk does not.
59
+ *
60
+ * Migration runs before this module and, on a dry run, deliberately does not
61
+ * write. Re-reading the file then showed a preview of the block being added
62
+ * with none of the hand-written sections being removed — +361 lines against
63
+ * -1, where the real run removes about a hundred and thirty. A preview that
64
+ * under-reports its own removals is worse than no preview: it is read as
65
+ * permission.
66
+ *
67
+ * Passing the source in also makes the dry and the wet path compute from the
68
+ * same bytes, rather than agreeing because the wet one happened to write
69
+ * first.
70
+ */
56
71
  function applyOne(file, routers, opts) {
57
72
  const exists = fs.existsSync(file);
58
- const before = exists ? fs.readFileSync(file, 'utf8') : '';
73
+ // Two different texts, and conflating them is the bug this exists to fix.
74
+ // `onDisk` is what the operator has right now and the only honest baseline
75
+ // for a diff. `before` is what to compute from — the migrated text on a dry
76
+ // run, where migration deliberately wrote nothing.
77
+ const onDisk = exists ? fs.readFileSync(file, 'utf8') : '';
78
+ const supplied = opts.sources && Object.prototype.hasOwnProperty.call(opts.sources, file);
79
+ const before = supplied ? opts.sources[file] : onDisk;
59
80
  const parsed = R.parse(before);
60
81
 
61
82
  if (parsed.state === R.STATE.OPTED_OUT) return { file, action: 'opted-out' };
@@ -75,14 +96,17 @@ function applyOne(file, routers, opts) {
75
96
  source = before + gap + EMPTY_BLOCK;
76
97
  }
77
98
 
78
- const result = R.upsert(source, routers);
99
+ const result = R.upsert(source, routers, { remove: opts.remove || [] });
79
100
  const next = result.text;
80
- if (next === before) return { file, action: 'unchanged' };
101
+ const removed = result.removed || {};
102
+ if (next === onDisk) return { file, action: 'unchanged', removed };
81
103
 
82
- if (opts.dryRun) return { file, action: 'would-write', diff: R.diff(before, next) };
104
+ if (opts.dryRun) {
105
+ return { file, action: 'would-write', diff: R.diff(onDisk, next), removed };
106
+ }
83
107
 
84
108
  fs.writeFileSync(file, next, 'utf8');
85
- return { file, action: exists ? 'updated' : 'created' };
109
+ return { file, action: exists ? 'updated' : 'created', removed };
86
110
  }
87
111
 
88
112
  /**
@@ -102,6 +126,7 @@ function apply(opts) {
102
126
  continue;
103
127
  }
104
128
  const record = applyOne(path.join(dir, t.file), opts.routers || {}, opts);
129
+ record.agent = t.agent;
105
130
  targets.push(record);
106
131
 
107
132
  if (record.action === 'malformed') {
package/lib/config.js ADDED
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+ /**
3
+ * The pack's settings — which routers this machine wants.
4
+ *
5
+ * Shape:
6
+ *
7
+ * {
8
+ * "routers": { "seo-llmo": "off" },
9
+ * "stash": { "task-pipeline": "…the section body that was removed…" }
10
+ * }
11
+ *
12
+ * **Only deviations are stored.** A router absent from `routers` is on. That
13
+ * is what lets a new family member's router appear on its own: a file listing
14
+ * every router as "on" would freeze today's list, and the next release's
15
+ * router would arrive switched off by a file nobody remembered writing.
16
+ *
17
+ * **The stash is not a cache.** Migration moves the operator's own wording
18
+ * into the block, asides and all. Switching a router off and back on would
19
+ * otherwise return the packaged default, silently replacing text a person
20
+ * wrote — against `~/.claude/CLAUDE.md`, which is not under version control
21
+ * and has nothing to roll back to.
22
+ */
23
+
24
+ const path = require('path');
25
+ const store = require('./store.js');
26
+
27
+ const STATES = ['on', 'off'];
28
+
29
+ function configPath(home) {
30
+ return path.join(home, '.sshlg-skills', 'config.json');
31
+ }
32
+
33
+ /** The settings, or `{}`. A file we cannot read falls back to the defaults. */
34
+ function readConfig(home) {
35
+ return store.readJson(configPath(home));
36
+ }
37
+
38
+ /** Absent means on. Only an explicit "off" disables a router. */
39
+ function isEnabled(config, name) {
40
+ const routers = (config && config.routers) || {};
41
+ return routers[name] !== 'off';
42
+ }
43
+
44
+ /**
45
+ * Record a router's state. `on` DELETES the key rather than writing it, so
46
+ * the file keeps holding deviations only.
47
+ */
48
+ function setRouter(home, name, state) {
49
+ if (!STATES.includes(state)) {
50
+ throw new Error(`state must be one of on, off — got "${state}"`);
51
+ }
52
+ const config = readConfig(home);
53
+ const routers = Object.assign({}, config.routers);
54
+ if (state === 'off') routers[name] = 'off';
55
+ else delete routers[name];
56
+ return store.writeJson(configPath(home), { routers });
57
+ }
58
+
59
+ function stashGet(config, name) {
60
+ const stash = (config && config.stash) || {};
61
+ return Object.prototype.hasOwnProperty.call(stash, name) ? stash[name] : undefined;
62
+ }
63
+
64
+ function stashSet(home, name, body) {
65
+ const config = readConfig(home);
66
+ const stash = Object.assign({}, config.stash);
67
+ stash[name] = body;
68
+ return store.writeJson(configPath(home), { stash });
69
+ }
70
+
71
+ function stashClear(home, name) {
72
+ const config = readConfig(home);
73
+ const stash = Object.assign({}, config.stash);
74
+ delete stash[name];
75
+ return store.writeJson(configPath(home), { stash });
76
+ }
77
+
78
+ module.exports = {
79
+ configPath, readConfig, isEnabled, setRouter,
80
+ stashGet, stashSet, stashClear, STATES,
81
+ };
package/lib/consent.js CHANGED
@@ -11,8 +11,8 @@
11
11
  * against a temp directory and cannot reach the real state file.
12
12
  */
13
13
 
14
- const fs = require('fs');
15
14
  const path = require('path');
15
+ const store = require('./store.js');
16
16
 
17
17
  function statePath(home) {
18
18
  return path.join(home, '.sshlg-skills', 'state.json');
@@ -20,25 +20,18 @@ function statePath(home) {
20
20
 
21
21
  /** The recorded state, or `{}`. A file we cannot parse is not consent. */
22
22
  function readState(home) {
23
- try {
24
- const raw = fs.readFileSync(statePath(home), 'utf8');
25
- const parsed = JSON.parse(raw);
26
- return parsed && typeof parsed === 'object' ? parsed : {};
27
- } catch (e) {
28
- return {};
29
- }
23
+ return store.readJson(statePath(home));
30
24
  }
31
25
 
32
- /** Merge `patch` into the recorded state. Owner-only, like anything else here. */
26
+ /**
27
+ * Merge `patch` into the recorded state. Owner-only, like anything else here.
28
+ *
29
+ * The reading, the merge and the 0600 now live in `store.js`, shared with the
30
+ * settings file — one implementation of the permission rule instead of two
31
+ * that agree today.
32
+ */
33
33
  function writeState(home, patch) {
34
- const file = statePath(home);
35
- fs.mkdirSync(path.dirname(file), { recursive: true });
36
- const next = Object.assign(readState(home), patch);
37
- fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
38
- // writeFileSync's mode is subject to umask on create and ignored entirely on
39
- // an existing file, so the permission is set rather than requested.
40
- fs.chmodSync(file, 0o600);
41
- return next;
34
+ return store.writeJson(statePath(home), patch);
42
35
  }
43
36
 
44
37
  function defaultPrompt() {