@manyoars/oars 0.4.0 → 0.5.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 (185) hide show
  1. package/README.md +82 -32
  2. package/dist/bin.js +3 -1
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +206 -20
  5. package/dist/club.d.ts +19 -0
  6. package/dist/club.js +132 -0
  7. package/dist/gate/hook-fill-main.js +1 -1
  8. package/dist/gate/hook-fill.d.ts +1 -1
  9. package/dist/gate/hook-fill.js +1 -1
  10. package/dist/gate/hook-main.js +7 -5
  11. package/dist/gate/hook.d.ts +12 -1
  12. package/dist/gate/hook.js +38 -7
  13. package/dist/hook-bin.js +23 -4
  14. package/dist/issuer.d.ts +1 -1
  15. package/dist/issuer.js +2 -2
  16. package/dist/main.js +5 -0
  17. package/dist/node-floor.d.ts +10 -0
  18. package/dist/node-floor.js +23 -0
  19. package/dist/oars-hook.d.ts +2 -0
  20. package/dist/oars-hook.js +7 -0
  21. package/dist/sessions/allow.d.ts +1 -0
  22. package/dist/sessions/allow.js +1 -1
  23. package/dist/sessions/call-shape.d.ts +111 -0
  24. package/dist/sessions/call-shape.js +464 -0
  25. package/dist/sessions/candidates.d.ts +65 -0
  26. package/dist/sessions/candidates.js +236 -0
  27. package/dist/sessions/card-command.d.ts +24 -0
  28. package/dist/sessions/card-command.js +71 -0
  29. package/dist/sessions/card-font.d.ts +6 -0
  30. package/dist/sessions/card-font.js +36 -0
  31. package/dist/sessions/card-line.d.ts +30 -0
  32. package/dist/sessions/card-line.js +105 -0
  33. package/dist/sessions/card-render.d.ts +15 -0
  34. package/dist/sessions/card-render.js +146 -0
  35. package/dist/sessions/card.d.ts +59 -0
  36. package/dist/sessions/card.js +107 -0
  37. package/dist/sessions/cell-increments.d.ts +14 -3
  38. package/dist/sessions/cell-increments.js +79 -12
  39. package/dist/sessions/cells.d.ts +42 -4
  40. package/dist/sessions/cells.js +72 -18
  41. package/dist/sessions/codex-hook-bin.d.ts +1 -0
  42. package/dist/sessions/codex-hook-bin.js +24 -0
  43. package/dist/sessions/codex-hook-install.d.ts +21 -0
  44. package/dist/sessions/codex-hook-install.js +47 -0
  45. package/dist/sessions/codex-hook-view.d.ts +20 -0
  46. package/dist/sessions/codex-hook-view.js +20 -0
  47. package/dist/sessions/codex-hook.d.ts +77 -0
  48. package/dist/sessions/codex-hook.js +255 -0
  49. package/dist/sessions/codex.d.ts +9 -0
  50. package/dist/sessions/codex.js +104 -18
  51. package/dist/sessions/command.d.ts +17 -2
  52. package/dist/sessions/command.js +145 -33
  53. package/dist/sessions/copilot.d.ts +3 -0
  54. package/dist/sessions/copilot.js +12 -4
  55. package/dist/sessions/derive.js +2 -1
  56. package/dist/sessions/empty-state.d.ts +17 -0
  57. package/dist/sessions/empty-state.js +38 -0
  58. package/dist/sessions/exclusion.js +48 -3
  59. package/dist/sessions/extract.d.ts +16 -3
  60. package/dist/sessions/extract.js +281 -24
  61. package/dist/sessions/gemini.d.ts +2 -0
  62. package/dist/sessions/gemini.js +29 -2
  63. package/dist/sessions/harness-files.d.ts +27 -0
  64. package/dist/sessions/harness-files.js +109 -0
  65. package/dist/sessions/head-only.d.ts +7 -0
  66. package/dist/sessions/head-only.js +21 -0
  67. package/dist/sessions/hook-calls.js +4 -3
  68. package/dist/sessions/hooks.d.ts +1 -1
  69. package/dist/sessions/hooks.js +6 -2
  70. package/dist/sessions/identity.d.ts +6 -0
  71. package/dist/sessions/injected.d.ts +13 -0
  72. package/dist/sessions/injected.js +212 -0
  73. package/dist/sessions/install.d.ts +31 -20
  74. package/dist/sessions/install.js +80 -82
  75. package/dist/sessions/load-status.d.ts +41 -0
  76. package/dist/sessions/load-status.js +156 -0
  77. package/dist/sessions/machine-child-deps.d.ts +5 -0
  78. package/dist/sessions/machine-child-deps.js +83 -0
  79. package/dist/sessions/machine-child.d.ts +43 -0
  80. package/dist/sessions/machine-child.js +120 -0
  81. package/dist/sessions/machine-credential.d.ts +10 -0
  82. package/dist/sessions/machine-credential.js +20 -0
  83. package/dist/sessions/machine-enroll.d.ts +47 -0
  84. package/dist/sessions/machine-enroll.js +230 -0
  85. package/dist/sessions/machine-hold.d.ts +49 -0
  86. package/dist/sessions/machine-hold.js +88 -0
  87. package/dist/sessions/machine-ids.d.ts +6 -0
  88. package/dist/sessions/machine-ids.js +9 -0
  89. package/dist/sessions/machine-notice.d.ts +21 -0
  90. package/dist/sessions/machine-notice.js +89 -0
  91. package/dist/sessions/machine-pull.d.ts +40 -0
  92. package/dist/sessions/machine-pull.js +220 -0
  93. package/dist/sessions/machine-repo.d.ts +7 -0
  94. package/dist/sessions/machine-repo.js +30 -0
  95. package/dist/sessions/machine-scan-cache.d.ts +12 -0
  96. package/dist/sessions/machine-scan-cache.js +47 -0
  97. package/dist/sessions/machine-send.d.ts +12 -0
  98. package/dist/sessions/machine-send.js +156 -0
  99. package/dist/sessions/machine-stop.d.ts +22 -0
  100. package/dist/sessions/machine-stop.js +53 -0
  101. package/dist/sessions/machine.d.ts +41 -0
  102. package/dist/sessions/machine.js +89 -0
  103. package/dist/sessions/opencode.d.ts +2 -0
  104. package/dist/sessions/opencode.js +6 -0
  105. package/dist/sessions/otel.d.ts +21 -0
  106. package/dist/sessions/otel.js +109 -38
  107. package/dist/sessions/owner-only.d.ts +7 -0
  108. package/dist/sessions/owner-only.js +34 -0
  109. package/dist/sessions/pi.d.ts +13 -1
  110. package/dist/sessions/pi.js +27 -13
  111. package/dist/sessions/pool-books.d.ts +1 -1
  112. package/dist/sessions/pool-books.js +1 -1
  113. package/dist/sessions/pool-choices.d.ts +4 -2
  114. package/dist/sessions/pool-choices.js +4 -2
  115. package/dist/sessions/pool-command.d.ts +46 -6
  116. package/dist/sessions/pool-command.js +577 -133
  117. package/dist/sessions/pool-delta.d.ts +7 -0
  118. package/dist/sessions/pool-delta.js +28 -5
  119. package/dist/sessions/pool-due.d.ts +43 -0
  120. package/dist/sessions/pool-due.js +113 -0
  121. package/dist/sessions/pool-nightly.d.ts +9 -1
  122. package/dist/sessions/pool-nightly.js +65 -21
  123. package/dist/sessions/pool-outbox.d.ts +51 -5
  124. package/dist/sessions/pool-outbox.js +217 -24
  125. package/dist/sessions/pool-print.d.ts +1 -1
  126. package/dist/sessions/pool-raw.js +4 -3
  127. package/dist/sessions/pool-replica.d.ts +4 -4
  128. package/dist/sessions/pool-replica.js +26 -7
  129. package/dist/sessions/pool-send-lock.js +1 -1
  130. package/dist/sessions/pool-sent.d.ts +2 -0
  131. package/dist/sessions/pool-sent.js +21 -4
  132. package/dist/sessions/pool-wire.d.ts +172 -12
  133. package/dist/sessions/pool-wire.js +284 -30
  134. package/dist/sessions/pool.js +4 -2
  135. package/dist/sessions/public-vocabulary.d.ts +5 -0
  136. package/dist/sessions/public-vocabulary.js +16 -0
  137. package/dist/sessions/read-call-shape.js +12 -6
  138. package/dist/sessions/read-fit.d.ts +5 -0
  139. package/dist/sessions/read-fit.js +23 -2
  140. package/dist/sessions/read.d.ts +1 -1
  141. package/dist/sessions/recall-consequence.d.ts +2 -2
  142. package/dist/sessions/recall-consequence.js +14 -9
  143. package/dist/sessions/recall-object.d.ts +9 -2
  144. package/dist/sessions/receipt.d.ts +8 -0
  145. package/dist/sessions/receipt.js +78 -0
  146. package/dist/sessions/recovery-calls.d.ts +3 -2
  147. package/dist/sessions/recovery-calls.js +9 -3
  148. package/dist/sessions/redact.d.ts +2 -2
  149. package/dist/sessions/redact.js +116 -14
  150. package/dist/sessions/reversion.d.ts +8 -0
  151. package/dist/sessions/reversion.js +114 -0
  152. package/dist/sessions/schema.d.ts +78 -4
  153. package/dist/sessions/schema.js +31 -1
  154. package/dist/sessions/session-shape.d.ts +2 -1
  155. package/dist/sessions/session-shape.js +15 -0
  156. package/dist/sessions/sources.d.ts +9 -0
  157. package/dist/sessions/sources.js +16 -5
  158. package/dist/sessions/steps.js +3 -2
  159. package/dist/sessions/store.d.ts +41 -2
  160. package/dist/sessions/store.js +68 -16
  161. package/dist/sessions/try-demo.d.ts +8 -0
  162. package/dist/sessions/try-demo.js +90 -0
  163. package/dist/sessions/try.d.ts +13 -0
  164. package/dist/sessions/try.js +50 -0
  165. package/dist/sessions/vscode-copilot.d.ts +4 -0
  166. package/dist/sessions/vscode-copilot.js +25 -18
  167. package/dist/telemetry/command.js +12 -6
  168. package/dist/telemetry/consent.d.ts +9 -3
  169. package/dist/telemetry/consent.js +25 -9
  170. package/dist/telemetry/fields.d.ts +11 -0
  171. package/dist/telemetry/fields.js +56 -0
  172. package/dist/telemetry/measures.d.ts +13 -0
  173. package/dist/telemetry/measures.js +30 -0
  174. package/dist/telemetry/observe.d.ts +15 -2
  175. package/dist/telemetry/observe.js +34 -7
  176. package/dist/telemetry/region.d.ts +6 -0
  177. package/dist/telemetry/region.js +28 -0
  178. package/dist/telemetry/report.js +1 -1
  179. package/dist/telemetry/schema.d.ts +3 -0
  180. package/dist/telemetry/schema.js +11 -1
  181. package/dist/telemetry/send.d.ts +5 -2
  182. package/dist/telemetry/send.js +15 -7
  183. package/dist/telemetry/words.d.ts +6 -0
  184. package/dist/telemetry/words.js +6 -0
  185. package/package.json +23 -4
package/dist/cli.js CHANGED
@@ -7,16 +7,24 @@ import { logout } from './commands/logout.js';
7
7
  import { CliError, ExitCode } from './errors.js';
8
8
  import { closedQueryOf, formsRefusal, RECALL_FAMILIES } from './sessions/recall-reads.js';
9
9
  import { FAMILIES } from './sessions/read.js';
10
- import { sessionsBooks, sessionsContext, sessionsDeposit, sessionsGoldArms, sessionsGoldSample, sessionsGoldScore, sessionsHookInstall, sessionsHookStatus, sessionsHookUninstall, sessionsMcp, sessionsLineage, sessionsMeasure, sessionsRead, sessionsRecall, sessionsRecallCounts, sessionsVerify } from './sessions/command.js';
10
+ import { sessionsBooks, sessionsContext, sessionsDeposit, sessionsGoldArms, sessionsGoldSample, sessionsGoldScore, sessionsHookInstall, sessionsHookStatus, sessionsHookUninstall, sessionsMcp, sessionsLineage, sessionsMeasure, sessionsRead, sessionsRecall, sessionsReceipt, sessionsRecallCounts, sessionsVerify } from './sessions/command.js';
11
+ import { sessionsCard } from './sessions/card-command.js';
11
12
  import { gateInstall, gatePolicy, gateReplay, gateReport, gateUninstall } from './gate/command.js';
12
13
  import { DEADLINE_MS, gateHook } from './gate/hook.js';
13
14
  import { managerReader } from './gate/registry.js';
14
15
  import { exclusionPaths, sessionsExclusions } from './sessions/exclusion.js';
15
- import { poolCoverage, poolDeposit, poolFetch, poolLogin, poolOutbox, poolRaw } from './sessions/pool-command.js';
16
+ import { sessionsTry } from './sessions/try.js';
17
+ import { board, clubCreate, clubJoin, clubLeave, clubShow } from './club.js';
18
+ import { githubVisibility, poolEnroll, poolMachineRevoke, poolMachines } from './sessions/machine-enroll.js';
19
+ import { runNetGit } from './sessions/machine-hold.js';
20
+ import { holdPull } from './sessions/machine-pull.js';
21
+ import { runGit } from './sessions/survival.js';
22
+ import { poolCoverage, poolDeposit, poolExport, poolFetch, poolLogin, poolLogout, poolOutbox, poolProve, poolRaw } from './sessions/pool-command.js';
16
23
  import { telemetryOff, telemetryOn, telemetryReport, telemetryShow, telemetryStatus } from './telemetry/command.js';
24
+ import { cardLine } from './sessions/card-line.js';
17
25
  import { recordCrash } from './telemetry/crash.js';
18
26
  import { emitOf } from './telemetry/emit.js';
19
- import { finishTelemetry, observeCommand } from './telemetry/observe.js';
27
+ import { finishTelemetry, isHook, observeCommand } from './telemetry/observe.js';
20
28
  const flag = (options, name) => options[name] === true;
21
29
  const text = (options, name) => {
22
30
  const value = options[name];
@@ -129,6 +137,20 @@ const buildProgram = (deps, observed) => {
129
137
  const options = command.optsWithGlobals();
130
138
  emit(deps, flag(options, 'json'), await keysRevoke(deps, { apiUrl: resolveApiUrl(deps, text(options, 'apiUrl')), keyId }));
131
139
  });
140
+ program
141
+ .command('card')
142
+ .description('a share-ready card of your own week, month, or year across every harness, read from your local store only: nothing is uploaded and no folder, path, prompt text, or private name is printed')
143
+ .option('--week', 'the last 7 days (the default)')
144
+ .option('--month', 'the last 30 days')
145
+ .option('--year', 'the last 365 days')
146
+ .option('--text', 'print the card as a table instead of saving a picture')
147
+ .option('--out <file>', 'save the card here, as .png or .svg: both are written, one with each extension (default oars-card-<period> in the working directory)')
148
+ .option('--no-cost', 'leave the cost row out of the picture, the table, and --json')
149
+ .action(async (_options, command) => {
150
+ const options = command.optsWithGlobals();
151
+ const period = flag(options, 'year') ? 'year' : flag(options, 'month') ? 'month' : 'week';
152
+ emit(deps, flag(options, 'json'), await sessionsCard(sessionDeps(deps), { period, text: flag(options, 'text'), out: text(options, 'out'), noCost: options.cost === false }, deps.env));
153
+ });
132
154
  const sessions = program.command('sessions').description("deposit and recall your own coding-agent session logs, locally (these commands make no model call and no network call, except `gold arms`, which asks your own Claude subscription; telemetry is separate: oars telemetry)");
133
155
  sessions
134
156
  .command('deposit')
@@ -141,6 +163,16 @@ const buildProgram = (deps, observed) => {
141
163
  const options = command.optsWithGlobals();
142
164
  emit(deps, flag(options, 'json'), await sessionsDeposit(sessionDeps(deps), { allow: list(options, 'allow'), allowFiles: list(options, 'allowFile'), deny: list(options, 'deny'), redactDomains: list(options, 'redactDomain') }));
143
165
  });
166
+ sessions
167
+ .command('try')
168
+ .description('see recall, books, and the hook line work on made-up sessions, with no history of your own: it builds a throwaway store under your temp folder, labels everything it prints as synthetic, and deletes the store when it exits. It never reads or writes your real store')
169
+ .action(async (_options, command) => {
170
+ const options = command.optsWithGlobals();
171
+ // The command promises no network call: drop the sender before it runs, so the daily telemetry send that follows every command cannot start one, whether the run returns or throws.
172
+ if (deps.telemetry !== undefined)
173
+ deps.telemetry = { dir: deps.telemetry.dir, platform: deps.telemetry.platform };
174
+ emit(deps, flag(options, 'json'), await sessionsTry(deps));
175
+ });
144
176
  sessions
145
177
  .command('recall')
146
178
  .description('look a tool error, a file, or a prompt class up in the store (--mcp serves the same query to a running agent). Note: an error pasted with a leading <tool_use_error> line does not match a stored signature, because the extractor keeps that markup line as the signature (reference-script behavior); paste the line below it')
@@ -153,7 +185,8 @@ const buildProgram = (deps, observed) => {
153
185
  .option('--family <name>', `one read family (${RECALL_FAMILIES.join(', ')}); only recovery and fit are narrowed by --class and --since`)
154
186
  .option('--text <text>', 'look for text in the local record')
155
187
  .option('--session <id>', 'the session asking, so what it did next can be read for this recall')
156
- .option('--counts', 'print counts of recalls by mode, arm, and answer, with what followed, instead of answering (see --day)')
188
+ .option('--receipt', 'with --error: print a redacted Markdown block to paste into an issue on the failing tool (tool, harness, normalized signature, earlier sessions and weeks, what they did next)')
189
+ .option('--counts', 'print counts of recalls by mode, arm, and answer, with how many windows hold a call named like a shown action, instead of answering (see --day)')
157
190
  .option('--day <yyyy-mm-dd>', 'with --counts: only this UTC day')
158
191
  .option('--mcp', 'serve one recall tool over MCP on stdio instead of answering once')
159
192
  .option('--context', "print the repository's top recurring errors as a short block for injection into a session (see --cwd, --harness, --top, --max-chars)")
@@ -163,6 +196,12 @@ const buildProgram = (deps, observed) => {
163
196
  .option('--max-chars <n>', "with --context: the block's length limit (default 1200)", positiveInteger)
164
197
  .action(async (_options, command) => {
165
198
  const options = command.optsWithGlobals();
199
+ if (flag(options, 'receipt')) {
200
+ if (flag(options, 'counts') || flag(options, 'mcp') || flag(options, 'context') || list(options, 'pattern').length > 0 || text(options, 'entity') !== undefined || text(options, 'family') !== undefined || text(options, 'text') !== undefined)
201
+ throw new CliError('--receipt answers one error: pass --error "<text>" and no other form.', ExitCode.USAGE, 'usage');
202
+ emit(deps, flag(options, 'json'), await sessionsReceipt(sessionDeps(deps), { ...(text(options, 'error') === undefined ? {} : { error: text(options, 'error') }), ...(text(options, 'path') === undefined ? {} : { path: text(options, 'path') }), ...(text(options, 'class') === undefined ? {} : { cls: text(options, 'class') }), ...(count(options, 'since') === undefined ? {} : { sinceDays: count(options, 'since') }) }, text(options, 'session') ?? null));
203
+ return;
204
+ }
166
205
  if (flag(options, 'counts')) {
167
206
  emit(deps, flag(options, 'json'), await sessionsRecallCounts(sessionDeps(deps), text(options, 'day')));
168
207
  return;
@@ -310,7 +349,7 @@ const buildProgram = (deps, observed) => {
310
349
  const options = command.optsWithGlobals();
311
350
  emit(deps, flag(options, 'json'), await sessionsGoldArms(sessionDeps(deps), { file }));
312
351
  });
313
- const hook = sessions.command('hook').description('install the recall hooks into a Claude Code settings file, and the nightly deposit task');
352
+ const hook = sessions.command('hook').description('install the recall hooks into a Claude Code settings file, and remove the scheduled task earlier versions registered');
314
353
  const scopeOption = (value) => {
315
354
  if (value !== 'user' && value !== 'project' && value !== 'file')
316
355
  throw new InvalidArgumentError('it must be user, project, or file.');
@@ -320,15 +359,25 @@ const buildProgram = (deps, observed) => {
320
359
  const value = text(options, 'scope');
321
360
  return value === 'user' || value === 'project' || value === 'file' ? value : undefined;
322
361
  };
362
+ const harnessOption = (value) => {
363
+ if (value !== 'codex')
364
+ throw new InvalidArgumentError('it must be codex (Claude Code is the default and needs no flag).');
365
+ return value;
366
+ };
367
+ const harnessOf = (options) => (text(options, 'harness') === 'codex' ? 'codex' : undefined);
323
368
  hook
324
369
  .command('install')
325
- .description('merge the SessionStart, UserPromptSubmit, PreToolUse, PostToolUseFailure, and SessionEnd hooks into a settings file (existing hooks are kept), raise cleanupPeriodDays to at least 365, and on Windows register the nightly task (which sends telemetry while telemetry is on, and pool batches only after you accept a pool covenant)')
370
+ .description('merge the SessionStart, UserPromptSubmit, PreToolUse, PostToolUseFailure, and SessionEnd hooks into a settings file (existing hooks are kept), raise cleanupPeriodDays to at least 365, and, on Windows, remove the nightly task an earlier version registered (oars installs no scheduler: a due sweep and send, at most about hourly, starts from the session-end hook and from any oars command; telemetry goes only while it is on, and pool batches only after you accept a pool covenant)')
326
371
  .option('--scope <scope>', 'user (~/.claude/settings.json; the default), project (./.claude/settings.json), or file (needs --file; implied by --file)', scopeOption)
327
372
  .option('--file <path>', 'the settings file for --scope file')
373
+ .option('--harness <harness>', 'codex: install the opt-in Codex recording hook in hooks.json in the Codex home instead (Codex asks you to trust it before it runs; it records tool and token rows locally while codex is allowed)', harnessOption)
328
374
  .option('--keep-retention', 'leave cleanupPeriodDays as it is (the harness deletes raw transcripts older than it)')
329
- .option('--schedule <mode>', 'nightly: register a Windows task that runs the full deposit and the recurrence refresh daily (a plain install on Windows does this too); off: remove it', (value) => {
330
- if (value !== 'nightly' && value !== 'off')
331
- throw new InvalidArgumentError('it must be nightly or off.');
375
+ .option('--machine', 'write the Stop and SessionEnd entries of a machine (a cloud VM, a VPS, or a runner): they run oars-hook, which builds the batch of its session at each Stop only where OARS_HOST_CLASS names a class other than local; --scope project (committed) or --scope user')
376
+ .option('--env <name=value>', 'with --machine --scope user: one variable of the enrollment block for the env block of that file (OARS_HOST_CLASS is required; repeat the flag; OARS_EXCLUSION_TERMS_INLINE: one term per line; a comma is part of the term); the file is then made owner-only', (value, before) => [...(before ?? []), value])
377
+ .option('--terms-file <path>', 'with --machine --scope user: write the OARS_EXCLUSION_TERMS_INLINE terms to this owner-only file and name it in OARS_EXCLUSION_TERMS, so the settings file holds no term')
378
+ .option('--schedule <mode>', 'off: no background sweep or pool send from the session-end hook or from commands (telemetry has its own switch: oars telemetry off), and remove the task an earlier version registered; on: clear that choice; nightly is refused, because sends now ride the hook and commands', (value) => {
379
+ if (value !== 'nightly' && value !== 'off' && value !== 'on')
380
+ throw new InvalidArgumentError('it must be off or on (nightly is no longer registered).');
332
381
  return value;
333
382
  })
334
383
  .action(async (_options, command) => {
@@ -338,25 +387,31 @@ const buildProgram = (deps, observed) => {
338
387
  scope: scopeOf(options),
339
388
  file: text(options, 'file'),
340
389
  keepRetention: flag(options, 'keepRetention'),
341
- schedule: schedule === 'nightly' || schedule === 'off' ? schedule : undefined,
390
+ schedule: schedule === 'nightly' || schedule === 'off' || schedule === 'on' ? schedule : undefined,
391
+ harness: harnessOf(options),
392
+ machine: flag(options, 'machine'),
393
+ env: list(options, 'env'),
394
+ termsFile: text(options, 'termsFile'),
342
395
  }));
343
396
  });
344
397
  hook
345
398
  .command('uninstall')
346
- .description('remove only the hooks `install` wrote from a settings file (cleanupPeriodDays stays; `install --schedule off` removes the task)')
399
+ .description('remove only the hooks `install` wrote from a settings file (cleanupPeriodDays stays; it also removes the task an earlier version registered)')
347
400
  .option('--scope <scope>', 'user (the default), project, or file (needs --file; implied by --file)', scopeOption)
348
401
  .option('--file <path>', 'the settings file for --scope file')
402
+ .option('--harness <harness>', 'codex: remove only the Codex recording hook from hooks.json in the Codex home', harnessOption)
349
403
  .action(async (_options, command) => {
350
404
  const options = command.optsWithGlobals();
351
- emit(deps, flag(options, 'json'), await sessionsHookUninstall(sessionDeps(deps), { scope: scopeOf(options), file: text(options, 'file') }));
405
+ emit(deps, flag(options, 'json'), await sessionsHookUninstall(sessionDeps(deps), { scope: scopeOf(options), file: text(options, 'file'), harness: harnessOf(options) }));
352
406
  });
353
407
  hook
354
408
  .command('status')
355
- .description('what is installed in the user and project settings (and --file), and whether the nightly task is registered')
409
+ .description('what is installed in the user and project settings (and --file), and whether a task an earlier version registered is still there')
356
410
  .option('--file <path>', 'one more settings file to report')
411
+ .option('--harness <harness>', 'codex: report the Codex recording hook in hooks.json in the Codex home instead', harnessOption)
357
412
  .action(async (_options, command) => {
358
413
  const options = command.optsWithGlobals();
359
- emit(deps, flag(options, 'json'), await sessionsHookStatus(sessionDeps(deps), { file: text(options, 'file') }));
414
+ emit(deps, flag(options, 'json'), await sessionsHookStatus(sessionDeps(deps), { file: text(options, 'file'), harness: harnessOf(options) }));
360
415
  });
361
416
  const gate = program.command('gate').description('record the install, fetch-and-run, MCP, and skill picks your agents make, live through a hook or from logs already on disk, without judging them');
362
417
  gate
@@ -407,27 +462,81 @@ const buildProgram = (deps, observed) => {
407
462
  .action(async (_options, command) => {
408
463
  emit(deps, flag(command.optsWithGlobals(), 'json'), await gateReport(sessionDeps(deps)));
409
464
  });
410
- const pool = program.command('pool').description('sign in to a pool server, deposit session skeletons (a closed list of fields, each value redacted first) and, after a yes to the raw prompt, redacted raw copies, and fetch signed reads; local capture, recall, and reads need none of it');
465
+ const pool = program.command('pool').description('sign in to a pool server, deposit session skeletons (a closed list of fields, each value redacted first) and, after a yes to the raw prompt, redacted raw copies, and fetch signed reads; local capture, recall, and reads need none of it. The covenant screen, where you type its phrase to accept, appears when you send a batch with oars pool deposit --send <hash> and have not accepted the covenant in force, not at sign-in');
411
466
  const serverOption = '--server <url>';
412
467
  const serverHelp = 'the pool server, https or plain http to localhost (default: OARS_POOL_URL, else https://pool.manyoars.com)';
468
+ pool
469
+ .command('logout')
470
+ .description('revoke the credential held for this pool and remove it here; if the pool cannot be reached the local entry is still removed')
471
+ .option(serverOption, serverHelp)
472
+ .action(async (_options, command) => {
473
+ const options = command.optsWithGlobals();
474
+ emit(deps, flag(options, 'json'), await poolLogout(deps, { server: text(options, 'server') }));
475
+ });
413
476
  pool
414
477
  .command('login')
415
- .description('sign in by GitHub or Google device flow, run here against the provider; prints each request before sending it, and the pool keeps a pseudonym; no harness token is read')
478
+ .description('sign in by GitHub or Google device flow, run here against the provider; --verbose prints each request before sending it, and the pool keeps a pseudonym; no harness token is read')
416
479
  .requiredOption('--provider <name>', 'github or google')
480
+ .option('--verbose', 'print each request, headers and body, to stderr before it is sent (a credential shows as its size)')
481
+ .option(serverOption, serverHelp)
482
+ .action(async (_options, command) => {
483
+ const options = command.optsWithGlobals();
484
+ emit(deps, flag(options, 'json'), await poolLogin(deps, { server: text(options, 'server'), provider: text(options, 'provider'), verbose: flag(options, 'verbose') }));
485
+ });
486
+ pool
487
+ .command('enroll')
488
+ .description('enroll a machine: print the whole configuration a cloud VM needs (host class, launcher, a new machine id, the repositories, two salts derived from this machine\'s, and your exclusion terms) after checking that each repository is private (GH_TOKEN or GITHUB_TOKEN lets the check see a private one). With --hold-only that is all, and no pool credential is used; without it the pool also issues a one-time enrollment token for the machine, which needs oars pool login')
489
+ .option('--hold-only', 'hold in the session\'s own repository and send nothing to the pool: no login, no token')
490
+ .option('--host-class <class>', 'vps, cloud, ci, or hosted')
491
+ .option('--launcher <launcher>', 'interactive, orchestrated, or scheduled')
492
+ .option('--allow-repos <repos>', 'the repositories the machine may hold, as owner/name; repeat the flag or separate them with commas', collect)
493
+ .option('--name <label>', 'without --hold-only: the label the pool lists this machine by (letters, digits, spaces, dots, underscores, and hyphens; 64 at most)')
494
+ .option('--days <n>', 'without --hold-only: how many days the machine\'s key lives from its activation, 1 to 365 (default 90)')
495
+ .option(serverOption, serverHelp)
496
+ .action(async (_options, command) => {
497
+ const options = command.optsWithGlobals();
498
+ const token = deps.env.GH_TOKEN ?? deps.env.GITHUB_TOKEN;
499
+ emit(deps, flag(options, 'json'), await poolEnroll(deps, { holdOnly: flag(options, 'holdOnly'), hostClass: text(options, 'hostClass'), launcher: text(options, 'launcher'), allowRepos: list(options, 'allowRepos'), name: text(options, 'name'), days: text(options, 'days'), server: text(options, 'server') }, githubVisibility(fetch, token)));
500
+ });
501
+ const machines = pool
502
+ .command('machines')
503
+ .description('list the machines enrolled with the pool: label, id, host class, launcher, state, last deposit day, and expiry; needs oars pool login')
504
+ .option(serverOption, serverHelp)
505
+ .action(async (_options, command) => {
506
+ const options = command.optsWithGlobals();
507
+ emit(deps, flag(options, 'json'), await poolMachines(deps, { server: text(options, 'server') }));
508
+ });
509
+ machines
510
+ .command('revoke')
511
+ .argument('<id>', 'the machine\'s id, as oars pool machines lists it')
512
+ .description('revoke one machine: its key is refused from its next request')
513
+ .option(serverOption, serverHelp)
514
+ .action(async (id, _options, command) => {
515
+ const options = command.optsWithGlobals();
516
+ emit(deps, flag(options, 'json'), await poolMachineRevoke(deps, id, { server: text(options, 'server') }));
517
+ });
518
+ pool
519
+ .command('hold')
520
+ .description('the trusted machine\'s side of the holding route')
521
+ .command('pull')
522
+ .description('on the trusted machine, read the oars-hold/* branch tips of every enrolled machine\'s private repositories (each repository\'s visibility is checked again on every run) and admit each batch that passes every gate to the outbox; a branch that does not is reported and left where it is. Uses this machine\'s own git credential and GH_TOKEN or GITHUB_TOKEN for the visibility check')
417
523
  .option(serverOption, serverHelp)
418
524
  .action(async (_options, command) => {
419
525
  const options = command.optsWithGlobals();
420
- emit(deps, flag(options, 'json'), await poolLogin(deps, { server: text(options, 'server'), provider: text(options, 'provider') }));
526
+ const token = deps.env.GH_TOKEN ?? deps.env.GITHUB_TOKEN;
527
+ const ports = { visibility: githubVisibility(fetch, token), git: runGit, net: runNetGit, remoteOf: (repo) => `https://github.com/${repo}.git` };
528
+ emit(deps, flag(options, 'json'), await holdPull(deps, { server: text(options, 'server') }, ports));
421
529
  });
422
530
  pool
423
531
  .command('deposit')
424
- .description('write a batch to the outbox for each session that passes the folder allowlist and the exclusion check, and stop: nothing is sent, and only counts print. --send <hash> sends one batch that oars pool outbox printed and that is unchanged, once this account has accepted the pool covenant')
425
- .option('--accept-covenant <version>', 'accept this version of the pool covenant before sending (with --send)')
532
+ .description('write a batch to the outbox for each session that passes the folder allowlist and the exclusion check, and stop: no session data is sent, and only counts print. --send <hash> sends one batch that oars pool outbox printed and that is unchanged, once this account has accepted the pool covenant')
533
+ .option('--accept-covenant <version>', 'name the covenant version you expect, with --send: on a terminal the screen is shown and you type its phrase yourself, and off a terminal this is refused')
534
+ .option('--in-eu-eea-uk <yes|no>', 'answer "Are you in the EU, the EEA, or the UK?" when you accept, with --send; without it a person is asked, with or without --accept-covenant. While the pool is closed to those regions, yes is refused')
426
535
  .option('--send <hash>', 'the SHA-256 of a batch that oars pool outbox printed')
427
536
  .option(serverOption, serverHelp)
428
537
  .action(async (_options, command) => {
429
538
  const options = command.optsWithGlobals();
430
- emit(deps, flag(options, 'json'), await poolDeposit(deps, { server: text(options, 'server'), acceptCovenant: text(options, 'acceptCovenant'), send: text(options, 'send') }));
539
+ emit(deps, flag(options, 'json'), await poolDeposit(deps, { server: text(options, 'server'), acceptCovenant: text(options, 'acceptCovenant'), inEuEeaUk: text(options, 'inEuEeaUk'), send: text(options, 'send') }));
431
540
  });
432
541
  pool
433
542
  .command('raw')
@@ -457,6 +566,24 @@ const buildProgram = (deps, observed) => {
457
566
  const options = command.optsWithGlobals();
458
567
  emit(deps, flag(options, 'json'), await poolOutbox(deps, { server: text(options, 'server'), explain: flag(options, 'explain') }));
459
568
  });
569
+ pool
570
+ .command('prove')
571
+ .description('prove you control your pool account with your sign-in, and print the account and the time the pool stored, to send to the operator who asked for proof before acting on your request (GDPR Article 12(6)); for a closed account, run login instead')
572
+ .option(serverOption, serverHelp)
573
+ .action(async (_options, command) => {
574
+ const options = command.optsWithGlobals();
575
+ emit(deps, flag(options, 'json'), await poolProve(deps, { server: text(options, 'server') }));
576
+ });
577
+ pool
578
+ .command('export')
579
+ .description('save a copy of what the pool holds about your account to a folder, as JSON, one file a page, and write nothing to the pool; with --raw also save your own raw transcripts that still stand (GDPR Articles 15 and 20)')
580
+ .requiredOption('--out <folder>', 'the folder to save the export in; its files are owner-only, and a rerun overwrites the same ones')
581
+ .option('--raw', 'also fetch each raw transcript you deposited that has not been erased, into <folder>/raw')
582
+ .option(serverOption, serverHelp)
583
+ .action(async (_options, command) => {
584
+ const options = command.optsWithGlobals();
585
+ emit(deps, flag(options, 'json'), await poolExport(deps, { server: text(options, 'server'), out: text(options, 'out') ?? '', raw: flag(options, 'raw') }));
586
+ });
460
587
  pool
461
588
  .command('fetch')
462
589
  .description('fetch the signed bundle for one harness version, verify it against the key pinned at login, and keep it locally')
@@ -467,6 +594,53 @@ const buildProgram = (deps, observed) => {
467
594
  const options = command.optsWithGlobals();
468
595
  emit(deps, flag(options, 'json'), await poolFetch(deps, { server: text(options, 'server'), harness: text(options, 'harness'), harnessVersion: text(options, 'harnessVersion') ?? '' }));
469
596
  });
597
+ const club = program.command('club').description('private boards for people you invite (docs/pool.md, "Boards"): a club shows the nicknames of its members with weeks rowing, active days, sessions, and strokes this week, and never a session, project, prompt, or token; needs oars pool login');
598
+ club
599
+ .command('create')
600
+ .description('start a club and print its code once; you are its first member. Shows the consent text from the pool and asks you to type yes')
601
+ .argument('<name>', 'the name of the club (letters, digits, spaces, dots, underscores, and hyphens; 24 at most)')
602
+ .requiredOption('--nickname <name>', 'the name members of the club see for you (2 to 24 letters, digits, spaces, dots, underscores, or hyphens; no @ or link)')
603
+ .option(serverOption, serverHelp)
604
+ .action(async (name, _options, command) => {
605
+ const options = command.optsWithGlobals();
606
+ emit(deps, flag(options, 'json'), await clubCreate(deps, name, { server: text(options, 'server'), nickname: text(options, 'nickname') ?? '' }));
607
+ });
608
+ club
609
+ .command('join')
610
+ .description('join a club by its code, or the open board with the word open; shows the consent text from the pool for it and asks you to type yes')
611
+ .argument('<code>', 'the code of the club, or open')
612
+ .requiredOption('--nickname <name>', 'the name the board shows for you (2 to 24 letters, digits, spaces, dots, underscores, or hyphens; no @ or link)')
613
+ .option(serverOption, serverHelp)
614
+ .action(async (code, _options, command) => {
615
+ const options = command.optsWithGlobals();
616
+ emit(deps, flag(options, 'json'), await clubJoin(deps, code, { server: text(options, 'server'), nickname: text(options, 'nickname') ?? '' }));
617
+ });
618
+ club
619
+ .command('leave')
620
+ .description('leave a club: your row is gone from its board at once')
621
+ .argument('<club>', 'the id of the club, as oars club create or join printed it')
622
+ .option(serverOption, serverHelp)
623
+ .action(async (id, _options, command) => {
624
+ const options = command.optsWithGlobals();
625
+ emit(deps, flag(options, 'json'), await clubLeave(deps, id, { server: text(options, 'server') }));
626
+ });
627
+ club
628
+ .command('show')
629
+ .description('print the board of a club, in the order the pool gives: nickname, weeks rowing, active days, sessions, and strokes this week; members only')
630
+ .argument('<club>', 'the id of the club')
631
+ .option(serverOption, serverHelp)
632
+ .action(async (id, _options, command) => {
633
+ const options = command.optsWithGlobals();
634
+ emit(deps, flag(options, 'json'), await clubShow(deps, id, { server: text(options, 'server') }));
635
+ });
636
+ program
637
+ .command('board')
638
+ .description('print the open board: people who chose to join it and have 90 days of standing, ranked by weeks rowing, then active days; public, so it needs no sign-in')
639
+ .option(serverOption, serverHelp)
640
+ .action(async (_options, command) => {
641
+ const options = command.optsWithGlobals();
642
+ emit(deps, flag(options, 'json'), await board(deps, { server: text(options, 'server') }));
643
+ });
470
644
  const telemetry = program.command('telemetry').description('what oars records about its own use, on this machine (docs/telemetry.md): status, on, off, show, and report');
471
645
  const telemetryJson = (command) => flag(command.optsWithGlobals(), 'json');
472
646
  telemetry
@@ -527,11 +701,23 @@ const execute = async (argv, deps, observed) => {
527
701
  return failure.exitCode;
528
702
  }
529
703
  };
704
+ /** `sessions hook uninstall` is the first step of removing the store, so it must not start a sweep that would recreate what the person is about to delete. */
705
+ const isHookUninstall = (observed) => observed.path.join(' ') === 'sessions hook uninstall';
706
+ /** `sessions try` promises no network call and no read of the real store, and the sender does both. */
707
+ const isSessionsTry = (observed) => observed.path.join(' ') === 'sessions try';
708
+ /** The weekly card line (`sessions/card-line.ts`): stderr only, never an error, never a change to the exit code. */
709
+ const weeklyLine = (argv, deps, observed) => deps.sessions === undefined
710
+ ? Promise.resolve('not_due')
711
+ : cardLine({ storeDir: deps.sessions.storeDir, telemetryDir: deps.telemetry?.dir, now: deps.now(), tty: deps.sessions.stderr?.isTTY === true, argv, path: observed.path, flags: observed.flags, stderr: deps.io.stderr });
530
712
  /** Runs one invocation (`argv` without the node and script entries) and returns the exit code. Telemetry records it afterward and cannot change it. */
531
713
  export const run = async (argv, deps) => {
532
714
  const observed = { path: [], flags: [] };
533
715
  const startedAt = deps.now();
534
716
  const exit = await execute(argv, deps, observed);
717
+ observed.cardLine = await weeklyLine(argv, deps, observed);
535
718
  finishTelemetry(deps, argv, observed, startedAt, exit);
719
+ // Never from a gate hook (it runs before every tool call) or from `hook uninstall`; it cannot change the exit code or what the command printed.
720
+ if (deps.catchUp !== undefined && !isHook(argv, observed) && !isHookUninstall(observed) && !isSessionsTry(observed))
721
+ await Promise.resolve().then(deps.catchUp).catch(() => undefined);
536
722
  return exit;
537
723
  };
package/dist/club.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { Outcome, RunDeps } from './cli.js';
2
+ interface Options {
3
+ server?: string | undefined;
4
+ }
5
+ /** `oars club create <name> --nickname <n>`: a new club with the caller as its first member. The code comes back once. */
6
+ export declare const clubCreate: (deps: RunDeps, name: string, options: Options & {
7
+ nickname: string;
8
+ }) => Promise<Outcome>;
9
+ /** `oars club join <code> --nickname <n>`; the word `open` joins the open board under its own text. */
10
+ export declare const clubJoin: (deps: RunDeps, code: string, options: Options & {
11
+ nickname: string;
12
+ }) => Promise<Outcome>;
13
+ /** `oars club leave <club>`: the row leaves every view at once. */
14
+ export declare const clubLeave: (deps: RunDeps, club: string, options: Options) => Promise<Outcome>;
15
+ /** `oars club show <club>`: the board, to its members only. */
16
+ export declare const clubShow: (deps: RunDeps, club: string, options: Options) => Promise<Outcome>;
17
+ /** `oars board`: the open board. It is public, so it needs no sign-in. */
18
+ export declare const board: (deps: RunDeps, options: Options) => Promise<Outcome>;
19
+ export {};
package/dist/club.js ADDED
@@ -0,0 +1,132 @@
1
+ // `oars club` and `oars board` (t1091): the commands over the pool's board routes (docs/pool.md, "Boards"). A club shares counts with the people who hold its code; the open board is public. Joining is a person at a terminal who reads the pool's consent text and types yes, the way the covenant is accepted: a flag or a script on stdin is not that person.
2
+ import { createHash } from 'node:crypto';
3
+ import { CliError, exitCodeForProblem, ExitCode } from './errors.js';
4
+ import { ask, isTerminal, lineFrom, poolServer, runtimeOf } from './sessions/pool-command.js';
5
+ import { isObject, loadSignIns, numberIn, textIn } from './sessions/pool-replica.js';
6
+ const CREATE = 'club-create';
7
+ const JOIN = 'club-join';
8
+ const LEAVE = 'club-leave';
9
+ const SHOW = 'club-show';
10
+ const BOARD = 'board';
11
+ /** The word that names the open board where a club's code goes. */
12
+ const OPEN_WORD = 'open';
13
+ /** A plain span of time: seconds under two minutes, minutes under two hours, else hours. */
14
+ const spanOf = (seconds) => {
15
+ if (seconds < 120)
16
+ return `${String(Math.max(1, Math.ceil(seconds)))} seconds`;
17
+ if (seconds < 7200)
18
+ return `${String(Math.ceil(seconds / 60))} minutes`;
19
+ return `${String(Math.ceil(seconds / 3600))} hours`;
20
+ };
21
+ const failure = (step, code, exit, message) => new CliError(`${code}: ${message}`, exit, step, { title: `${code}: ${message}`, code });
22
+ /** The pool's named refusal with its code first (`club_full: ...`), and for a brake, when to try again. */
23
+ const refused = (step, { status, body }) => {
24
+ // A token the pool no longer knows (revoked, or expired there) while the local sign-in still looks valid: the same cause as no sign-in at all.
25
+ if (status === 401)
26
+ return failure(step, 'not_signed_in', ExitCode.AUTH, 'the pool did not accept your sign-in. Run oars pool login first.');
27
+ const code = isObject(body) ? textIn(body, 'error') : undefined;
28
+ const said = (isObject(body) ? textIn(body, 'message') : undefined) ?? `The pool answered HTTP ${String(status)}.`;
29
+ const wait = isObject(body) ? numberIn(body, 'retry_after') : undefined;
30
+ const title = `${code === undefined ? '' : `${code}: `}${said}${code === 'rate_limited' && wait !== undefined ? ` Try again in ${spanOf(wait)}.` : ''}`;
31
+ const exit = status === 409 ? ExitCode.CONFLICT : status === 404 ? ExitCode.INVALID : exitCodeForProblem(status, code === undefined ? undefined : { code });
32
+ return new CliError(title, exit, step, { title, status, ...(code === undefined ? {} : { code }) });
33
+ };
34
+ /** The pool and the credential held for it, or `not_signed_in` before anything is shown or asked. */
35
+ const credential = async (deps, step, options) => {
36
+ const server = poolServer(deps.env, step, options.server);
37
+ const found = (await loadSignIns(runtimeOf(deps).storeDir, step)).find((entry) => entry.server === server);
38
+ if (found === undefined || found.expiresAt <= deps.now())
39
+ throw failure(step, 'not_signed_in', ExitCode.AUTH, `you are not signed in to ${server}. Run oars pool login first.`);
40
+ return { server, token: found.token };
41
+ };
42
+ /** The text a person is shown before joining, read from the pool, and the hash of exactly those bytes. */
43
+ const textOf = async (deps, server, which, step) => {
44
+ const reply = await ask(deps, step, server, '/v1/clubs/consent', {});
45
+ const entry = isObject(reply.body) ? Reflect.get(reply.body, which) : undefined;
46
+ const text = isObject(entry) ? textIn(entry, 'text') : undefined;
47
+ if (reply.status !== 200 || text === undefined)
48
+ throw reply.status === 200 ? new CliError('The pool did not state the consent text for this board.', ExitCode.UNEXPECTED, step) : refused(step, reply);
49
+ return { text, sha256: createHash('sha256').update(text).digest('hex') };
50
+ };
51
+ /** Shows the consent text and reads yes from a person at a terminal. Nothing is sent without it. */
52
+ const agreed = async (deps, step, text) => {
53
+ if (!isTerminal(deps))
54
+ throw failure(step, 'consent_not_given', ExitCode.AUTH, 'agreeing takes a person at a terminal who reads the text and types yes: run this command from one. Nothing was sent.');
55
+ deps.io.stderr(`\n${text}\n\nType yes to agree, anything else to stop.\n> `);
56
+ const typed = await lineFrom(runtimeOf(deps).stdin);
57
+ if (typed?.trim().toLowerCase() !== 'yes')
58
+ throw failure(step, 'consent_not_given', ExitCode.AUTH, `${typed === undefined ? 'No answer was typed' : 'That was not yes'}. Nothing was sent.`);
59
+ };
60
+ const stringIn = (body, key) => (isObject(body) ? (textIn(body, key) ?? '') : '');
61
+ /** `oars club create <name> --nickname <n>`: a new club with the caller as its first member. The code comes back once. */
62
+ export const clubCreate = async (deps, name, options) => {
63
+ const { server, token } = await credential(deps, CREATE, options);
64
+ const consent = await textOf(deps, server, 'club', CREATE);
65
+ await agreed(deps, CREATE, consent.text);
66
+ const reply = await ask(deps, CREATE, server, '/v1/clubs', { token, body: { name, nickname: options.nickname, consent: consent.sha256 } });
67
+ if (reply.status !== 201)
68
+ throw refused(CREATE, reply);
69
+ const [id, code] = [stringIn(reply.body, 'id'), stringIn(reply.body, 'code')];
70
+ return {
71
+ document: { ok: true, id, name, nickname: options.nickname, code },
72
+ text: [`Club "${name}" created. You are ${options.nickname}.`, `Code: ${code}`, 'This is the only time the code is shown. Share it only with the people you want in the club: anyone who holds it can join.', `See the board with: oars club show ${id}`].join('\n'),
73
+ };
74
+ };
75
+ /** `oars club join <code> --nickname <n>`; the word `open` joins the open board under its own text. */
76
+ export const clubJoin = async (deps, code, options) => {
77
+ const { server, token } = await credential(deps, JOIN, options);
78
+ const open = code.trim().toLowerCase() === OPEN_WORD;
79
+ const consent = await textOf(deps, server, open ? 'open' : 'club', JOIN);
80
+ await agreed(deps, JOIN, consent.text);
81
+ const reply = await ask(deps, JOIN, server, '/v1/clubs/join', { token, body: { code, nickname: options.nickname, consent: consent.sha256 } });
82
+ if (reply.status !== 200)
83
+ throw refused(JOIN, reply);
84
+ const [id, name] = [stringIn(reply.body, 'id'), stringIn(reply.body, 'name')];
85
+ return {
86
+ document: { ok: true, id, name, nickname: options.nickname },
87
+ text: [`Joined "${name}" as ${options.nickname}.`, ...(open ? ['Your row shows on the open board once your account has 90 days of standing.'] : []), `Leave any time with: oars club leave ${id}`].join('\n'),
88
+ };
89
+ };
90
+ /** `oars club leave <club>`: the row leaves every view at once. */
91
+ export const clubLeave = async (deps, club, options) => {
92
+ const { server, token } = await credential(deps, LEAVE, options);
93
+ const reply = await ask(deps, LEAVE, server, `/v1/clubs/${encodeURIComponent(club)}/me`, { token, method: 'DELETE' });
94
+ if (reply.status !== 204)
95
+ throw refused(LEAVE, reply);
96
+ return { document: { ok: true, id: club, left: true }, text: 'You left the club. Your row is gone from its board.' };
97
+ };
98
+ const rowsOf = (body) => {
99
+ const members = isObject(body) ? Reflect.get(body, 'members') : undefined;
100
+ const rows = [];
101
+ for (const member of Array.isArray(members) ? members : []) {
102
+ if (!isObject(member))
103
+ continue;
104
+ rows.push({ place: numberIn(member, 'place') ?? rows.length + 1, nickname: textIn(member, 'nickname') ?? '', ashore: Reflect.get(member, 'ashore') === true, weeksRowing: numberIn(member, 'weeksRowing') ?? 0, activeDays: numberIn(member, 'activeDays') ?? 0, sessions: numberIn(member, 'sessions') ?? 0, strokesThisWeek: numberIn(member, 'strokesThisWeek') ?? 0 });
105
+ }
106
+ return rows;
107
+ };
108
+ /** The board as lines, in the pool's order: a member with no counting week is "ashore" and has no numbers. */
109
+ const tableOf = (rows) => {
110
+ const head = ['#', 'nickname', 'weeks rowing', 'active days', 'sessions', 'strokes this week'];
111
+ const body = rows.map((row) => [String(row.place), row.nickname, ...(row.ashore ? ['ashore', '', '', ''] : [String(row.weeksRowing), String(row.activeDays), String(row.sessions), String(row.strokesThisWeek)])]);
112
+ const widths = head.map((title, at) => Math.max(title.length, ...body.map((line) => (line[at] ?? '').length)));
113
+ return [head, ...body].map((line) => line.map((cell, at) => cell.padEnd(widths[at] ?? 0)).join(' ').trimEnd()).join('\n');
114
+ };
115
+ /** `oars club show <club>`: the board, to its members only. */
116
+ export const clubShow = async (deps, club, options) => {
117
+ const { server, token } = await credential(deps, SHOW, options);
118
+ const reply = await ask(deps, SHOW, server, `/v1/clubs/${encodeURIComponent(club)}`, { token });
119
+ if (reply.status !== 200 || !isObject(reply.body))
120
+ throw refused(SHOW, reply);
121
+ const rows = rowsOf(reply.body);
122
+ return { document: { ok: true, ...reply.body }, text: `${stringIn(reply.body, 'name')}\n${tableOf(rows)}` };
123
+ };
124
+ /** `oars board`: the open board. It is public, so it needs no sign-in. */
125
+ export const board = async (deps, options) => {
126
+ const server = poolServer(deps.env, BOARD, options.server);
127
+ const reply = await ask(deps, BOARD, server, '/v1/board', {});
128
+ if (reply.status !== 200 || !isObject(reply.body))
129
+ throw refused(BOARD, reply);
130
+ const rows = rowsOf(reply.body);
131
+ return { document: { ok: true, ...reply.body }, text: rows.length === 0 ? 'No one is on the open board yet.' : `Open board\n${tableOf(rows)}` };
132
+ };
@@ -1,4 +1,4 @@
1
- // The entry the hook starts, detached, after a cut ask: `node hook-fill-main.js <storeDir> <class> <name> <cwd>`. It prints nothing, answers nothing, and exits 0 whatever
1
+ // The entry the hook starts, detached, at the hand-off mark or after an ask with no time to start: `node hook-fill-main.js <storeDir> <class> <name> <cwd>`. It prints nothing, answers nothing, and exits 0 whatever
2
2
  // happens; nobody waits on it.
3
3
  import { CROSSING_CLASSES } from './checks.js';
4
4
  import { FILL_TIMEOUT_MS, runFill } from './hook-fill.js';
@@ -27,7 +27,7 @@ export type HelperSpawn = (command: string, args: string[], options: {
27
27
  /** The helper outlives the hook: detached, no stdio, never waited on, so the harness's call does not wait for it either. One that cannot start never releases its lock, so the lock goes on its `error`. */
28
28
  export declare const startHelper: (spawn: HelperSpawn, storeDir: string, ask: FillAsk) => void;
29
29
  /**
30
- * Called by the hook after a cut ask: takes the lock and starts the helper. Nothing here throws into the hook: a store that cannot be written, or a helper that cannot
30
+ * Called by the hook at the hand-off mark, or after an ask with no time to start: takes the lock and starts the helper. Nothing here throws into the hook: a store that cannot be written, or a helper that cannot
31
31
  * start, is an answer that does not come. The lock goes when the start throws, so the next sight asks about the name again.
32
32
  */
33
33
  export declare const startFill: (storeDir: string, ask: FillAsk, now: number, start: (storeDir: string, ask: FillAsk) => void) => Promise<void>;
@@ -61,7 +61,7 @@ export const startHelper = (spawn, storeDir, ask) => {
61
61
  child.unref();
62
62
  };
63
63
  /**
64
- * Called by the hook after a cut ask: takes the lock and starts the helper. Nothing here throws into the hook: a store that cannot be written, or a helper that cannot
64
+ * Called by the hook at the hand-off mark, or after an ask with no time to start: takes the lock and starts the helper. Nothing here throws into the hook: a store that cannot be written, or a helper that cannot
65
65
  * start, is an answer that does not come. The lock goes when the start throws, so the next sight asks about the name again.
66
66
  */
67
67
  export const startFill = async (storeDir, ask, now, start) => {
@@ -5,14 +5,15 @@ import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { performance } from 'node:perf_hooks';
7
7
  import { defaultConfigDir } from '../credentials.js';
8
- import { DEADLINE_MS } from './hook.js';
8
+ import { deadlineOf, handOffMarkOf } from './hook.js';
9
9
  import { isLocked, startFill, startHelper } from './hook-fill.js';
10
10
  import { fireRecorder } from './hook-record.js';
11
11
  import { runHookProcess } from './hook-process.js';
12
12
  import { managerReader } from './registry.js';
13
13
  import { hookEmitter } from '../telemetry/hook-emit.js';
14
- /** The process itself ends this long after `DEADLINE_MS`. */
14
+ /** The process itself ends this long after the deadline. */
15
15
  const GRACE_MS = 100;
16
+ const deadlineMs = deadlineOf(process.env);
16
17
  const readStdin = async () => {
17
18
  process.stdin.setEncoding('utf8');
18
19
  let text = '';
@@ -27,8 +28,9 @@ const depsFor = (stage) => {
27
28
  now: Date.now,
28
29
  // `performance.now()` counts from the start of the process, so node's own start is inside the budget.
29
30
  clock: () => performance.now(),
30
- deadlineMs: DEADLINE_MS,
31
- registry: managerReader(undefined, DEADLINE_MS),
31
+ deadlineMs,
32
+ handOffMarkMs: handOffMarkOf(process.env, deadlineMs),
33
+ registry: managerReader(undefined, deadlineMs),
32
34
  fill: (ask) => startFill(storeDir, ask, Date.now(), (dir, one) => { startHelper(spawn, dir, one); }),
33
35
  locked: (ask) => isLocked(storeDir, ask, Date.now()),
34
36
  stage,
@@ -41,7 +43,7 @@ await runHookProcess({
41
43
  deps: depsFor,
42
44
  exit: (code) => process.exit(code),
43
45
  write: (text) => new Promise((done) => process.stdout.write(text, () => { done(); })),
44
- deadlineMs: DEADLINE_MS + GRACE_MS - performance.now(),
46
+ deadlineMs: deadlineMs + GRACE_MS - performance.now(),
45
47
  clock: () => performance.now(),
46
48
  record: fireRecorder(emit),
47
49
  });