clearotron 0.3.3 → 0.4.0-beta.1

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 (142) hide show
  1. package/.env.example +9 -0
  2. package/INSTALL.md +1 -14
  3. package/bin/brandowner.mjs +5 -5
  4. package/bin/connect.mjs +4 -4
  5. package/bin/onboard.mjs +5 -7
  6. package/bin/start.mjs +20 -5
  7. package/bin/update.mjs +6 -1
  8. package/build-info.json +2 -2
  9. package/docs/architecture/04-configuration-reference.md +2 -6
  10. package/driver/CHANGELOG.md +28 -0
  11. package/driver/ask-ledger.mjs +2 -2
  12. package/driver/band-shape.mjs +8 -8
  13. package/driver/blind-frame-model.mjs +1 -1
  14. package/driver/common-law-receipts.mjs +2 -2
  15. package/driver/commonlaw-carry.mjs +2 -2
  16. package/driver/company-bundle.mjs +11 -18
  17. package/driver/connotation-search.mjs +4 -4
  18. package/driver/contract-e3-backlog.mjs +3 -3
  19. package/driver/declination-call.mjs +1 -1
  20. package/driver/declination-tool.mjs +1 -1
  21. package/driver/dev-portal.mjs +1 -1
  22. package/driver/door-call-verdict.mjs +27 -0
  23. package/driver/driver.config.mjs +17 -11
  24. package/driver/e2e/README.md +1 -1
  25. package/driver/engine/mcp/clarivate-server.mjs +2 -1
  26. package/driver/engine/mcp/corsearch-server.mjs +1 -0
  27. package/driver/engine/mcp/euipo-server.mjs +1 -0
  28. package/driver/engine/mcp/free-tier-server.mjs +1 -1
  29. package/driver/engine/mcp/gather-config.mjs +1 -1
  30. package/driver/engine/mcp/perplexity-server.mjs +1 -1
  31. package/driver/engine/mcp/recording-server.mjs +1 -1
  32. package/driver/engine/mcp/signa-server.mjs +1 -0
  33. package/driver/engine/mcp/supplemental.mjs +1 -1
  34. package/driver/engine/mcp/uspto-local-server.mjs +1 -0
  35. package/driver/enqueue-schema.mjs +2 -2
  36. package/driver/feedback-issues.mjs +1 -1
  37. package/driver/feedback-store.mjs +1 -1
  38. package/driver/findings-model.mjs +3 -3
  39. package/driver/flag-snapshot.mjs +1 -1
  40. package/driver/floor-duty.mjs +2 -2
  41. package/driver/form-neighbourhood.mjs +47 -15
  42. package/driver/frame-diff-model.mjs +3 -3
  43. package/driver/gateway.mjs +5 -5
  44. package/driver/jx-lanes.mjs +1 -1
  45. package/driver/known-conflicts.mjs +18 -0
  46. package/driver/log.mjs +2 -2
  47. package/driver/package.json +1 -1
  48. package/driver/pipeline-knockout.mjs +129 -95
  49. package/driver/pipeline.mjs +69 -32
  50. package/driver/placement-carry.mjs +2 -2
  51. package/driver/placement-form.mjs +1 -1
  52. package/driver/portal-mcp-client.mjs +1 -1
  53. package/driver/portal-request-origin.mjs +79 -0
  54. package/driver/portal-service.mjs +45 -17
  55. package/driver/predelivery-lint.mjs +10 -10
  56. package/driver/profile-page.html +9 -13
  57. package/driver/profile-service.mjs +25 -13
  58. package/driver/profiles.mjs +17 -4
  59. package/driver/progress.mjs +1 -1
  60. package/driver/provider-usage.mjs +24 -1
  61. package/driver/publish/index.mjs +17 -5
  62. package/driver/publish/knockout.mjs +3 -2
  63. package/driver/publish/render-knockout.mjs +1 -1
  64. package/driver/publish/render.mjs +14 -3
  65. package/driver/publish/search-depth.mjs +4 -2
  66. package/driver/recall-reconciliation.mjs +1 -1
  67. package/driver/record-carry.mjs +6 -6
  68. package/driver/recording-agreement.mjs +2 -2
  69. package/driver/reference-score.mjs +27 -27
  70. package/driver/register-count.mjs +56 -1
  71. package/driver/register-digest-record.mjs +1 -1
  72. package/driver/register-plan.mjs +5 -5
  73. package/driver/register-records.mjs +10 -1
  74. package/driver/registry-fidelity.mjs +4 -4
  75. package/driver/repair-composers.mjs +6 -6
  76. package/driver/run-economics.mjs +8 -19
  77. package/driver/screen-gate.mjs +1 -1
  78. package/driver/skills/blind-frame/SKILL.md +2 -2
  79. package/driver/skills/clearance-common-law/SKILL.md +2 -2
  80. package/driver/skills/clearance-common-law/perplexity-prompts.md +4 -4
  81. package/driver/skills/clearance-register/digest.md +1 -1
  82. package/driver/skills/clearance-register/unit.md +1 -1
  83. package/driver/skills/clearance-search/report-prose.md +5 -5
  84. package/driver/skills/clearance-search/synthesis-rules.md +4 -4
  85. package/driver/skills/clearance-variants/SKILL.md +2 -2
  86. package/driver/skills/clearance-variants/transliteration-scripts.md +1 -1
  87. package/driver/skills/frame-diff/SKILL.md +2 -2
  88. package/driver/skills/knockout-assess/SKILL.md +9 -9
  89. package/driver/skills/matter-frame/watchlist-reference.md +1 -1
  90. package/driver/skills/narrative-refutation/SKILL.md +2 -2
  91. package/driver/skills/placement-inquiry/SKILL.md +1 -1
  92. package/driver/stage-context.mjs +4 -4
  93. package/driver/stages.mjs +23 -15
  94. package/driver/suite-census.json +138 -42
  95. package/driver/systemd/clearotron-client-mcp.service +24 -0
  96. package/driver/systemd/clearotron-mcp-face.service +24 -0
  97. package/driver/systemd/clearotron-portal.service +24 -0
  98. package/driver/systemd/clearotron-worker.service +24 -0
  99. package/driver/tokens.mjs +26 -17
  100. package/driver/turnaround-bands.mjs +1 -1
  101. package/driver/unit-inventory.mjs +3 -3
  102. package/driver/variant-manifest-model.mjs +1 -1
  103. package/driver/verify.mjs +2 -2
  104. package/driver/whatif-memo-run.mjs +1 -1
  105. package/mcp-server/CHANGELOG.md +8 -0
  106. package/mcp-server/lib/audit.mjs +9 -2
  107. package/mcp-server/lib/http-handler.mjs +7 -3
  108. package/mcp-server/mint-token.mjs +8 -6
  109. package/mcp-server/package.json +1 -1
  110. package/mcp-server/server.mjs +11 -1
  111. package/package.json +1 -1
  112. package/portal-ui/dist/assets/{index-GBbbyQxc.js → index-D_O_55vK.js} +59 -9
  113. package/portal-ui/dist/index.html +1 -1
  114. package/portal-ui/package.json +1 -1
  115. package/providers/_shared/README.md +1 -1
  116. package/providers/_shared/answer-memory.mjs +199 -0
  117. package/providers/_shared/ledger-path.mjs +1 -1
  118. package/providers/_shared/ledger.mjs +47 -5
  119. package/providers/_shared/script-form.mjs +24 -5
  120. package/providers/_shared/term-shape.mjs +5 -5
  121. package/providers/clarivate/src/capabilities.js +11 -0
  122. package/providers/clarivate/src/core.js +140 -13
  123. package/providers/jx-subclass/lookup.mjs +1 -1
  124. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  125. package/providers/oauth-mcp-bridge/package.json +1 -1
  126. package/providers/signa/src/capabilities.js +24 -0
  127. package/providers/signa/src/core.js +66 -0
  128. package/scripts/deprecate-below.mjs +114 -2
  129. package/scripts/freeze-example-run.mjs +1 -1
  130. package/scripts/live-surface-check.mjs +11 -2
  131. package/scripts/release-entry-catch-up.mjs +211 -0
  132. package/scripts/release-note-required.mjs +102 -6
  133. package/scripts/release-rehearsal-version.mjs +60 -0
  134. package/scripts/release-sbom.mjs +104 -0
  135. package/scripts/release-visible-check.mjs +7 -5
  136. package/scripts/score.mjs +3 -3
  137. package/shared/brand.mjs +1 -1
  138. package/shared/client-door.mjs +15 -8
  139. package/shared/driver-dir.mjs +20 -9
  140. package/shared/names-in-force.mjs +2 -0
  141. package/shared/scope.mjs +25 -10
  142. package/shared/store-in-repo.mjs +38 -17
@@ -40,8 +40,10 @@
40
40
  // a door — which is now the difference between a box that installs and a box that crash-loops one.
41
41
 
42
42
  import { join } from "node:path";
43
+ import { homedir } from "node:os";
43
44
  import { challengeVerdict, blockedByAccessChallenge, challengeNote } from "./mcp-challenge.mjs"; // — F57
44
45
  import { whatHoldsPort } from "./os-advice.mjs";
46
+ import { defaultDenylistPath } from "./scope.mjs"; // — the verifier owns the path; see the re-export below
45
47
 
46
48
  /** The unit that runs the client door. Installed like any other; started only by this module. */
47
49
  export const CLIENT_DOOR_UNIT = "clearotron-client-mcp.service";
@@ -59,7 +61,9 @@ export const CLIENT_DOOR_UNIT = "clearotron-client-mcp.service";
59
61
  * file no verifier could read, and a revoked key kept answering 200. The guard existed; one of the two
60
62
  * doors was outside it.
61
63
  */
62
- export const defaultDenylistPath = (home) => join(home, ".config", "clearotron", "token-denylist");
64
+ // ONE definition, owned by the verifier (scope.mjs): the file a door reads when the setting is unset is,
65
+ // by construction, the file every revoker here writes.
66
+ export { defaultDenylistPath } from "./scope.mjs";
63
67
 
64
68
  /**
65
69
  * The denylist path a door should be given — the operator's, if they set one.
@@ -617,9 +621,10 @@ export function enablePlan({ env = {}, address, identity, accessFile = null, por
617
621
  // ── THE DENYLIST PATH IS NAMED AND CREATED HERE, NEVER ASSUMED ───────────
618
622
  //
619
623
  // Measured on production, owner 2026-08-31: no denylist is configured anywhere — the variable is
620
- // empty in one example env and commented out in the other, and `isRevoked()` returns false when the
621
- // path is unset. So a key minted by a connect that assumed a denylist would be UNREVOKABLE, silently:
622
- // `disconnect` would write a jti into a file no verifier reads, and every check would look done.
624
+ // empty in one example env and commented out in the other, and `isRevoked()` then returned false for
625
+ // every token. So a key minted by a connect that assumed a denylist was UNREVOKABLE, silently:
626
+ // `disconnect` wrote a jti into a file no verifier read, and every check looked done. `isRevoked()`
627
+ // now reads the default list when the path is unset; naming it here still pins every door to one file.
623
628
  //
624
629
  // Armed AT CONNECT TIME, deliberately, not at disconnect: a process reads its environment once, at
625
630
  // start. A variable first written when someone disconnects is one the already-running door never
@@ -917,7 +922,7 @@ export function connectKeyReport(grants, { now = Date.now(), revoked = () => fal
917
922
  * were recorded".
918
923
  */
919
924
  export function disablePlan({ env = {}, unitDir, exists, identity = null, recorded = [],
920
- denylistPath = null } = {}) {
925
+ denylistPath = null, home = homedir() } = {}) {
921
926
  const door = clientDoorState({ env, unitDir, exists });
922
927
  const jtis = (recorded ?? []).map((r) => r.jti).filter(Boolean);
923
928
  // The path the running verifiers were BORN with wins; the caller's default only covers the install
@@ -926,7 +931,9 @@ export function disablePlan({ env = {}, unitDir, exists, identity = null, record
926
931
  // a revocation the environment cannot deliver retroactively.
927
932
  const existing = String(env.TRADEMARK_MCP_TOKEN_DENYLIST ?? "").trim();
928
933
  const armedPath = existing || (jtis.length && denylistPath ? String(denylistPath) : null);
929
- const lateArm = Boolean(jtis.length && !existing && armedPath);
934
+ // NOT LATE WHEN IT IS THE DEFAULT LIST: a door with the setting unset reads the install's default list
935
+ // (isRevoked, shared/scope.mjs), so writing there reaches the running doors now, not from their next start.
936
+ const lateArm = Boolean(jtis.length && !existing && armedPath && armedPath !== defaultDenylistPath(home));
930
937
 
931
938
  if (!jtis.length) {
932
939
  return { possible: false, nothingOpen: true, door,
@@ -965,7 +972,7 @@ export function disablePlan({ env = {}, unitDir, exists, identity = null, record
965
972
  * everything. Stopping the unit would additionally break the stdio-free local route for readers who
966
973
  * hold no key at all, and would be undone by the next install.
967
974
  */
968
- export function revokeEveryonePlan({ env = {}, grants = null, denylistPath = null } = {}) {
975
+ export function revokeEveryonePlan({ env = {}, grants = null, denylistPath = null, home = homedir() } = {}) {
969
976
  const rows = Object.entries(grants?.connectKeys ?? {}).map(([jti, r]) => ({ jti, sub: r?.sub ?? null }));
970
977
  const jtis = rows.map((r) => r.jti);
971
978
  const people = [...new Set(rows.map((r) => r.sub).filter(Boolean))];
@@ -980,7 +987,7 @@ export function revokeEveryonePlan({ env = {}, grants = null, denylistPath = nul
980
987
  return {
981
988
  possible: true, nothingOpen: false, jtis, people,
982
989
  denylistPath: armedPath,
983
- lateArm: Boolean(!existing && armedPath),
990
+ lateArm: Boolean(!existing && armedPath && armedPath !== defaultDenylistPath(home)), // see disablePlan
984
991
  // THE COUNT IS THE FIRST THING SAID, and it names both dimensions: five keys held by one person and
985
992
  // five keys held by five people are the same number and not the same act.
986
993
  says: [`This revokes ${jtis.length} issued key(s) held by ${people.length} ${people.length === 1 ? "person" : "people"}`
@@ -40,15 +40,23 @@
40
40
  //
41
41
  // ── WHAT THIS DELIBERATELY DOES NOT DO ────────────────────────────────────────────────────────────
42
42
  //
43
- // IT DOES NOT SET A MODE, and this change alters no behaviour on disk. Pinning 0o750 here in the same
44
- // commit would be a semantic change riding inside a seam, and the seam is the part that has to be
45
- // reviewable by reading nineteen call sites and agreeing they still mean what they meant. The mode is
46
- // That business, and when it is taken it is one line in this file.
43
+ // ── THE MODE, TAKEN HERE ONCE (2026-09-23) ────────────────────────────────────────────────────────
47
44
  //
48
- // IT WOULD NOT RESTRAIN THE SEAT IF IT DID. The agent runs as the same account that owns these
49
- // directories, so owner bits apply whatever the group bits say. A tighter mode removes GROUP write,
50
- // which is worth having on a box with a shared pool group, and nothing more. Said here because a module
51
- // named for a boundary invites the stronger reading.
45
+ // A new run folder is created 0750: its owner, and the group the deployment reads reports through, and
46
+ // no other account. It used to take whatever the umask gave, 775 on a real run, so on a machine whose
47
+ // home or data folder other accounts can enter, any of them could read client matter. The folder that
48
+ // holds a run is created by the call below (measured by tracing a whole clearance: `ensureDriverDir` is
49
+ // the first write into a new run folder, and the archive is a rename that keeps the mode), so this one
50
+ // line closes the live run and its archived copy. The published copy takes the same constant.
51
+ //
52
+ // A MODE GIVEN TO mkdir, NEVER A chmod. The kernel sets a new folder's set-GID bit from its parent
53
+ // whatever mode is asked for, which is what the set-GID pool root relies on; a chmod by an account
54
+ // outside that group strips the bit silently and every report then answers 403. So nothing here ever
55
+ // changes an existing folder: a folder that already exists keeps the mode it has.
56
+ //
57
+ // IT DOES NOT RESTRAIN THE SEAT. The agent runs as the same account that owns these directories, so owner
58
+ // bits apply whatever the group bits say. Said here because a module named for a boundary invites the
59
+ // stronger reading.
52
60
  //
53
61
  // IT IS NOT `driverDirs`. `scripts/seat-retry-report.mjs` exports `driverDirs(root)`, which WALKS a tree
54
62
  // to find every `_driver` beneath it. Discovery, not construction — a different concern that happens to
@@ -60,6 +68,9 @@ import { join } from "node:path";
60
68
  /** The directory's name. The one place the string lives, so renaming it is an edit rather than a sweep. */
61
69
  export const DRIVER_DIR = "_driver";
62
70
 
71
+ /** The mode a new run folder is created with: owner and group, no other account. See the header. */
72
+ export const RUN_DIR_MODE = 0o750;
73
+
63
74
  /**
64
75
  * The path to a run's `_driver/`, or to something inside it.
65
76
  *
@@ -91,6 +102,6 @@ export function driverRel(...parts) {
91
102
  */
92
103
  export function ensureDriverDir(base, ...parts) {
93
104
  const dir = driverDir(base, ...parts);
94
- mkdirSync(dir, { recursive: true });
105
+ mkdirSync(dir, { recursive: true, mode: RUN_DIR_MODE });
95
106
  return dir;
96
107
  }
@@ -32,6 +32,7 @@ export const NAMES_IN_FORCE = Object.freeze([
32
32
  "CLEAROTRON_BRAND_TAGLINE",
33
33
  "CLEAROTRON_CARD_CONCURRENCY",
34
34
  "CLEAROTRON_CHECKOUT_DIR",
35
+ "CLEAROTRON_CLARIVATE_ANSWER_MEMORY",
35
36
  "CLEAROTRON_CLAUDE_PATH",
36
37
  "CLEAROTRON_CLIENT_MCP_URL",
37
38
  "CLEAROTRON_CLIENT_OIDC_AUDIENCE",
@@ -163,6 +164,7 @@ export const NAMES_IN_FORCE = Object.freeze([
163
164
  "CLEAROTRON_SCREEN_GATE_UNNAMED",
164
165
  "CLEAROTRON_SELFTEST_PATHS_ONLY",
165
166
  "CLEAROTRON_SEND_TOOL_PREFIX",
167
+ "CLEAROTRON_SIGNA_ANSWER_MEMORY",
166
168
  "CLEAROTRON_SKILLS_STORE_MAIN_BRANCH",
167
169
  "CLEAROTRON_SKILLS_STORE_STRICT",
168
170
  "CLEAROTRON_SPAWN_GRACE_MS",
package/shared/scope.mjs CHANGED
@@ -19,6 +19,8 @@
19
19
 
20
20
  import { createHmac, timingSafeEqual, randomBytes } from "node:crypto";
21
21
  import { readFileSync, statSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { homedir } from "node:os";
22
24
  import { envFrom } from "./env-aliases.mjs"; // — resolves EITHER spelling; names the retired one because that is the live-writable half
23
25
 
24
26
  const SECRET = () => process.env.TRADEMARK_MCP_TOKEN_SECRET || "";
@@ -29,16 +31,29 @@ const SECRETS = () => [process.env.TRADEMARK_MCP_TOKEN_SECRET, process.env.TRADE
29
31
  .filter((s) => typeof s === "string" && s.length > 0);
30
32
  const b64u = (buf) => Buffer.from(buf).toString("base64url");
31
33
 
32
- // Emergency revocation: a plain-text denylist file (one `jti` per line, `#` comments), path in
33
- // TRADEMARK_MCP_TOKEN_DENYLIST. Checked on every token verification. Short TTLs remain the primary
34
- // control; a missing/unreadable file means "nothing revoked yet" (the denylist must never be able
35
- // to take ALL token auth down on an fs blip). Legacy tokens carry no jti and are killed by secret
36
- // rotation instead.
37
- export function isRevoked(jti, { denylistPath = process.env.TRADEMARK_MCP_TOKEN_DENYLIST } = {}) {
38
- if (!jti || !denylistPath) return false;
34
+ // Emergency revocation: a plain-text denylist file (one `jti` per line, `#` comments). Checked on every
35
+ // token verification, ALWAYS: the path is TRADEMARK_MCP_TOKEN_DENYLIST when it is set, and otherwise the
36
+ // install's default below, the same file every revoker writes when the setting is unset (connect,
37
+ // disconnect, `key`, mint-token's own note). Short TTLs remain the primary control. Legacy tokens carry
38
+ // no jti and are killed by secret rotation instead.
39
+ //
40
+ // REVOCATION USED TO BE OFF WHEN THE SETTING WAS UNSET: this returned "not revoked" for every token, so a
41
+ // door started without the variable (by hand, or by a unit that never loaded it) ignored a revocation
42
+ // written to the default file. Now:
43
+ // - a NAMED list that cannot be read refuses every token (below), as it already did;
44
+ // - a list that EXISTS and cannot be read refuses every token, named or default;
45
+ // - the DEFAULT list being ABSENT means nothing has been revoked. Every revocation creates the file
46
+ // it writes to, and with the setting unset that is this file, so its absence is the proof, not a
47
+ // gap. It is the one absence read as a negative here, and only for that reason.
48
+ export const defaultDenylistPath = (home) => join(home, ".config", "clearotron", "token-denylist");
49
+ export function isRevoked(jti, { denylistPath = undefined, env = process.env, home = homedir() } = {}) {
50
+ if (!jti) return false;
51
+ const named = String(denylistPath ?? env.TRADEMARK_MCP_TOKEN_DENYLIST ?? "").trim();
52
+ const path = named || defaultDenylistPath(home);
39
53
  let text;
40
- try { text = readFileSync(denylistPath, "utf8"); }
54
+ try { text = readFileSync(path, "utf8"); }
41
55
  catch (e) {
56
+ if (!named && e?.code === "ENOENT") return false; // the default list, never written: nothing revoked
42
57
  // FAIL CLOSED (found in review; ruled and recorded for the owner's
43
58
  // review with the reversal path, which is this function).
44
59
  //
@@ -50,10 +65,10 @@ export function isRevoked(jti, { denylistPath = process.env.TRADEMARK_MCP_TOKEN_
50
65
  // The cost is now a VISIBLE outage that names its own cause, rather than an invisible hole. `start`
51
66
  // creates this file before any door starts, so reaching this branch means someone removed or
52
67
  // unreadable-ified it while the door was running — rare, and worth stopping for.
53
- const err = new Error(`revocation could not be checked: the denylist at ${denylistPath} is unreadable `
68
+ const err = new Error(`revocation could not be checked: the denylist at ${path} is unreadable `
54
69
  + `(${e.code ?? e.message}). Refusing the token rather than assuming it was never revoked.`);
55
70
  err.code = "REVOCATION_UNCHECKABLE";
56
- err.denylistPath = denylistPath;
71
+ err.denylistPath = path;
57
72
  throw err;
58
73
  }
59
74
  return text.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).includes(jti);
@@ -282,6 +282,7 @@ export function commitWithAuditRow({ audit, gitCommit, files, message, by, row }
282
282
  // That makes the failure resumable, and it makes "a concurrent editor's staged work survives" true by
283
283
  // construction rather than by a careful reset that has to guess whose paths are whose.
284
284
  import { execFileSync } from "node:child_process";
285
+ import { BRAND } from "./brand.mjs";
285
286
 
286
287
  // Lock contention is TRANSIENT and worth retrying. A hook rejection and a full disk are not: retrying
287
288
  // them turns one named failure into several and delays the report. The distinction is the reason this
@@ -289,17 +290,39 @@ import { execFileSync } from "node:child_process";
289
290
  export const isTransientGitFault = (detail) =>
290
291
  /index\.lock|another git process seems to be running|Unable to create/i.test(String(detail ?? ""));
291
292
 
293
+ // ── THE PRODUCT NAMES ITS OWN COMMITTER WHEN GIT HAS NONE ───────────────────────────────────────────
294
+ //
295
+ // A save names its author, the person who asked for it. Git also records a COMMITTER, taken from the
296
+ // repository's or the account's configuration, and refuses the commit when it finds none. That is the
297
+ // default state of a service account nobody ran `git config` for, and of a fresh Windows install: every
298
+ // company created in the portal was refused there, with a message telling the person to run git. The
299
+ // person creating a company cannot act on that, and nothing about the save needs them to.
300
+ //
301
+ // So a commit into the store is given the product's own committer whenever git cannot name one, on every
302
+ // commit rather than once: `-c` or an environment variable configures one invocation, not the repository.
303
+ // A store or account that already has an identity keeps it, so a configured install records exactly what
304
+ // it recorded before, signing included.
305
+ export const STORE_COMMITTER = Object.freeze({ name: BRAND.name, email: "store@localhost" });
306
+
307
+ /** The environment a commit into `repoRoot` runs with: `env` itself when git can name a committer, else `env` plus the product's. */
308
+ export function storeCommitEnv(repoRoot, { env = process.env } = {}) {
309
+ requireRepoRootPath(repoRoot, "storeCommitEnv");
310
+ try {
311
+ execFileSync("git", ["-C", repoRoot, "var", "GIT_COMMITTER_IDENT"], { stdio: ["ignore", "pipe", "pipe"], env });
312
+ return env;
313
+ } catch {
314
+ return { ...env, GIT_COMMITTER_NAME: STORE_COMMITTER.name, GIT_COMMITTER_EMAIL: STORE_COMMITTER.email };
315
+ }
316
+ }
317
+
292
318
  /**
293
319
  * Why a commit into `repoRoot` would be refused, asked BEFORE anything is written; null when it would not
294
- * be. Read-only: `rev-parse` and `git var` change nothing. `{ code, detail, message }`, where `message` names
295
- * the store and the command that clears it, in terms an operator acts on rather than git's own words:
320
+ * be. Read-only: `rev-parse` changes nothing. `{ code, detail, message }`, where `message` names the store
321
+ * and the command that clears it, in terms an operator acts on rather than git's own words:
296
322
  *
297
- * not-a-repository — the store is not inside a git repository this process can use;
298
- * no-identity — git has no committer identity here. That is the default state of any machine where
299
- * nobody ran `git config user.email`, a fresh Windows install among them. A save names
300
- * its author; the COMMITTER is the machine's, and git refuses a commit it cannot name
301
- * one for. `git -c user.email=…` on a one-off seed commit does not help: `-c` configures
302
- * that invocation, not the repository, so every save after it fails the same way.
323
+ * not-a-repository — the store is not inside a git repository this process can use.
324
+ *
325
+ * A missing committer identity is not a refusal: every commit into the store supplies one (storeCommitEnv).
303
326
  */
304
327
  export function storeCommitRefusal(repoRoot, { env = process.env } = {}) {
305
328
  requireRepoRootPath(repoRoot, "storeCommitRefusal");
@@ -313,18 +336,13 @@ export function storeCommitRefusal(repoRoot, { env = process.env } = {}) {
313
336
  : `run \`git init\` in ${repoRoot}, or point PROFILE_REPO_ROOT at the repository that holds the store`;
314
337
  return { code: "not-a-repository", detail, message: `the store at ${repoRoot} is not a git repository this install can record into (${detail}) — ${fix}` };
315
338
  }
316
- try { ask("var", "GIT_COMMITTER_IDENT"); }
317
- catch (e) {
318
- return { code: "no-identity", detail: said(e).pop()?.slice(0, 200) ?? "",
319
- message: `the store at ${repoRoot} has no git identity, so nothing saved to it can be recorded — run `
320
- + `\`git -C ${repoRoot} config user.email "you@example.com"\` and \`git -C ${repoRoot} config user.name "Your Name"\`` };
321
- }
322
339
  return null;
323
340
  }
324
341
 
325
342
  export function makeStoreCommit({ repoRoot, log = () => {}, what = "store", retries = 3, waitMs = 50 }) {
326
343
  requireRepoRootPath(repoRoot, "makeStoreCommit");
327
- const git = (...args) => execFileSync("git", ["-C", repoRoot, ...args], { encoding: "utf8" }).toString().trim();
344
+ const run = (env, args) => execFileSync("git", ["-C", repoRoot, ...args], { encoding: "utf8", ...(env ? { env } : {}) }).toString().trim();
345
+ const git = (...args) => run(null, args);
328
346
  // ── IS THIS A REPOSITORY WE CAN USE? ASKED FIRST ─────────────────────────────
329
347
  //
330
348
  // Outside a repository git falls back to `--no-index` mode, which has no `--cached` — so the very
@@ -351,13 +369,16 @@ export function makeStoreCommit({ repoRoot, log = () => {}, what = "store", retr
351
369
  log(`${what}: the store is not a usable git repository, so this save CANNOT be committed — ${refusal}`);
352
370
  throw new Error(`the store at ${repoRoot} is not a usable git repository: ${refusal}`);
353
371
  }
372
+ // Asked per commit, so an identity somebody configures while the service runs is used from then on.
373
+ const committing = storeCommitEnv(repoRoot);
374
+ const gitCommit = (...args) => run(committing, args);
354
375
  // (1) Complete what an earlier failed save left staged. BEST EFFORT: if this fails too — the same
355
376
  // hook is still rejecting, the disk is still full — it must not stop the save that is happening now,
356
377
  // and it must not touch the index either. The paths simply stay staged for the next attempt.
357
378
  try {
358
379
  const found = git("diff", "--cached", "--name-only").split("\n").filter(Boolean);
359
380
  if (found.length) {
360
- git("commit", "-m", `Complete a store save left staged by an earlier failure (${found.length} path(s))`,
381
+ gitCommit("commit", "-m", `Complete a store save left staged by an earlier failure (${found.length} path(s))`,
361
382
  "--author", `${author} <${author}>`);
362
383
  log(`${what}: completed a commit left staged by an earlier failed save — ${found.join(", ")}`);
363
384
  }
@@ -369,7 +390,7 @@ export function makeStoreCommit({ repoRoot, log = () => {}, what = "store", retr
369
390
  git("add", ...files);
370
391
  for (let attempt = 1; ; attempt++) {
371
392
  try {
372
- git("commit", "-m", message, "--author", `${author} <${author}>`);
393
+ gitCommit("commit", "-m", message, "--author", `${author} <${author}>`);
373
394
  return git("rev-parse", "HEAD");
374
395
  } catch (e) {
375
396
  const detail = detailOf(e);