@wirecat/cli-core 0.555.0 → 0.556.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/calendar/mcp/index.d.ts +12 -12
  3. package/dist/cli/config-command.js +64 -2
  4. package/dist/cli/config-issues.d.ts +2 -0
  5. package/dist/cli/config-issues.js +11 -3
  6. package/dist/cli/config-repair.d.ts +26 -0
  7. package/dist/cli/config-repair.js +252 -0
  8. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  9. package/dist/cli/messenger/tasks-analysis-command.d.ts +8 -0
  10. package/dist/cli/messenger/tasks-analysis-command.js +184 -0
  11. package/dist/cli/messenger/tasks-command.js +11 -2
  12. package/dist/cli/steps.js +10 -0
  13. package/dist/entities/index.d.ts +64 -2
  14. package/dist/entities/index.js +5 -2
  15. package/dist/entities/task-results.d.ts +72 -0
  16. package/dist/entities/task-results.js +40 -1
  17. package/dist/errors/reasons.d.ts +1 -1
  18. package/dist/errors/reasons.js +9 -0
  19. package/dist/mail/cli/index.d.ts +1 -0
  20. package/dist/mail/cli/index.js +3 -0
  21. package/dist/mail/cli/marks.d.ts +26 -0
  22. package/dist/mail/cli/marks.js +51 -0
  23. package/dist/mail/cli/review.js +70 -0
  24. package/dist/mail/index.d.ts +1 -0
  25. package/dist/mail/index.js +1 -0
  26. package/dist/mail/marks.d.ts +56 -0
  27. package/dist/mail/marks.js +178 -0
  28. package/dist/sends/permissions.js +1 -0
  29. package/dist/services/task-analysis.d.ts +58 -0
  30. package/dist/services/task-analysis.js +125 -0
  31. package/dist/store/exports.d.ts +2 -2
  32. package/dist/store/index.d.ts +1 -1
  33. package/dist/store/sqlite/email-activity.d.ts +31 -0
  34. package/dist/store/sqlite/email-activity.js +38 -0
  35. package/dist/store/sqlite/emails.d.ts +2 -0
  36. package/dist/store/sqlite/emails.js +1 -0
  37. package/dist/store/sqlite/task-analysis-marks.d.ts +7 -0
  38. package/dist/store/sqlite/task-analysis-marks.js +27 -0
  39. package/dist/store/sync.d.ts +5 -1
  40. package/dist/store/sync.js +4 -0
  41. package/dist/store/types.d.ts +25 -0
  42. package/package.json +1 -1
  43. package/parity.json +29 -0
  44. package/skills/follow-ups/SKILL.md +22 -2
package/CHANGELOG.md CHANGED
@@ -7,10 +7,33 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.556.0 — 02.11.2026
11
+
12
+ ### Added
13
+
14
+ - **A processing mark per holder and mail account** (`src/mail/marks.ts`): `review --holder <name>` reads what is
15
+ stored past the holder's mark, by store order (`emails.id`), so mail stored late is not skipped; bulk mail, envelopes
16
+ and drafts are skipped and counted, partial mail held until whole. `messages acknowledge --holder <name> --cursor
17
+ <cursor>` moves the mark forward; a read-only scheduled run may run it. Kept in `sync_cursors` as
18
+ `consumer:<holder>`, no schema change. `readPastMark`, `acknowledgeMark`, `marksOf`, `markItem` and the
19
+ `mail_mark` entity are exported for `status`; `store.mail` gains `emailsAfter`, `newestEmailId`, `statesLike` and
20
+ `emailHits`.
21
+
10
22
  ## 0.555.0 — 02.11.2026
11
23
 
12
24
  ### Added
13
25
 
26
+ - **Each chat keeps its own point of reading for tasks.** `review --new`, `inbox --new`, the messenger's read state
27
+ and other agents move their own points, so a message one of them showed could be skipped by the next task pass.
28
+ `tasks status` lists every chat that can hold tasks (not channels or Saved Messages) with how far it was read for
29
+ tasks, how many stored messages wait after that, and how many arrived late inside the stretch already read
30
+ (history fetched afterwards). `tasks review <chat>` gives the waiting messages, oldest first, with the chat's open
31
+ tasks, and moves nothing; `tasks acknowledge <chat> <message>` moves the point once the tasks are written, never
32
+ back. A chat never read starts 14 days back; `--since-time` reads a stretch again. The points live in
33
+ `store_settings`, so no migration; `SyncStore.taskAnalysisMarks`, `taskAnalysisMark` and `setTaskAnalysisMark`
34
+ read and write them. The follow-ups skill uses them. New permission key `tasks.acknowledge`.
35
+ A read again only updates and closes tasks: `tasks add` refuses a message the chat's point already passed
36
+ (`task.already-read`) unless given `--open-new`; a message that already has a task is returned as before.
14
37
  - **`review` for mail CLIs** (`mailReadCommands`): every stored email, both sides, in threads that changed since a
15
38
  point, in messenger `review`'s item shape plus `email` (people, labels, sender score and the marks `bulk`,
16
39
  `list_id`, `rule_id`); `--new` keeps a point per mailbox, `--unanswered [duration]` keeps threads whose newest email
@@ -21,6 +44,21 @@ shape of this file.
21
44
  `email` gains optional `bulk`, `list_id` and `rule_id`, and its `summary` view may carry `email`.
22
45
  - **`tasks agenda` over an email account fills `owed`, `waiting` and `stale`** from its threads, with the same rule
23
46
  of whose turn a thread is as mail `review`; before, they were empty and `stale` true.
47
+ - **`config repair` mends a settings file whose JSON is broken only in its syntax**, without AI or network (the file
48
+ may hold keys). It fixes a byte-order mark, `//` and `/* */` comments, trailing commas, a missing comma between
49
+ entries on separate lines, unquoted keys, single-quoted text, and closing brackets missing at the end. Every value,
50
+ permissions included, stays as written, in the file's key order. The old file is kept beside it as
51
+ `config.json.bak-<time>` in its old mode, and the new one is written atomically at 0600. It refuses damage with
52
+ more than one reading (two entries on one line, a key twice, a bare word as a value, a file that stops after a
53
+ closing bracket or inside a value, text that is not JSON or not text) with `configuration_error` and the line and
54
+ column, and changes nothing. It prints the line and kind of each fix, never a value; `--dry-run` writes nothing.
55
+ `--json` gives `file`, `found`, `repaired`, `dry_run`, `changes` (`{ line, kind }`), `backup`, and for a file that
56
+ needs nothing `reason` (`nothing to repair`, `no settings file`); a refusal is the error reason `config.unrepairable`
57
+ with `details.file`, `details.line` and `details.column`.
58
+ It changes what a later run may do, so the read-only lock refuses it and an MCP server runs it only with
59
+ `mcp.authority`; an agent's shell may not repair a file cut off before its end. `repairConfigText` exports it.
60
+ - **`doctor` offers `<tool> config repair` as the `fix` of an `invalid_json` issue** when the repair would succeed, so
61
+ the desktop can show a Repair button; a command refused on such a file names it too.
24
62
 
25
63
  ### Fixed
26
64
 
@@ -10,13 +10,13 @@ export declare const calendarTools: ({
10
10
  inputSchema: {
11
11
  type: string;
12
12
  properties: {
13
+ limit?: undefined;
13
14
  account_id: {
14
15
  type: string;
15
16
  minimum: number;
16
17
  };
17
18
  query?: undefined;
18
19
  timezone?: undefined;
19
- limit?: undefined;
20
20
  page?: undefined;
21
21
  event?: undefined;
22
22
  } | {
@@ -45,9 +45,9 @@ export declare const calendarTools: ({
45
45
  };
46
46
  event?: undefined;
47
47
  } | {
48
+ limit?: undefined;
48
49
  query?: undefined;
49
50
  timezone?: undefined;
50
- limit?: undefined;
51
51
  page?: undefined;
52
52
  event: {
53
53
  type: string;
@@ -58,9 +58,9 @@ export declare const calendarTools: ({
58
58
  minimum: number;
59
59
  };
60
60
  } | {
61
+ limit?: undefined;
61
62
  query?: undefined;
62
63
  timezone?: undefined;
63
- limit?: undefined;
64
64
  page?: undefined;
65
65
  event?: undefined;
66
66
  account_id?: undefined;
@@ -80,13 +80,13 @@ export declare const calendarTools: ({
80
80
  inputSchema: {
81
81
  type: string;
82
82
  properties: {
83
+ limit?: undefined;
83
84
  account_id: {
84
85
  type: string;
85
86
  minimum: number;
86
87
  };
87
88
  query?: undefined;
88
89
  timezone?: undefined;
89
- limit?: undefined;
90
90
  page?: undefined;
91
91
  event?: undefined;
92
92
  } | {
@@ -115,9 +115,9 @@ export declare const calendarTools: ({
115
115
  };
116
116
  event?: undefined;
117
117
  } | {
118
+ limit?: undefined;
118
119
  query?: undefined;
119
120
  timezone?: undefined;
120
- limit?: undefined;
121
121
  page?: undefined;
122
122
  event: {
123
123
  type: string;
@@ -128,9 +128,9 @@ export declare const calendarTools: ({
128
128
  minimum: number;
129
129
  };
130
130
  } | {
131
+ limit?: undefined;
131
132
  query?: undefined;
132
133
  timezone?: undefined;
133
- limit?: undefined;
134
134
  page?: undefined;
135
135
  event?: undefined;
136
136
  account_id?: undefined;
@@ -150,13 +150,13 @@ export declare const calendarTools: ({
150
150
  inputSchema: {
151
151
  type: string;
152
152
  properties: {
153
+ limit?: undefined;
153
154
  account_id: {
154
155
  type: string;
155
156
  minimum: number;
156
157
  };
157
158
  query?: undefined;
158
159
  timezone?: undefined;
159
- limit?: undefined;
160
160
  page?: undefined;
161
161
  event?: undefined;
162
162
  } | {
@@ -185,9 +185,9 @@ export declare const calendarTools: ({
185
185
  };
186
186
  event?: undefined;
187
187
  } | {
188
+ limit?: undefined;
188
189
  query?: undefined;
189
190
  timezone?: undefined;
190
- limit?: undefined;
191
191
  page?: undefined;
192
192
  event: {
193
193
  type: string;
@@ -198,9 +198,9 @@ export declare const calendarTools: ({
198
198
  minimum: number;
199
199
  };
200
200
  } | {
201
+ limit?: undefined;
201
202
  query?: undefined;
202
203
  timezone?: undefined;
203
- limit?: undefined;
204
204
  page?: undefined;
205
205
  event?: undefined;
206
206
  account_id?: undefined;
@@ -220,13 +220,13 @@ export declare const calendarTools: ({
220
220
  inputSchema: {
221
221
  type: string;
222
222
  properties: {
223
+ limit?: undefined;
223
224
  account_id: {
224
225
  type: string;
225
226
  minimum: number;
226
227
  };
227
228
  query?: undefined;
228
229
  timezone?: undefined;
229
- limit?: undefined;
230
230
  page?: undefined;
231
231
  event?: undefined;
232
232
  } | {
@@ -255,9 +255,9 @@ export declare const calendarTools: ({
255
255
  };
256
256
  event?: undefined;
257
257
  } | {
258
+ limit?: undefined;
258
259
  query?: undefined;
259
260
  timezone?: undefined;
260
- limit?: undefined;
261
261
  page?: undefined;
262
262
  event: {
263
263
  type: string;
@@ -268,9 +268,9 @@ export declare const calendarTools: ({
268
268
  minimum: number;
269
269
  };
270
270
  } | {
271
+ limit?: undefined;
271
272
  query?: undefined;
272
273
  timezone?: undefined;
273
- limit?: undefined;
274
274
  page?: undefined;
275
275
  event?: undefined;
276
276
  account_id?: undefined;
@@ -1,13 +1,17 @@
1
- import { CliError, pathsAreOverridden } from "@wirecat/cli-base";
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { CliError, pathsAreOverridden, writeSecurely } from "@wirecat/cli-base";
2
3
  import { annotate } from "@wirecat/cli-base/commands";
3
4
  import { Command, Option } from "commander";
4
5
  import { envName } from "../ports/app.js";
5
6
  import { agentOf } from "../runs/run-context.js";
6
7
  import { AUTHORITY_KEY, BOT_API_KEY, LEVELS, levelFor } from "../sends/permissions.js";
8
+ import { fixVerb } from "./config-issues.js";
9
+ import { REPAIR_WORDS, repairConfigText } from "./config-repair.js";
7
10
  import { baseContext, environmentOf } from "./context.js";
8
11
  import { grantsAuthority, LOCAL_READ, LOCAL_WRITE } from "./effects.js";
9
12
  import { itemOf, renderAnswer } from "./envelope.js";
10
13
  import { knownBeside, knownPermissionKeys, unknownPermissionIssues, } from "./permission-keys.js";
14
+ import { profileFree } from "./profile.js";
11
15
  import { fromFile, NONE_NAMED } from "./settings.js";
12
16
  import { nextStep } from "./steps.js";
13
17
  import { changeStoreSetting, isStoreSetting, offersStoreSetting, storeSettings, storeSettingsOf, } from "./store-settings.js";
@@ -51,7 +55,7 @@ export const configCommand = (app, config, { permissionKey = config.permissions,
51
55
  ...(settings.configIssues ?? []),
52
56
  ...unknownPermissionIssues(this, settings, app.command, permissionKey),
53
57
  ])
54
- renderer.warn(`${issue.what}, so it is left out${issue.fix ? ` — \`${issue.fix}\` removes it` : ""}`);
58
+ renderer.warn(`${issue.what}, so it is left out${issue.fix ? ` — \`${issue.fix}\` ${fixVerb(issue)}s it` : ""}`);
55
59
  if (overridden) {
56
60
  renderer.note(`${envName(app, "CONFIG_DIR")}, ${envName(app, "STATE_DIR")} or ${envName(app, "CACHE_DIR")} is set — ` +
57
61
  "a session is still read from the usual keyring entry, but a sign-in made with them is kept apart, in a " +
@@ -140,8 +144,66 @@ export const configCommand = (app, config, { permissionKey = config.permissions,
140
144
  }));
141
145
  });
142
146
  }
147
+ command.addCommand(repairCommand(app, config));
143
148
  return command;
144
149
  };
150
+ /**
151
+ * Mends JSON syntax with one reading and keeps every value (NEED-2178): no network, no AI, since the file may hold
152
+ * keys. Anything else is refused with nothing changed.
153
+ */
154
+ const repairCommand = (app, config) => profileFree(grantsAuthority(annotate(new Command("repair"), {
155
+ effects: LOCAL_WRITE,
156
+ examples: [`${app.command} config repair`, `${app.command} config repair --dry-run`],
157
+ }), { unless: "dryRun" }))
158
+ .summary("fix the settings file's JSON syntax, keeping every value")
159
+ .description("fix broken JSON syntax in the settings file — comments, a missing or extra comma, unquoted keys, single " +
160
+ "quotes, missing closing brackets at the end — keeping every value, with a backup beside it; refuses " +
161
+ "anything with more than one reading and changes nothing then")
162
+ .option("--dry-run", "say what it would fix, and change nothing")
163
+ .action(function () {
164
+ const dryRun = this.opts().dryRun === true;
165
+ const context = baseContext(this, (flags, options) => config.resolveSettings(flags, { ...options, readOnly: true, needsProfile: false, withIssues: true }));
166
+ const { settings, renderer, env } = context;
167
+ const file = settings.configPath;
168
+ // The desktop reads these names (wirecat-desktop#83).
169
+ const answer = (fields) => renderAnswer(this, context, itemOf({ file, found: existsSync(file), dry_run: dryRun, changes: [], backup: null, ...fields }));
170
+ if (!existsSync(file)) {
171
+ answer({ repaired: false, reason: "no settings file" });
172
+ return;
173
+ }
174
+ const bytes = readFileSync(file);
175
+ const result = repairConfigText(bytes.toString("utf8"));
176
+ if (result.state === "valid") {
177
+ answer({ repaired: false, reason: "nothing to repair" });
178
+ return;
179
+ }
180
+ if (result.state === "refused")
181
+ throw new CliError("configuration_error", `${file}: cannot repair it safely — ${result.why}, at line ${result.line}, column ${result.column}; ` +
182
+ "nothing was changed, so fix that part by hand", { reason: "config.unrepairable", file, line: result.line, column: result.column });
183
+ const cut = result.fixes.some((one) => one.kind === "missing_closer");
184
+ // What came after the cut may have been a deny; only the owner decides the shorter file is what they meant.
185
+ if (cut && !dryRun && (environmentOf(this).agent || agentOf(env)))
186
+ throw new CliError("permission_error", `${file} stops before its end, and a setting after the cut may be lost — the owner repairs it in a terminal`);
187
+ let backup = null;
188
+ if (!dryRun) {
189
+ backup = backupPathFor(file);
190
+ writeSecurely(backup, bytes, statSync(file).mode & 0o777);
191
+ writeSecurely(file, result.text, 0o600);
192
+ }
193
+ answer({ repaired: !dryRun, changes: result.fixes, backup });
194
+ for (const one of result.fixes)
195
+ renderer.note(`line ${one.line}: ${dryRun ? "would fix" : "fixed"} ${REPAIR_WORDS[one.kind]}`);
196
+ if (backup)
197
+ renderer.note(`the old file is kept as ${backup}`);
198
+ });
199
+ /** Beside the file, so the owner finds it; a second repair in the same millisecond never replaces the first. */
200
+ const backupPathFor = (file) => {
201
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
202
+ let path = `${file}.bak-${stamp}`;
203
+ for (let n = 2; existsSync(path); n++)
204
+ path = `${file}.bak-${stamp}-${n}`;
205
+ return path;
206
+ };
145
207
  /**
146
208
  * A key no command or write is checked against would be saved and do nothing (BUG-137) — whether it
147
209
  * comes as `permissions.<key>` or inside a whole `permissions` object.
@@ -63,6 +63,8 @@ export declare const withoutSetting: (raw: unknown, setting: string) => {
63
63
  };
64
64
  /** The public shape: where in the file is the loader's own business. */
65
65
  export declare const publicIssue: ({ at: _, ...issue }: LocatedIssue) => ConfigIssue;
66
+ /** What an issue's `fix` does to it: `config repair` mends the file, `config unset` removes the setting. */
67
+ export declare const fixVerb: (issue: ConfigIssue) => string;
66
68
  /**
67
69
  * One line for a command that refuses to run on the file: the first problem, how to fix it, and how many more.
68
70
  * `doctor` is the command that lists them all, as the reader types it.
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import * as v from "valibot";
3
+ import { repairConfigText } from "./config-repair.js";
3
4
  const isNode = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4
5
  const SECTIONS = ["personal", "bot"];
5
6
  const SHELL_SAFE = /^[A-Za-z0-9_.:@-]*$/;
@@ -295,6 +296,8 @@ export const withoutPath = (raw, at) => {
295
296
  export const withoutSetting = (raw, setting) => withoutPath(raw, settingPathOf(setting));
296
297
  /** The public shape: where in the file is the loader's own business. */
297
298
  export const publicIssue = ({ at: _, ...issue }) => issue;
299
+ /** What an issue's `fix` does to it: `config repair` mends the file, `config unset` removes the setting. */
300
+ export const fixVerb = (issue) => (issue.kind === "invalid_json" ? "repair" : "remove");
298
301
  /**
299
302
  * One line for a command that refuses to run on the file: the first problem, how to fix it, and how many more.
300
303
  * `doctor` is the command that lists them all, as the reader types it.
@@ -305,7 +308,7 @@ export const issueMessage = (issues, doctor = "doctor") => {
305
308
  return "";
306
309
  const more = issues.length - 1;
307
310
  return (`${first.file}: ${first.what}` +
308
- (first.fix ? ` — remove it with \`${first.fix}\`` : "") +
311
+ (first.fix ? ` — ${fixVerb(first)} it with \`${first.fix}\`` : "") +
309
312
  (more === 0 ? "" : ` (and ${more} more problem${more === 1 ? "" : "s"} — \`${doctor}\` lists them all)`));
310
313
  };
311
314
  /**
@@ -316,8 +319,13 @@ export const issueMessage = (issues, doctor = "doctor") => {
316
319
  */
317
320
  export const checkConfigText = (text, { file, tool, schema, knownPermissions, }) => {
318
321
  const parsed = parseConfigText(text, file);
319
- if ("issue" in parsed)
320
- return { issues: [publicIssue(parsed.issue)], config: undefined };
322
+ if ("issue" in parsed) {
323
+ const issue = publicIssue(parsed.issue);
324
+ // Offered only where it would work, so the desktop never shows a Repair button that fails.
325
+ if (repairConfigText(text).state === "repaired")
326
+ issue.fix = `${tool} config repair`;
327
+ return { issues: [issue], config: undefined };
328
+ }
321
329
  // valibot reads a list as an object, so `[]` would pass for `{}`.
322
330
  if (!isNode(parsed.raw))
323
331
  return { issues: [wholeFile(file, "the file must hold a JSON object")], config: undefined };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Repairs a settings file whose JSON is broken only in its syntax, without AI and without guessing (NEED-2178): a
3
+ * file can hold keys and tokens, so it never leaves the machine. A damage with one reading is fixed; anything else
4
+ * is refused, and the caller changes nothing.
5
+ */
6
+ export type RepairKind = "byte_order_mark" | "comment" | "trailing_comma" | "missing_comma" | "unquoted_key" | "single_quotes" | "missing_closer";
7
+ /** Where and what, never the text: a value may be a secret. */
8
+ export interface RepairFix {
9
+ line: number;
10
+ kind: RepairKind;
11
+ }
12
+ export type RepairResult = {
13
+ state: "valid";
14
+ } | {
15
+ state: "repaired";
16
+ text: string;
17
+ fixes: RepairFix[];
18
+ } | {
19
+ state: "refused";
20
+ line: number;
21
+ column: number;
22
+ why: string;
23
+ };
24
+ /** What each fix mends, as a person reads it. */
25
+ export declare const REPAIR_WORDS: Record<RepairKind, string>;
26
+ export declare const repairConfigText: (text: string) => RepairResult;
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Repairs a settings file whose JSON is broken only in its syntax, without AI and without guessing (NEED-2178): a
3
+ * file can hold keys and tokens, so it never leaves the machine. A damage with one reading is fixed; anything else
4
+ * is refused, and the caller changes nothing.
5
+ */
6
+ /** What each fix mends, as a person reads it. */
7
+ export const REPAIR_WORDS = {
8
+ byte_order_mark: "an invisible byte-order mark at the start",
9
+ comment: "a comment, which JSON does not allow",
10
+ trailing_comma: "a comma before a closing bracket",
11
+ missing_comma: "a missing comma",
12
+ unquoted_key: "a key without double quotes",
13
+ single_quotes: "text in single quotes",
14
+ missing_closer: "the file stops before its closing brackets — whatever followed this line was already lost",
15
+ };
16
+ class GiveUp {
17
+ at;
18
+ why;
19
+ constructor(at, why) {
20
+ this.at = at;
21
+ this.why = why;
22
+ }
23
+ }
24
+ const NUMBER = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/;
25
+ const LITERAL = /^(true|false|null)/;
26
+ const KEY = /^[A-Za-z_$][\w$.-]*/;
27
+ const ENDS_VALUE = /^[\s,\]}/]?$/;
28
+ export const repairConfigText = (text) => {
29
+ try {
30
+ JSON.parse(text);
31
+ return { state: "valid" };
32
+ }
33
+ catch { }
34
+ const fixes = [];
35
+ const lineAt = (offset) => text.slice(0, offset).split("\n").length;
36
+ const fix = (kind, offset) => fixes.push({ kind, line: lineAt(offset) });
37
+ const giveUp = (why, offset = at) => {
38
+ throw new GiveUp(offset, why);
39
+ };
40
+ let at = 0;
41
+ let truncated = false;
42
+ let last = "open";
43
+ let lastEnd = 0;
44
+ // A nested value() moves it, which TypeScript's narrowing inside the loop cannot see.
45
+ const previous = () => last;
46
+ const skip = () => {
47
+ for (;;) {
48
+ while (/\s/.test(text[at] ?? ""))
49
+ at++;
50
+ if (text.startsWith("//", at)) {
51
+ fix("comment", at);
52
+ while (at < text.length && text[at] !== "\n")
53
+ at++;
54
+ }
55
+ else if (text.startsWith("/*", at)) {
56
+ const end = text.indexOf("*/", at + 2);
57
+ if (end < 0)
58
+ giveUp("a /* comment is never closed");
59
+ fix("comment", at);
60
+ at = end + 2;
61
+ }
62
+ else
63
+ return;
64
+ }
65
+ };
66
+ const string = () => {
67
+ const quote = text[at];
68
+ const start = at;
69
+ if (quote === "'")
70
+ fix("single_quotes", at);
71
+ at++;
72
+ let body = "";
73
+ for (;;) {
74
+ if (at >= text.length)
75
+ giveUp("the file stops inside a quoted text", start);
76
+ const char = text[at];
77
+ if (char === quote)
78
+ break;
79
+ if (text.charCodeAt(at) < 0x20)
80
+ giveUp("a line break inside a quoted text");
81
+ if (char === "\\") {
82
+ const next = text[at + 1] ?? "";
83
+ if (quote === "'" && next === "'")
84
+ body += "'";
85
+ else if (/^["\\/bfnrt]$/.test(next))
86
+ body += `\\${next}`;
87
+ else if (next === "u" && /^[0-9a-fA-F]{4}$/.test(text.slice(at + 2, at + 6))) {
88
+ body += text.slice(at, at + 6);
89
+ at += 4;
90
+ }
91
+ else
92
+ giveUp("an escape JSON has no meaning for");
93
+ at += 2;
94
+ continue;
95
+ }
96
+ body += char === '"' ? '\\"' : char;
97
+ at++;
98
+ }
99
+ at++;
100
+ return JSON.parse(`"${body}"`);
101
+ };
102
+ const key = () => {
103
+ const char = text[at] ?? "";
104
+ if (char === '"' || char === "'")
105
+ return string();
106
+ const word = KEY.exec(text.slice(at))?.[0];
107
+ if (!word)
108
+ return giveUp("something other than a key where a key should be");
109
+ fix("unquoted_key", at);
110
+ at += word.length;
111
+ return word;
112
+ };
113
+ const scalar = () => {
114
+ const char = text[at] ?? "";
115
+ if (char === '"' || char === "'")
116
+ return { json: JSON.stringify(string()) };
117
+ const word = (NUMBER.exec(text.slice(at)) ?? LITERAL.exec(text.slice(at)))?.[0];
118
+ if (!word || !ENDS_VALUE.test(text[at + word.length] ?? ""))
119
+ return giveUp("a value that is not JSON — text needs quotes, and only true, false and null go without");
120
+ // A number the file stops on may be the start of a longer one.
121
+ if (at + word.length === text.length && NUMBER.test(word))
122
+ giveUp("the file stops inside a number");
123
+ at += word.length;
124
+ return { json: word };
125
+ };
126
+ /** Members of an object or items of a list, with every syntax fix that has one reading. */
127
+ const members = (close, one) => {
128
+ const out = [];
129
+ at++;
130
+ last = "open";
131
+ let comma = -1;
132
+ for (;;) {
133
+ skip();
134
+ if (truncated)
135
+ return out;
136
+ if (at >= text.length) {
137
+ if (previous() === "close")
138
+ giveUp("the file stops after a closing bracket, so which brackets are missing has two readings");
139
+ if (previous() === "comma")
140
+ fix("trailing_comma", comma);
141
+ fix("missing_closer", text.length);
142
+ truncated = true;
143
+ return out;
144
+ }
145
+ const char = text[at];
146
+ if (char === close) {
147
+ if (previous() === "comma")
148
+ fix("trailing_comma", comma);
149
+ at++;
150
+ last = "close";
151
+ return out;
152
+ }
153
+ if (char === "}" || char === "]")
154
+ giveUp(`a ${char} where ${close} should close`);
155
+ if (char === ",") {
156
+ if (previous() !== "value" && previous() !== "close")
157
+ giveUp("a comma with nothing before it");
158
+ comma = at;
159
+ at++;
160
+ last = "comma";
161
+ continue;
162
+ }
163
+ if (previous() === "value" || previous() === "close") {
164
+ if (lineAt(at) === lineAt(lastEnd))
165
+ giveUp("two entries on one line with no comma between them");
166
+ fix("missing_comma", lastEnd);
167
+ }
168
+ out.push(one());
169
+ }
170
+ };
171
+ const value = () => {
172
+ const char = text[at] ?? "";
173
+ let node;
174
+ if (char === "{") {
175
+ const seen = new Set();
176
+ node = {
177
+ object: members("}", () => {
178
+ const start = at;
179
+ const name = key();
180
+ // JSON.parse keeps the last of two, which could lift a deny written first.
181
+ if (seen.has(name))
182
+ giveUp("a key appears twice in one object", start);
183
+ seen.add(name);
184
+ skip();
185
+ if (at >= text.length)
186
+ giveUp("the file stops after a key, before its value");
187
+ if (text[at] !== ":")
188
+ giveUp("a key with no colon after it");
189
+ at++;
190
+ skip();
191
+ if (at >= text.length)
192
+ giveUp("the file stops after a key, before its value");
193
+ return [name, value()];
194
+ }),
195
+ };
196
+ }
197
+ else if (char === "[") {
198
+ node = {
199
+ array: members("]", () => {
200
+ return value();
201
+ }),
202
+ };
203
+ }
204
+ else
205
+ node = scalar();
206
+ if (!truncated) {
207
+ last = "json" in node ? "value" : "close";
208
+ lastEnd = at;
209
+ }
210
+ return node;
211
+ };
212
+ try {
213
+ if (text.includes("\u0000") || text.includes(String.fromCharCode(0xfffd)))
214
+ giveUp("the file is not text", 0);
215
+ if (text.charCodeAt(0) === 0xfeff) {
216
+ fix("byte_order_mark", 0);
217
+ at = 1;
218
+ }
219
+ skip();
220
+ if (text[at] !== "{")
221
+ giveUp(at >= text.length ? "the file is empty" : "the file does not start with {");
222
+ const root = value();
223
+ skip();
224
+ if (at < text.length)
225
+ giveUp("more text after the closing }");
226
+ const repaired = `${serialise(root, "")}\n`;
227
+ JSON.parse(repaired);
228
+ return { state: "repaired", text: repaired, fixes };
229
+ }
230
+ catch (error) {
231
+ if (!(error instanceof GiveUp))
232
+ throw error;
233
+ const before = text.slice(0, error.at).split("\n");
234
+ return {
235
+ state: "refused",
236
+ line: before.length,
237
+ column: (before.at(-1)?.length ?? 0) + 1,
238
+ why: error.why,
239
+ };
240
+ }
241
+ };
242
+ /** `JSON.stringify(value, null, 2)` as `saveConfigFile` writes it, but in the file's own key order and numbers. */
243
+ const serialise = (node, indent) => {
244
+ if ("json" in node)
245
+ return node.json;
246
+ const inner = `${indent} `;
247
+ const parts = "object" in node
248
+ ? node.object.map(([name, item]) => `${inner}${JSON.stringify(name)}: ${serialise(item, inner)}`)
249
+ : node.array.map((item) => `${inner}${serialise(item, inner)}`);
250
+ const [open, close] = "object" in node ? ["{", "}"] : ["[", "]"];
251
+ return parts.length === 0 ? `${open}${close}` : `${open}\n${parts.join(",\n")}\n${indent}${close}`;
252
+ };
@@ -3,9 +3,9 @@ import type { CacheDatabase } from "../../store/driver.js";
3
3
  import type { Messenger } from "./context.js";
4
4
  /** `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. */
5
5
  export declare const storeSummary: (env: NodeJS.ProcessEnv) => Promise<{
6
+ error?: undefined;
6
7
  path: string;
7
8
  exists: boolean;
8
- error?: undefined;
9
9
  } | {
10
10
  path: string;
11
11
  exists: boolean;
@@ -0,0 +1,8 @@
1
+ import type { Command } from "commander";
2
+ import { type Messenger } from "./context.js";
3
+ /**
4
+ * Each chat's own point of reading for tasks (`../../services/task-analysis.ts`): how far it was read, what waits, and
5
+ * the step that moves it. Apart from `inbox --new`, `review --new` and the messenger's read state, which other
6
+ * readers move.
7
+ */
8
+ export declare const analysisCommands: (tasks: Command, messenger: Messenger) => void;