@north-light/crouter 0.3.180 → 0.3.182

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 (137) hide show
  1. package/dist/api/client.d.ts +10 -1
  2. package/dist/api/client.js +13 -0
  3. package/dist/api/dto/broker.d.ts +32 -0
  4. package/dist/api/dto/crons.d.ts +17 -0
  5. package/dist/api/dto/memory.d.ts +17 -0
  6. package/dist/api/dto/memory.js +6 -0
  7. package/dist/api/dto/messages.d.ts +5 -0
  8. package/dist/api/dto/reviews.d.ts +8 -4
  9. package/dist/api/index.d.ts +1 -0
  10. package/dist/api/index.js +1 -0
  11. package/dist/api/routes.d.ts +2 -0
  12. package/dist/api/routes.js +4 -0
  13. package/dist/build-root.d.ts +7 -0
  14. package/dist/build-root.js +21 -0
  15. package/dist/builtin-memory/insights/init.md +48 -3
  16. package/dist/builtin-pi-packages/pi-crtr-extensions/__tests__/insights-active-init.test.ts +98 -0
  17. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/claude-plugin-commands.ts +7 -50
  18. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +16 -1
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/pi-shell-runner.ts +34 -0
  20. package/dist/cli.js +1 -2
  21. package/dist/clients/attach/__tests__/context-message.test.js +5 -2
  22. package/dist/clients/attach/assets/README.md +7 -0
  23. package/dist/clients/attach/assets/whip-06.mp3 +0 -0
  24. package/dist/clients/attach/assets/whip-crack.mp3 +0 -0
  25. package/dist/clients/attach/assets/whip-snap.mp3 +0 -0
  26. package/dist/clients/attach/chrome/canvas-panels.d.ts +7 -1
  27. package/dist/clients/attach/chrome/canvas-panels.js +20 -3
  28. package/dist/clients/attach/chrome/review-wait.d.ts +6 -0
  29. package/dist/clients/attach/chrome/review-wait.js +22 -0
  30. package/dist/clients/attach/chrome/roster.js +23 -2
  31. package/dist/clients/attach/chrome/widgets.js +1 -1
  32. package/dist/clients/attach/input/controller.js +4 -3
  33. package/dist/clients/attach/overlays/mcp.js +3 -1
  34. package/dist/clients/attach/render/chat-view.js +1 -1
  35. package/dist/clients/attach/session/whip.d.ts +1 -0
  36. package/dist/clients/attach/session/whip.js +26 -0
  37. package/dist/clients/attach/slash/dispatch.js +2 -0
  38. package/dist/clients/attach/viewer.js +578 -573
  39. package/dist/clients/inbox/review/document-surface.d.ts +1 -1
  40. package/dist/clients/inbox/review/document-surface.js +4 -4
  41. package/dist/clients/inbox/review/launch.js +16 -4
  42. package/dist/clients/inbox/review/review-client.d.ts +9 -4
  43. package/dist/clients/inbox/review/review-client.js +3 -0
  44. package/dist/commands/cron.js +30 -8
  45. package/dist/commands/human/prompts.d.ts +7 -2
  46. package/dist/commands/human/prompts.js +15 -10
  47. package/dist/commands/human.js +1 -2
  48. package/dist/commands/memory/find.js +11 -8
  49. package/dist/commands/memory/read.js +111 -11
  50. package/dist/commands/memory/write.js +1 -1
  51. package/dist/commands/memory.js +1 -1
  52. package/dist/commands/pkg/market-manage.d.ts +13 -0
  53. package/dist/commands/pkg/market-manage.js +39 -33
  54. package/dist/commands/pkg/plugin-inspect.js +4 -3
  55. package/dist/commands/pkg/plugin-manage.js +12 -11
  56. package/dist/commands/surface/node/focus.js +1 -2
  57. package/dist/commands/sys/doctor.js +4 -4
  58. package/dist/commands/sys/setup-core.d.ts +14 -7
  59. package/dist/commands/sys/setup-core.js +66 -11
  60. package/dist/commands/sys/setup-wizard.js +2 -2
  61. package/dist/commands/sys/setup.js +1 -1
  62. package/dist/core/__tests__/cron-held-settlement.test.d.ts +1 -0
  63. package/dist/core/__tests__/cron-held-settlement.test.js +222 -0
  64. package/dist/core/__tests__/helpers/harness.js +1 -2
  65. package/dist/core/__tests__/phase4-review-store.test.js +1 -0
  66. package/dist/core/__tests__/serial/command-plugins.test.js +88 -1
  67. package/dist/core/__tests__/session-model.test.js +5 -3
  68. package/dist/core/bootstrap.d.ts +0 -4
  69. package/dist/core/bootstrap.js +1 -55
  70. package/dist/core/canvas/crons.d.ts +54 -2
  71. package/dist/core/canvas/crons.js +48 -4
  72. package/dist/core/canvas/db.js +23 -0
  73. package/dist/core/command-manifests/manifest.d.ts +11 -0
  74. package/dist/core/command-manifests/manifest.js +45 -4
  75. package/dist/core/command-manifests/schema.d.ts +1 -1
  76. package/dist/core/command-plugins/bundle.d.ts +1 -0
  77. package/dist/core/command-plugins/bundle.js +3 -3
  78. package/dist/core/command-plugins/discovery.d.ts +5 -2
  79. package/dist/core/command-plugins/discovery.js +5 -5
  80. package/dist/core/command-plugins/help-addenda.d.ts +12 -0
  81. package/dist/core/command-plugins/help-addenda.js +30 -0
  82. package/dist/core/command.js +25 -2
  83. package/dist/core/config.js +0 -1
  84. package/dist/core/human/convention.d.ts +0 -1
  85. package/dist/core/human/convention.js +0 -6
  86. package/dist/core/keybindings/inbox.d.ts +6 -8
  87. package/dist/core/keybindings/inbox.js +6 -15
  88. package/dist/core/keybindings/index.d.ts +1 -1
  89. package/dist/core/keybindings/index.js +1 -1
  90. package/dist/core/memory/doc-link-grammar.js +4 -1
  91. package/dist/core/memory-resolver.d.ts +28 -4
  92. package/dist/core/memory-resolver.js +51 -39
  93. package/dist/core/review/stage.js +1 -0
  94. package/dist/core/review/store.d.ts +5 -0
  95. package/dist/core/review/store.js +10 -0
  96. package/dist/core/review/types.d.ts +4 -0
  97. package/dist/core/runtime/broker/event-projection.d.ts +8 -1
  98. package/dist/core/runtime/broker/event-projection.js +25 -1
  99. package/dist/core/runtime/broker/frame-dispatch.d.ts +2 -0
  100. package/dist/core/runtime/broker/frame-dispatch.js +50 -8
  101. package/dist/core/runtime/broker/inbox.d.ts +4 -0
  102. package/dist/core/runtime/broker/inbox.js +17 -8
  103. package/dist/core/runtime/broker/message-ledger.d.ts +53 -0
  104. package/dist/core/runtime/broker/message-ledger.js +143 -0
  105. package/dist/core/runtime/broker/rebind.js +14 -0
  106. package/dist/core/runtime/broker-protocol.d.ts +46 -1
  107. package/dist/core/runtime/broker.js +11 -2
  108. package/dist/core/runtime/interactive-deliver.d.ts +5 -2
  109. package/dist/core/runtime/interactive-deliver.js +6 -3
  110. package/dist/core/runtime/shell-expansion.d.ts +32 -0
  111. package/dist/core/runtime/shell-expansion.js +102 -0
  112. package/dist/core/session-model/session-state.d.ts +9 -4
  113. package/dist/core/session-model/session-state.js +5 -1
  114. package/dist/daemon/api/handlers/broker-ops.js +8 -0
  115. package/dist/daemon/api/handlers/crons.js +14 -1
  116. package/dist/daemon/api/handlers/inbox.js +5 -0
  117. package/dist/daemon/api/handlers/memory.d.ts +2 -0
  118. package/dist/daemon/api/handlers/memory.js +48 -0
  119. package/dist/daemon/api/handlers/messages.js +7 -1
  120. package/dist/daemon/api/handlers/reviews.js +7 -5
  121. package/dist/daemon/api/map.js +3 -0
  122. package/dist/daemon/api/server.js +2 -0
  123. package/dist/daemon/cron-run.js +71 -3
  124. package/dist/daemon/crtrd.js +3 -0
  125. package/dist/daemon/reconcilers/pending-review-submit.d.ts +7 -0
  126. package/dist/daemon/reconcilers/pending-review-submit.js +35 -0
  127. package/dist/daemon/review/companion.d.ts +8 -0
  128. package/dist/daemon/review/companion.js +35 -0
  129. package/dist/daemon/review/deliver.js +2 -1
  130. package/dist/daemon/review/finish.d.ts +29 -2
  131. package/dist/daemon/review/finish.js +75 -2
  132. package/dist/pi-extensions/canvas-inbox-watcher.js +34 -1
  133. package/dist/shared/generated-context.d.ts +3 -4
  134. package/dist/shared/generated-context.js +24 -6
  135. package/dist/types.d.ts +0 -1
  136. package/package.json +1 -1
  137. package/runtime.lock.json +2 -2
@@ -0,0 +1,222 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/cron-held-settlement.test.ts
2
+ //
3
+ // The owed-gate (exit 75) settlement branch: a scheduled run exiting
4
+ // EX_TEMPFAIL declares "this occurrence is owed but not currently eligible."
5
+ // The row is PARKED (held=1, occurrence not spent) instead of disposed — no
6
+ // failure, no escalation, no delivery, no on-change hash, no one-shot
7
+ // consumption — and a poke (`pokeHeldCrons`) re-dues every held active row
8
+ // now. These pin the branch's truth table: the exact class of scheduling
9
+ // defect that otherwise fails silently weeks later.
10
+ import { after, afterEach, before, test } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { mkdtempSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { setTimeout as delay } from 'node:timers/promises';
16
+ import { closeDb, openDb } from '../canvas/db.js';
17
+ import { armCron, cancelCron, getCron, listCronRuns, pokeHeldCrons, setCronState, } from '../canvas/crons.js';
18
+ import { executeCron, runDueCrons } from '../../daemon/cron-run.js';
19
+ const HELD_DELIVERED = 'deferred (exit 75) — held for poke';
20
+ const HELD_FAR_FUTURE = '9999-12-31T23:59:59.999Z';
21
+ let home;
22
+ let realHome;
23
+ function arm(overrides) {
24
+ armCron({
25
+ name: overrides.cron_id,
26
+ created_by: null,
27
+ fire_at: new Date(Date.now() - 1_000).toISOString(),
28
+ recur: null,
29
+ tz: null,
30
+ expires_at: null,
31
+ anchor_node: null,
32
+ cancel_on_wake: false,
33
+ cwd: home,
34
+ env_json: null,
35
+ profile: null,
36
+ scope: 'profile',
37
+ run_timeout_s: 30,
38
+ overlap: 'skip',
39
+ on_output: 'silent',
40
+ sink: '{}',
41
+ tier: 'normal',
42
+ ...overrides,
43
+ });
44
+ }
45
+ before(() => {
46
+ home = mkdtempSync(join(tmpdir(), 'crtr-cron-held-'));
47
+ realHome = process.env['CRTR_HOME'];
48
+ process.env['CRTR_HOME'] = home;
49
+ });
50
+ afterEach(() => {
51
+ closeDb();
52
+ });
53
+ after(() => {
54
+ closeDb();
55
+ rmSync(home, { recursive: true, force: true });
56
+ if (realHome === undefined)
57
+ delete process.env['CRTR_HOME'];
58
+ else
59
+ process.env['CRTR_HOME'] = realHome;
60
+ });
61
+ test('a recurring exit-75 run parks: held, backstop untouched, no disposition', async () => {
62
+ // on-change makes the skipped disposition observable: were the branch to
63
+ // fall through, a failed run would escalate (pause + node) and the hash
64
+ // bookkeeping would write last_output_hash.
65
+ arm({
66
+ cron_id: 'gate-recurring',
67
+ command: 'exit 75',
68
+ recur: JSON.stringify({ every: '1h' }),
69
+ on_output: 'on-change',
70
+ });
71
+ const settlements = runDueCrons(Date.now());
72
+ assert.equal(settlements.length, 1, 'the due gated run was admitted');
73
+ // The pre-run advance already wrote the natural next slot — the backstop.
74
+ const backstop = getCron('gate-recurring').fire_at;
75
+ await Promise.all(settlements);
76
+ const row = getCron('gate-recurring');
77
+ assert.equal(row.held, true);
78
+ assert.equal(row.fire_at, backstop, 'settlement must not touch the pre-advanced slot');
79
+ assert.equal(row.state, 'active', 'held is not a failure — no pause, no escalation');
80
+ assert.equal(row.run_state, 'idle');
81
+ assert.equal(row.last_output_hash, null, 'on-change bookkeeping is skipped for a held run');
82
+ const runs = listCronRuns('gate-recurring');
83
+ assert.equal(runs.length, 1);
84
+ assert.equal(runs[0].exit_code, 75);
85
+ assert.equal(runs[0].delivered, HELD_DELIVERED);
86
+ cancelCron('gate-recurring');
87
+ });
88
+ test('a held one-shot without expires is retained at the far-future backstop', async () => {
89
+ arm({ cron_id: 'gate-oneshot', command: 'exit 75' });
90
+ await Promise.all(runDueCrons(Date.now()));
91
+ const row = getCron('gate-oneshot');
92
+ assert.notEqual(row, null, 'the occurrence is not spent — the one-shot is not consumed');
93
+ assert.equal(row.held, true);
94
+ assert.equal(row.fire_at, HELD_FAR_FUTURE, 'poke-only: never due on the clock');
95
+ cancelCron('gate-oneshot');
96
+ });
97
+ test('a held one-shot with --expires waits at expiry, then is deleted unfired', async () => {
98
+ const expiresAt = new Date(Date.now() + 1_000).toISOString();
99
+ arm({ cron_id: 'gate-oneshot-expires', command: 'exit 75', expires_at: expiresAt });
100
+ await Promise.all(runDueCrons(Date.now()));
101
+ const row = getCron('gate-oneshot-expires');
102
+ assert.equal(row.held, true);
103
+ assert.equal(row.fire_at, expiresAt, 'expiry bounds the wait');
104
+ // Past the bound: expiry runs before due processing, so the row is deleted
105
+ // without ever firing blind at its expiry instant.
106
+ const afterExpiry = runDueCrons(new Date(expiresAt).getTime() + 1_000);
107
+ assert.equal(afterExpiry.length, 0, 'the expired row never fired');
108
+ await Promise.all(afterExpiry);
109
+ assert.equal(getCron('gate-oneshot-expires'), null, 'deleted unfired at expiry');
110
+ });
111
+ test('a poke re-dues held active rows, skips paused ones, stamps every row', async () => {
112
+ arm({ cron_id: 'poke-active', command: 'exit 75', recur: JSON.stringify({ every: '1h' }) });
113
+ await Promise.all(runDueCrons(Date.now()));
114
+ arm({ cron_id: 'poke-paused', command: 'exit 75', recur: JSON.stringify({ every: '1h' }) });
115
+ await Promise.all(runDueCrons(Date.now()));
116
+ setCronState('poke-paused', 'paused');
117
+ const pausedBackstop = getCron('poke-paused').fire_at;
118
+ arm({ cron_id: 'poke-ordinary', command: 'exit 0', fire_at: new Date(Date.now() + 3_600_000).toISOString() });
119
+ const nowIso = new Date().toISOString();
120
+ assert.equal(pokeHeldCrons(nowIso), 1, 'only the held ACTIVE row unparks');
121
+ const active = getCron('poke-active');
122
+ assert.equal(active.held, false);
123
+ assert.equal(active.fire_at, nowIso, 'unparked: due now');
124
+ const paused = getCron('poke-paused');
125
+ assert.equal(paused.held, true, 'pause is user-owned — a poke never unparks a paused row');
126
+ assert.equal(paused.fire_at, pausedBackstop);
127
+ for (const id of ['poke-active', 'poke-paused', 'poke-ordinary']) {
128
+ assert.equal(getCron(id).last_poke_at, nowIso, `last_poke_at stamps every row (${id})`);
129
+ cancelCron(id);
130
+ }
131
+ });
132
+ test('a poke never unparks a row whose gate check is in flight', async () => {
133
+ // A held recurring row at its backstop slot: due selection pre-advances
134
+ // fire_at, then leases the run. A poke landing mid-run must not touch the
135
+ // row — unparking would overwrite the advanced slot with `now`, and a
136
+ // PASSING gate (ordinary settlement only clears `held`) would then fire a
137
+ // second time on the next tick.
138
+ arm({ cron_id: 'poke-inflight', command: 'sleep 0.3; exit 0', recur: JSON.stringify({ every: '1h' }) });
139
+ openDb().prepare('UPDATE crons SET held = 1 WHERE cron_id = ?').run('poke-inflight');
140
+ const settlements = runDueCrons(Date.now());
141
+ assert.equal(settlements.length, 1, 'the held row past its backstop is due');
142
+ const advanced = getCron('poke-inflight').fire_at;
143
+ await delay(50);
144
+ const nowIso = new Date().toISOString();
145
+ assert.equal(pokeHeldCrons(nowIso), 0, 'a leased row is stamped, never unparked');
146
+ await Promise.all(settlements);
147
+ const row = getCron('poke-inflight');
148
+ assert.equal(row.held, false, 'the passing gate resolved — ordinary settlement clears held');
149
+ assert.equal(row.fire_at, advanced, 'the pre-advanced slot survives the poke — no double fire');
150
+ assert.equal(row.last_poke_at, nowIso);
151
+ cancelCron('poke-inflight');
152
+ });
153
+ test('a row paused mid-run parks on exit 75 even when a poke stamped it', async () => {
154
+ // Pause lands while the gate check is in flight, then a poke stamps every
155
+ // row (skipping the paused row's unpark). The settling 75 must PARK, not
156
+ // re-due: a re-due would leave a past-due unheld row that `cron resume`
157
+ // fires blind even though its gate never passed.
158
+ arm({ cron_id: 'paused-midrun', command: 'sleep 0.3; exit 75', recur: JSON.stringify({ every: '1h' }) });
159
+ const settlements = runDueCrons(Date.now());
160
+ assert.equal(settlements.length, 1);
161
+ const backstop = getCron('paused-midrun').fire_at;
162
+ await delay(50);
163
+ setCronState('paused-midrun', 'paused');
164
+ pokeHeldCrons(new Date().toISOString());
165
+ await Promise.all(settlements);
166
+ const row = getCron('paused-midrun');
167
+ assert.equal(row.state, 'paused');
168
+ assert.equal(row.held, true, 'paused mid-run parks — resume re-checks the gate, never fires it blind');
169
+ assert.equal(row.fire_at, backstop, 'the pre-advanced backstop survives');
170
+ cancelCron('paused-midrun');
171
+ });
172
+ test('a poke landing mid-run re-dues the settling row instead of parking it (F2)', async () => {
173
+ arm({ cron_id: 'gate-midrun-poke', command: 'sleep 0.3; exit 75' });
174
+ const settlement = executeCron(getCron('gate-midrun-poke'), { outOfBand: false });
175
+ await delay(50);
176
+ // The gate check is in flight: the poke's WHERE held=1 misses it.
177
+ assert.equal(pokeHeldCrons(new Date().toISOString()), 0);
178
+ const record = await settlement;
179
+ assert.equal(record?.exit_code, 75);
180
+ const row = getCron('gate-midrun-poke');
181
+ assert.equal(row.held, false, 're-dued, not parked — the poke is not lost');
182
+ assert.notEqual(row.fire_at, HELD_FAR_FUTURE);
183
+ assert.ok(Math.abs(new Date(row.fire_at).getTime() - Date.now()) < 5_000, `re-dued to settle time, got ${row.fire_at}`);
184
+ cancelCron('gate-midrun-poke');
185
+ });
186
+ test('an out-of-band exit 75 reports the result and never touches scheduling state (F3)', async () => {
187
+ const futureFire = new Date(Date.now() + 3_600_000).toISOString();
188
+ arm({ cron_id: 'oob-75', command: 'exit 75', recur: JSON.stringify({ every: '1h' }), fire_at: futureFire });
189
+ const record = await executeCron(getCron('oob-75'), { outOfBand: true });
190
+ assert.equal(record?.exit_code, 75);
191
+ assert.notEqual(record?.delivered, HELD_DELIVERED, 'a manual verification is not an owed gate');
192
+ const row = getCron('oob-75');
193
+ assert.equal(row.held, false);
194
+ assert.equal(row.fire_at, futureFire);
195
+ cancelCron('oob-75');
196
+ });
197
+ test('a timeout kill is a real failure even when the command would exit 75 (F3)', async () => {
198
+ arm({
199
+ cron_id: 'timeout-75',
200
+ command: 'sleep 3; exit 75',
201
+ recur: JSON.stringify({ every: '1h' }),
202
+ run_timeout_s: 1,
203
+ });
204
+ await Promise.all(runDueCrons(Date.now()));
205
+ const row = getCron('timeout-75');
206
+ assert.equal(row.held, false, 'a timed-out run takes the failure path, never the held branch');
207
+ const runs = listCronRuns('timeout-75');
208
+ assert.equal(runs[0].exit_code, -1);
209
+ assert.notEqual(runs[0].delivered, HELD_DELIVERED);
210
+ cancelCron('timeout-75');
211
+ });
212
+ test('an ordinary scheduled settlement clears held (the backstop run resolves the gate)', async () => {
213
+ arm({ cron_id: 'clear-held', command: 'exit 0', recur: JSON.stringify({ every: '1h' }) });
214
+ // Simulate a parked row whose backstop slot has arrived: held rows are
215
+ // deliberately NOT filtered out of the due query.
216
+ openDb().prepare('UPDATE crons SET held = 1 WHERE cron_id = ?').run('clear-held');
217
+ const settlements = runDueCrons(Date.now());
218
+ assert.equal(settlements.length, 1, 'a held row past its backstop is due');
219
+ await Promise.all(settlements);
220
+ assert.equal(getCron('clear-held').held, false, 'the resolved gate unparks the row');
221
+ cancelCron('clear-held');
222
+ });
@@ -103,9 +103,8 @@ function cleanBaseEnv() {
103
103
  e[k] = v;
104
104
  for (const k of CANVAS_ENV_KEYS)
105
105
  delete e[k];
106
- // Contain per-invocation bootstrap + auto-update side effects (they write to
106
+ // Contain per-invocation scope-init + auto-update side effects (they write to
107
107
  // ~/.crouter / ~/.claude / ~/.pi, NOT under CRTR_HOME — HOME is contained too).
108
- e['CRTR_NO_BOOTSTRAP'] = '1';
109
108
  e['CRTR_NO_AUTO_UPDATE'] = '1';
110
109
  e['CRTR_NO_EXPORTS'] = '1';
111
110
  e['CRTR_NO_AUTO_INIT'] = '1';
@@ -29,6 +29,7 @@ function row(overrides = {}) {
29
29
  created: '2026-01-01T00:00:00.000Z',
30
30
  captured_at: '2026-01-01T00:00:00.000Z',
31
31
  opened_at: null,
32
+ submit_requested_at: null,
32
33
  output_path: null,
33
34
  subtitle: null,
34
35
  approved_at: null,
@@ -30,6 +30,7 @@ import { join, dirname } from 'node:path';
30
30
  import { fileURLToPath } from 'node:url';
31
31
  import { discoverCommandContributions, effectiveCommandPlugins, validatePluginCommands, } from '../../command-plugins/discovery.js';
32
32
  import { composeExternalSubtrees, adaptPluginContributions } from '../../command-plugins/compose.js';
33
+ import { collectHelpAddenda } from '../../command-plugins/help-addenda.js';
33
34
  import { resolveCommandRegistry } from '../../command-manifests/registry.js';
34
35
  import { defineRoot } from '../../command.js';
35
36
  import { renderRoot, renderBranch, renderLeafArgv } from '../../help.js';
@@ -379,7 +380,7 @@ describe('end-to-end CLI invocation', () => {
379
380
  resetScopeCache();
380
381
  return execFileSync(process.execPath, [cli, ...args], {
381
382
  cwd: emptyStart,
382
- env: { ...process.env, HOME: home, CRTR_NO_BOOTSTRAP: '1', CRTR_FRONT_DOOR: '' },
383
+ env: { ...process.env, HOME: home, CRTR_FRONT_DOOR: '' },
383
384
  encoding: 'utf8',
384
385
  });
385
386
  };
@@ -511,6 +512,92 @@ describe('typed validation issues', () => {
511
512
  });
512
513
  });
513
514
  // ---------------------------------------------------------------------------
515
+ // Plugin help addenda — the append-only product-guidance seam. An addendum
516
+ // that quietly stops rendering is exactly the silent-absence class: pin that a
517
+ // valid entry survives validation and is collected attributed, and that the
518
+ // STRICT gates (install/bundle, which pass coreCommandPaths) reject a key
519
+ // naming no core command path while the tolerant compose path still mounts the
520
+ // plugin's tree. The help-render lookup receives the gate's exact inputs, so a
521
+ // map rejected at ingress renders nothing — render never disagrees with the
522
+ // install report.
523
+ // ---------------------------------------------------------------------------
524
+ describe('plugin help addenda', () => {
525
+ const gate = (paths) => ({
526
+ reservedCoreNames: RESERVED,
527
+ coreCommandPaths: new Set(paths),
528
+ });
529
+ test('a helpAddenda entry survives validation and is collected attributed to its plugin', () => {
530
+ const m = commandsJson();
531
+ m['helpAddenda'] = { cron: 'Gate on the device: exit 75 parks the occurrence.' };
532
+ installPlugin(userRoot, 'northlight-fixture', { manifest: m });
533
+ resetScopeCache();
534
+ const p = listInstalledPluginsInRoot('user', userRoot).find((x) => x.name === 'northlight-fixture');
535
+ const v = validatePluginCommands(p, RESERVED);
536
+ assert.equal(v.issues.length, 0, JSON.stringify(v.issues));
537
+ assert.deepEqual(v.manifest?.helpAddenda, { cron: 'Gate on the device: exit 75 parks the occurrence.' });
538
+ // The help-render lookup: attributed, and empty for an unaddended path.
539
+ assert.deepEqual(collectHelpAddenda('cron', gate(['cron', 'node']), emptyStart), [
540
+ { plugin: 'northlight-fixture', text: 'Gate on the device: exit 75 parks the occurrence.' },
541
+ ]);
542
+ assert.deepEqual(collectHelpAddenda('node', gate(['cron', 'node']), emptyStart), []);
543
+ });
544
+ test('a key naming no core command path fails the strict gate and renders nothing, even beside a valid key', () => {
545
+ const m = commandsJson();
546
+ m['helpAddenda'] = {
547
+ cron: 'a valid target beside the typo',
548
+ 'cronn add': 'a typo that must fail at install, never silently at render',
549
+ };
550
+ installPlugin(userRoot, 'p', { manifest: m });
551
+ resetScopeCache();
552
+ const p = listInstalledPluginsInRoot('user', userRoot).find((x) => x.name === 'p');
553
+ // Strict (install/bundle/doctor): the typo is a typed issue, zero contributions.
554
+ const strict = validatePluginCommands(p, RESERVED, new Set(['cron', 'cron add']));
555
+ assert.ok(strict.issues.some((i) => i.code === 'command_help_addendum_invalid'), JSON.stringify(strict.issues));
556
+ assert.equal(strict.contributions.length, 0);
557
+ assert.equal(strict.manifest, undefined);
558
+ // Tolerant (runtime compose): an already-installed plugin's typo'd addendum
559
+ // must not drop its whole command tree.
560
+ const tolerant = validatePluginCommands(p, RESERVED);
561
+ assert.equal(tolerant.issues.length, 0, JSON.stringify(tolerant.issues));
562
+ assert.equal(tolerant.contributions.length, 1);
563
+ // The render lookup reproduces the GATE verdict, not the compose one: the
564
+ // rejected map contributes nothing — including its valid key — so render
565
+ // can never show what the install report said was rejected.
566
+ assert.deepEqual(collectHelpAddenda('cron', gate(['cron', 'cron add']), emptyStart), []);
567
+ });
568
+ test('an installed addendum renders through the real help path as an attributed block', async () => {
569
+ const m = commandsJson();
570
+ m['helpAddenda'] = { cron: 'ADDENDUM RENDERED THROUGH THE REAL HELP PATH' };
571
+ installPlugin(userRoot, 'northlight-fixture', { manifest: m });
572
+ resetScopeCache();
573
+ // Pin the durable wiring, not the lookup: runCli → renderNodeWithAddenda →
574
+ // collectHelpAddenda → <plugin-help> serialization. This is the exact
575
+ // silent-absence class — the dynamic import or the append could regress
576
+ // with every lookup-level assertion still green.
577
+ const { resolveRoot } = await import('../../../build-root.js');
578
+ const { runCli } = await import('../../command.js');
579
+ const root = await resolveRoot('cron');
580
+ const chunks = [];
581
+ const realWrite = process.stdout.write.bind(process.stdout);
582
+ const realCwd = process.cwd();
583
+ process.chdir(emptyStart); // startDir with no project scope — user scope is the fixture HOME
584
+ process.stdout.write = ((chunk) => {
585
+ chunks.push(String(chunk));
586
+ return true;
587
+ });
588
+ try {
589
+ await runCli(root, ['node', 'crtr', 'cron', '-h']);
590
+ }
591
+ finally {
592
+ process.stdout.write = realWrite;
593
+ process.chdir(realCwd);
594
+ }
595
+ const out = chunks.join('');
596
+ assert.ok(out.includes('<plugin-help plugin="northlight-fixture">'), out.slice(-400));
597
+ assert.ok(out.includes('ADDENDUM RENDERED THROUGH THE REAL HELP PATH'), out.slice(-400));
598
+ });
599
+ });
600
+ // ---------------------------------------------------------------------------
514
601
  // Lifecycle surfaces — the validator's verdict must reach the caller WITHOUT
515
602
  // executing the plugin binary, and a cross-plugin top-level collision must not
516
603
  // read as a pass on any claimant.
@@ -98,11 +98,13 @@ test('a welcome is a COMPLETE catch-up: a re-welcome drops a dead broker s level
98
98
  const s0 = fold(initialSessionState(), [
99
99
  welcome(),
100
100
  { type: 'extension_ui_request', id: 'u1', method: 'setStatus', statusKey: 'gone', statusText: 'stale' },
101
- { type: 'queue_update', steering: ['one'], followUp: [] },
101
+ { type: 'queue_update', steering: ['one', 'two'], followUp: [], steeringIds: ['id-1'] },
102
102
  ]);
103
103
  assert.equal(s0.display.statuses['gone'], 'stale');
104
- assert.deepEqual(s0.queued, ['one']);
105
- assert.equal(s0.engine?.pendingMessageCount, 1);
104
+ // Broker-enriched ids zip positionally; a position without one (older broker,
105
+ // short array) still renders as a bare-text entry.
106
+ assert.deepEqual(s0.queued, [{ id: 'id-1', text: 'one' }, { text: 'two' }]);
107
+ assert.equal(s0.engine?.pendingMessageCount, 2);
106
108
  // A fresh broker (post-revive) welcomes with its OWN levels and an empty queue,
107
109
  // and emits no queue_update to overwrite the panel — so the welcome must.
108
110
  const s1 = reduce(s0, welcome());
@@ -1,5 +1 @@
1
- export declare const OFFICIAL_MARKETPLACE_NAME = "crouter-official-marketplace";
2
- export declare const OFFICIAL_MARKETPLACE_URL = "https://github.com/crouton-labs/crouter-official-marketplace.git";
3
- export declare const OFFICIAL_MARKETPLACE_REF = "main";
4
- export declare function ensureOfficialMarketplace(argv: string[]): void;
5
1
  export declare function ensureProjectScope(argv: string[]): void;
@@ -1,14 +1,8 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
3
  import { findProjectScopeRoot, resetScopeCache, userScopeRoot } from './scope.js';
4
- import { ensureDir, pathExists, removePath, nowIso } from './fs-utils.js';
5
- import { readConfig, readState, updateConfig, updateState, ensureScopeInitialized } from './config.js';
6
- import { clone } from './git.js';
7
- import { readMarketplaceManifest } from './manifest.js';
4
+ import { ensureScopeInitialized } from './config.js';
8
5
  import { CRTR_DIR_NAME } from '../types.js';
9
- export const OFFICIAL_MARKETPLACE_NAME = 'crouter-official-marketplace';
10
- export const OFFICIAL_MARKETPLACE_URL = 'https://github.com/crouton-labs/crouter-official-marketplace.git';
11
- export const OFFICIAL_MARKETPLACE_REF = 'main';
12
6
  const SKIP_SUBCOMMANDS = new Set([
13
7
  'help',
14
8
  '--help',
@@ -22,54 +16,6 @@ function shouldSkipForArgv(argv) {
22
16
  return true;
23
17
  return SKIP_SUBCOMMANDS.has(sub);
24
18
  }
25
- export function ensureOfficialMarketplace(argv) {
26
- try {
27
- if (process.env.CRTR_NO_BOOTSTRAP === '1')
28
- return;
29
- if (shouldSkipForArgv(argv))
30
- return;
31
- const state = readState('user');
32
- if (state.bootstrap_done === true)
33
- return;
34
- const cfg = readConfig('user');
35
- if (cfg.marketplaces[OFFICIAL_MARKETPLACE_NAME] !== undefined) {
36
- updateState('user', (s) => {
37
- s.bootstrap_done = true;
38
- });
39
- return;
40
- }
41
- const root = userScopeRoot();
42
- ensureScopeInitialized('user', root);
43
- const mktsDir = join(root, 'marketplaces');
44
- ensureDir(mktsDir);
45
- const dest = join(mktsDir, OFFICIAL_MARKETPLACE_NAME);
46
- if (pathExists(dest)) {
47
- removePath(dest);
48
- }
49
- clone(OFFICIAL_MARKETPLACE_URL, dest, { depth: 1, ref: OFFICIAL_MARKETPLACE_REF });
50
- const manifest = readMarketplaceManifest(dest);
51
- if (manifest === null) {
52
- removePath(dest);
53
- return;
54
- }
55
- updateConfig('user', (c) => {
56
- c.marketplaces[OFFICIAL_MARKETPLACE_NAME] = {
57
- url: OFFICIAL_MARKETPLACE_URL,
58
- ref: OFFICIAL_MARKETPLACE_REF,
59
- installed_at: nowIso(),
60
- };
61
- });
62
- updateState('user', (s) => {
63
- s.bootstrap_done = true;
64
- });
65
- }
66
- catch (e) {
67
- if (process.env.CRTR_DEBUG === '1') {
68
- const msg = e instanceof Error ? e.message : String(e);
69
- process.stderr.write(`crtr: bootstrap error: ${msg}\n`);
70
- }
71
- }
72
- }
73
19
  export function ensureProjectScope(argv) {
74
20
  try {
75
21
  if (process.env.CRTR_NO_AUTO_INIT === '1')
@@ -26,6 +26,19 @@ export interface Cron {
26
26
  sink: string;
27
27
  tier: string;
28
28
  last_output_hash: string | null;
29
+ /** True while the row is PARKED by the exit-75 owed-gate disposition: the
30
+ * last scheduled run declared "owed but not currently eligible", so the
31
+ * occurrence was not spent. A poke re-dues the row now; otherwise a
32
+ * recurring row re-checks at its already-advanced natural slot and a held
33
+ * one-shot waits at its backstop `fire_at` (expires_at, else far future).
34
+ * Deliberately NOT part of the due query — a held row is non-due by
35
+ * `fire_at`, not by a new predicate. */
36
+ held: boolean;
37
+ /** UTC ISO of the last `POST /v1/crons/poke` receipt, written to EVERY row
38
+ * on poke. Settlement compares a 75-exiting run's start against it: a poke
39
+ * that arrived mid-run re-dues instead of parking (the in-flight gate check
40
+ * could not have seen the eligibility change). */
41
+ last_poke_at: string | null;
29
42
  state: CronState;
30
43
  run_state: CronRunState;
31
44
  run_pid: number | null;
@@ -194,9 +207,48 @@ export declare function dueClockCrons(nowIso: string): Cron[];
194
207
  * `outOfBand` stamps the lease as a `cron run` (manual verification) so the
195
208
  * overlap pass leaves it alone — see `dueOverlapCrons`. */
196
209
  export declare function acquireCronRunLease(cron_id: string, leaseOwner: string, pid: number, startedAtIso: string, outOfBand: boolean): boolean;
210
+ /** What a settling run does with the row's `held` state, applied in the SAME
211
+ * UPDATE that releases the lease — one write boundary, so no tick can observe
212
+ * `run_state='idle'` with the held transition not yet applied.
213
+ * clear — an ordinary scheduled disposition settlement (success, real
214
+ * failure): the gate resolved, so a parked row unparks.
215
+ * park — the exit-75 owed-gate disposition: held=1. A one-shot also moves
216
+ * `fire_at` to its backstop (`fireAt`); a recurring row keeps its
217
+ * pre-run-advanced natural slot.
218
+ * redue — a poke arrived while this gate check was in flight (start <
219
+ * last_poke_at): fire_at=now, held=0 — re-check next tick.
220
+ * Omitted entirely = leave `held` untouched (a replaced run, an out-of-band
221
+ * run, an unobserved crashed run — the gate is still unresolved). */
222
+ export type HeldSettle = {
223
+ kind: 'clear';
224
+ } | {
225
+ kind: 'park';
226
+ fireAt?: string;
227
+ } | {
228
+ kind: 'redue';
229
+ nowIso: string;
230
+ };
197
231
  /** Release the run lease back to idle (run settled, or stale-lease recovery
198
- * for a dead pid). */
199
- export declare function releaseCronRunLease(cron_id: string): void;
232
+ * for a dead pid), applying the settlement's `held` disposition in the same
233
+ * statement. */
234
+ export declare function releaseCronRunLease(cron_id: string, held?: HeldSettle): void;
235
+ /** The daemon-level eligibility poke (`POST /v1/crons/poke`): "something
236
+ * changed; re-check now". One write boundary, two statements:
237
+ * 1. stamp `last_poke_at` on EVERY row — this is what reaches a gate check
238
+ * in flight right now, which statement 2 deliberately skips: its exit-75
239
+ * settlement sees the stamp and re-dues itself (the F2 branch in
240
+ * cron-run.ts);
241
+ * 2. re-due every held active IDLE row now (fire_at=now, held=0). A leased
242
+ * row is excluded: its running gate check settles against the stamp —
243
+ * 75 re-dues, and success means the gate resolved and the occurrence
244
+ * fired, poke satisfied. Unparking mid-run would overwrite the row's
245
+ * pre-advanced backstop slot with `now`, so a PASSING gate would fire a
246
+ * second time on the next tick.
247
+ * Paused rows are not unparked — pause is user-owned and freezes everything;
248
+ * a paused held row keeps its state and unparks via a later poke/backstop
249
+ * after resume (it still receives the stamp, harmlessly). Returns the
250
+ * unparked count. Idempotent and free when nothing is held. */
251
+ export declare function pokeHeldCrons(nowIso: string): number;
200
252
  /** Recurring crons whose NEXT fire has arrived while their previous run is
201
253
  * still leased — the overlap-policy pass. One-shots never appear here (a
202
254
  * one-shot has exactly one fire, already consumed by its running lease).
@@ -46,6 +46,8 @@ function cronFrom(r) {
46
46
  sink: r['sink'],
47
47
  tier: r['tier'],
48
48
  last_output_hash: r['last_output_hash'] ?? null,
49
+ held: Number(r['held']) !== 0,
50
+ last_poke_at: r['last_poke_at'] ?? null,
49
51
  state: r['state'],
50
52
  run_state: r['run_state'],
51
53
  run_pid: r['run_pid'] ?? null,
@@ -286,14 +288,56 @@ export function acquireCronRunLease(cron_id, leaseOwner, pid, startedAtIso, outO
286
288
  return Number(res.changes) > 0;
287
289
  }
288
290
  /** Release the run lease back to idle (run settled, or stale-lease recovery
289
- * for a dead pid). */
290
- export function releaseCronRunLease(cron_id) {
291
+ * for a dead pid), applying the settlement's `held` disposition in the same
292
+ * statement. */
293
+ export function releaseCronRunLease(cron_id, held) {
294
+ let heldSql = '';
295
+ const heldParams = [];
296
+ if (held !== undefined) {
297
+ if (held.kind === 'clear') {
298
+ heldSql = ', held = 0';
299
+ }
300
+ else if (held.kind === 'park') {
301
+ heldSql = held.fireAt !== undefined ? ', held = 1, fire_at = ?' : ', held = 1';
302
+ if (held.fireAt !== undefined)
303
+ heldParams.push(held.fireAt);
304
+ }
305
+ else {
306
+ heldSql = ', held = 0, fire_at = ?';
307
+ heldParams.push(held.nowIso);
308
+ }
309
+ }
291
310
  openDb()
292
311
  .prepare(`UPDATE crons
293
312
  SET run_state = 'idle', run_started_at = NULL, run_pid = NULL, run_pid_identity = NULL,
294
- run_lease_owner = NULL, run_out_of_band = 0, updated = ?
313
+ run_lease_owner = NULL, run_out_of_band = 0, updated = ?${heldSql}
295
314
  WHERE cron_id = ?`)
296
- .run(new Date().toISOString(), cron_id);
315
+ .run(new Date().toISOString(), ...heldParams, cron_id);
316
+ }
317
+ /** The daemon-level eligibility poke (`POST /v1/crons/poke`): "something
318
+ * changed; re-check now". One write boundary, two statements:
319
+ * 1. stamp `last_poke_at` on EVERY row — this is what reaches a gate check
320
+ * in flight right now, which statement 2 deliberately skips: its exit-75
321
+ * settlement sees the stamp and re-dues itself (the F2 branch in
322
+ * cron-run.ts);
323
+ * 2. re-due every held active IDLE row now (fire_at=now, held=0). A leased
324
+ * row is excluded: its running gate check settles against the stamp —
325
+ * 75 re-dues, and success means the gate resolved and the occurrence
326
+ * fired, poke satisfied. Unparking mid-run would overwrite the row's
327
+ * pre-advanced backstop slot with `now`, so a PASSING gate would fire a
328
+ * second time on the next tick.
329
+ * Paused rows are not unparked — pause is user-owned and freezes everything;
330
+ * a paused held row keeps its state and unparks via a later poke/backstop
331
+ * after resume (it still receives the stamp, harmlessly). Returns the
332
+ * unparked count. Idempotent and free when nothing is held. */
333
+ export function pokeHeldCrons(nowIso) {
334
+ return withCanvasWrite((db) => {
335
+ db.prepare('UPDATE crons SET last_poke_at = ?').run(nowIso);
336
+ const res = db
337
+ .prepare("UPDATE crons SET fire_at = ?, held = 0, updated = ? WHERE held = 1 AND state = 'active' AND run_state = 'idle'")
338
+ .run(nowIso, nowIso);
339
+ return Number(res.changes);
340
+ });
297
341
  }
298
342
  /** Recurring crons whose NEXT fire has arrived while their previous run is
299
343
  * still leased — the overlap-policy pass. One-shots never appear here (a
@@ -1131,6 +1131,27 @@ ALTER TABLE review_comments
1131
1131
  function dropConsultOutbox(db) {
1132
1132
  db.exec('DROP TABLE IF EXISTS consult_outbox');
1133
1133
  }
1134
+ /** v31 — a human's submit intent recorded on a still-open review, so the
1135
+ * approval can wait for the companion to stop working. Deliberately a column
1136
+ * and not a new `state` value: through the wait the review IS open — comments
1137
+ * still land, ranges still remap, and cancellation still applies. */
1138
+ function addReviewSubmitRequested(db) {
1139
+ db.exec('ALTER TABLE reviews ADD COLUMN submit_requested_at TEXT');
1140
+ }
1141
+ /** v32 — held crons (the exit-75 owed-gate disposition). `held` marks a row
1142
+ * whose last scheduled run exited 75 ("owed but not currently eligible"):
1143
+ * the occurrence was not spent and the row waits for an eligibility poke or
1144
+ * its natural backstop slot. `last_poke_at` records the most recent
1145
+ * `POST /v1/crons/poke` receipt on EVERY row, closing the race where a poke
1146
+ * lands while a gate check is in flight (the settling run compares its start
1147
+ * against it and re-dues instead of parking). Deliberately not part of the
1148
+ * due query: a held row is non-due by `fire_at`, never by a new predicate. */
1149
+ function addCronHeldColumns(db) {
1150
+ db.exec(`
1151
+ ALTER TABLE crons ADD COLUMN held INTEGER NOT NULL DEFAULT 0;
1152
+ ALTER TABLE crons ADD COLUMN last_poke_at TEXT;
1153
+ `);
1154
+ }
1134
1155
  /** The ordered migration list. Index `i` is migration version `i + 1`; the db's
1135
1156
  * `user_version` tracks how many have been applied. Append only. */
1136
1157
  export const MIGRATIONS = [
@@ -1166,6 +1187,8 @@ export const MIGRATIONS = [
1166
1187
  /* v28 */ addReviewCommentTables,
1167
1188
  /* v29 */ addReviewCommentAnchorState,
1168
1189
  /* v30 */ dropConsultOutbox,
1190
+ /* v31 */ addReviewSubmitRequested,
1191
+ /* v32 */ addCronHeldColumns,
1169
1192
  ];
1170
1193
  /** Migration indexes that manage their OWN transaction and therefore must not
1171
1194
  * be wrapped by `migrate()` — `node:sqlite` rejects a nested BEGIN. Index 3
@@ -5,6 +5,10 @@ export interface ValidatedCommandManifest {
5
5
  timeouts?: ManifestTimeouts;
6
6
  /** Top-level branches, each ready to mount at parent []. */
7
7
  roots: DeclBranch<DeclLeaf>[];
8
+ /** Core command path (space-joined, e.g. "cron add") → attributed addendum
9
+ * text appended beneath that core command's help. Append-only product
10
+ * guidance — a plugin can never alter core contract text. */
11
+ helpAddenda?: Readonly<Record<string, string>>;
8
12
  }
9
13
  export interface CommandManifestValidation {
10
14
  /** The validated, materialized manifest (present only if all issues are fixed). */
@@ -21,4 +25,11 @@ export interface CommandManifestValidation {
21
25
  export declare function validateCommandManifest(raw: unknown, options: {
22
26
  transport: TransportKind;
23
27
  reservedCoreNames: ReadonlySet<string>;
28
+ /** Every core command path (space-joined). Supplied at the strict gates
29
+ * (install, bundle parse, doctor/inspect reports) so a helpAddenda key
30
+ * naming no core command fails there — and by the help-render lookup
31
+ * (`collectHelpAddenda`), which reproduces the gate's verdict so render
32
+ * and ingress report can never disagree. Omitted only on the tolerant
33
+ * compose path, where mounting ignores addenda entirely. */
34
+ coreCommandPaths?: ReadonlySet<string>;
24
35
  }): CommandManifestValidation;