openplanr 1.21.2 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/cli/commands/doctor.d.ts.map +1 -1
  2. package/dist/cli/commands/doctor.js +11 -1
  3. package/dist/cli/commands/doctor.js.map +1 -1
  4. package/dist/cli/commands/operate.d.ts.map +1 -1
  5. package/dist/cli/commands/operate.js +91 -2
  6. package/dist/cli/commands/operate.js.map +1 -1
  7. package/dist/services/operate/advisors.d.ts +63 -0
  8. package/dist/services/operate/advisors.d.ts.map +1 -1
  9. package/dist/services/operate/advisors.js +68 -4
  10. package/dist/services/operate/advisors.js.map +1 -1
  11. package/dist/services/operate/artifacts.d.ts +9 -3
  12. package/dist/services/operate/artifacts.d.ts.map +1 -1
  13. package/dist/services/operate/artifacts.js +126 -5
  14. package/dist/services/operate/artifacts.js.map +1 -1
  15. package/dist/services/operate/citation-resolution.d.ts +14 -0
  16. package/dist/services/operate/citation-resolution.d.ts.map +1 -1
  17. package/dist/services/operate/citation-resolution.js +9 -0
  18. package/dist/services/operate/citation-resolution.js.map +1 -1
  19. package/dist/services/operate/completion.d.ts +80 -0
  20. package/dist/services/operate/completion.d.ts.map +1 -0
  21. package/dist/services/operate/completion.js +279 -0
  22. package/dist/services/operate/completion.js.map +1 -0
  23. package/dist/services/operate/config.d.ts +7 -0
  24. package/dist/services/operate/config.d.ts.map +1 -1
  25. package/dist/services/operate/config.js +7 -0
  26. package/dist/services/operate/config.js.map +1 -1
  27. package/dist/services/operate/context-research.d.ts +43 -0
  28. package/dist/services/operate/context-research.d.ts.map +1 -1
  29. package/dist/services/operate/context-research.js +219 -1
  30. package/dist/services/operate/context-research.js.map +1 -1
  31. package/dist/services/operate/decision-brief.d.ts +90 -1
  32. package/dist/services/operate/decision-brief.d.ts.map +1 -1
  33. package/dist/services/operate/decision-brief.js +175 -1
  34. package/dist/services/operate/decision-brief.js.map +1 -1
  35. package/dist/services/operate/doctor.d.ts +58 -0
  36. package/dist/services/operate/doctor.d.ts.map +1 -1
  37. package/dist/services/operate/doctor.js +395 -21
  38. package/dist/services/operate/doctor.js.map +1 -1
  39. package/dist/services/operate/engine.d.ts +75 -2
  40. package/dist/services/operate/engine.d.ts.map +1 -1
  41. package/dist/services/operate/engine.js +504 -33
  42. package/dist/services/operate/engine.js.map +1 -1
  43. package/dist/services/operate/event-store.d.ts +22 -0
  44. package/dist/services/operate/event-store.d.ts.map +1 -1
  45. package/dist/services/operate/event-store.js +35 -0
  46. package/dist/services/operate/event-store.js.map +1 -1
  47. package/dist/services/operate/evidence-cache.d.ts.map +1 -1
  48. package/dist/services/operate/evidence-cache.js +41 -5
  49. package/dist/services/operate/evidence-cache.js.map +1 -1
  50. package/dist/services/operate/index.d.ts.map +1 -1
  51. package/dist/services/operate/index.js +98 -1
  52. package/dist/services/operate/index.js.map +1 -1
  53. package/dist/services/operate/interaction/question-engine.d.ts.map +1 -1
  54. package/dist/services/operate/interaction/question-engine.js +36 -8
  55. package/dist/services/operate/interaction/question-engine.js.map +1 -1
  56. package/dist/services/operate/interaction/question-registry.d.ts +8 -0
  57. package/dist/services/operate/interaction/question-registry.d.ts.map +1 -1
  58. package/dist/services/operate/interaction/question-registry.js +21 -2
  59. package/dist/services/operate/interaction/question-registry.js.map +1 -1
  60. package/dist/services/operate/lifecycle-driver.d.ts +309 -0
  61. package/dist/services/operate/lifecycle-driver.d.ts.map +1 -0
  62. package/dist/services/operate/lifecycle-driver.js +434 -0
  63. package/dist/services/operate/lifecycle-driver.js.map +1 -0
  64. package/dist/services/operate/maintenance.d.ts +65 -1
  65. package/dist/services/operate/maintenance.d.ts.map +1 -1
  66. package/dist/services/operate/maintenance.js +718 -24
  67. package/dist/services/operate/maintenance.js.map +1 -1
  68. package/dist/services/operate/mission-dispatch.d.ts +61 -1
  69. package/dist/services/operate/mission-dispatch.d.ts.map +1 -1
  70. package/dist/services/operate/mission-dispatch.js +78 -5
  71. package/dist/services/operate/mission-dispatch.js.map +1 -1
  72. package/dist/services/operate/profile-migration.d.ts +69 -0
  73. package/dist/services/operate/profile-migration.d.ts.map +1 -0
  74. package/dist/services/operate/profile-migration.js +462 -0
  75. package/dist/services/operate/profile-migration.js.map +1 -0
  76. package/dist/services/operate/projection-persistence.d.ts.map +1 -1
  77. package/dist/services/operate/projection-persistence.js +24 -5
  78. package/dist/services/operate/projection-persistence.js.map +1 -1
  79. package/dist/services/operate/projection.d.ts.map +1 -1
  80. package/dist/services/operate/projection.js +9 -0
  81. package/dist/services/operate/projection.js.map +1 -1
  82. package/dist/services/operate/protocol.d.ts +1 -0
  83. package/dist/services/operate/protocol.d.ts.map +1 -1
  84. package/dist/services/operate/protocol.js.map +1 -1
  85. package/dist/services/operate/scratch.d.ts +81 -0
  86. package/dist/services/operate/scratch.d.ts.map +1 -0
  87. package/dist/services/operate/scratch.js +190 -0
  88. package/dist/services/operate/scratch.js.map +1 -0
  89. package/dist/services/operate/types.d.ts +3 -2
  90. package/dist/services/operate/types.d.ts.map +1 -1
  91. package/dist/services/operate/types.js.map +1 -1
  92. package/dist/services/operate/workspace.d.ts +1 -0
  93. package/dist/services/operate/workspace.d.ts.map +1 -1
  94. package/dist/services/operate/workspace.js +2 -0
  95. package/dist/services/operate/workspace.js.map +1 -1
  96. package/dist/services/runtime-manager-service.d.ts +23 -0
  97. package/dist/services/runtime-manager-service.d.ts.map +1 -1
  98. package/dist/services/runtime-manager-service.js +30 -0
  99. package/dist/services/runtime-manager-service.js.map +1 -1
  100. package/package.json +2 -2
@@ -1,18 +1,20 @@
1
1
  import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
- import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises';
2
+ import { mkdir, open, readdir, readFile, rename, rm, stat, unlink, writeFile, } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { advisorResponseContractDetails, assertAdvisorOutputMatchesBrief, buildOperatingMandate, createNativeMissionOperatingRoleResult, } from './advisors.js';
4
+ import { advisorResponseContractDetails, assertAdvisorOutputMatchesBrief, buildOperatingMandate, createNativeMissionOperatingRoleResult, DEFAULT_OPERATING_ROLE_RESEARCH_BUDGET_MS, } from './advisors.js';
5
5
  import { canonicalDigest, canonicalize, sha256Digest } from './canonical.js';
6
6
  import { operatingProjectKey, readOperatingAdapterLeaseDurationMs } from './config.js';
7
+ import { buildOperatingBootstrapMap } from './context-research.js';
7
8
  import { gateRecordedProposalCitations } from './engine.js';
8
- import { OperatingEventStore } from './event-store.js';
9
+ import { OperatingEventStore, toPersistedDataGap } from './event-store.js';
9
10
  import { OperatingEvidenceCache } from './evidence-cache.js';
10
11
  import { guidedSessionStatus, purgeGuidedSessions } from './interaction/session-service.js';
11
12
  import { assertCommittedOperatingView, recoverOperatingTransactions } from './journal.js';
12
13
  import { withOperatingLock } from './lock-service.js';
13
14
  import { assertOperatingArtifact, loadOperatingProtocol } from './protocol.js';
14
15
  import { containsSecret, redactSensitiveText } from './redaction.js';
15
- import { OPERATE_PROTOCOL_VERSION, OPERATE_SCHEMA_VERSION, OperateError, } from './types.js';
16
+ import { cleanOperatingScratch, listAbandonedOperatingScratch } from './scratch.js';
17
+ import { OPERATE_MISSION_PROTOCOL_VERSION, OPERATE_PROTOCOL_VERSION, OPERATE_SCHEMA_VERSION, OperateError, } from './types.js';
16
18
  import { readOperatingConfig, refreshOperatingWorkspaceManifest, resolveContainedPath, resolveOperatingPaths, } from './workspace.js';
17
19
  function adapterSessionSummary(session) {
18
20
  const { roleBriefs: _roleBriefs, roleMandates: _roleMandates, ...summary } = session;
@@ -20,11 +22,17 @@ function adapterSessionSummary(session) {
20
22
  }
21
23
  async function adapterHandoff(session) {
22
24
  const recorded = new Set(session.recordedRoles);
25
+ const notEvaluated = session.notEvaluatedRoles ?? {};
26
+ // T-020: a role is terminal once it has recorded a result OR was governed
27
+ // terminal `not-evaluated`. Only in-flight (pending) roles keep the board in
28
+ // `record-required`; an all-terminal board is `finalize-required`, exactly the
29
+ // T-001 wire contract the pipeline handoff validator enforces.
30
+ const isTerminal = (role) => recorded.has(role) || role in notEvaluated;
23
31
  const state = session.state === 'cancelled'
24
32
  ? 'cancelled'
25
33
  : session.state === 'finalized'
26
34
  ? 'continue-required'
27
- : session.roles.some((role) => !recorded.has(role))
35
+ : session.roles.some((role) => !isTerminal(role))
28
36
  ? 'record-required'
29
37
  : 'finalize-required';
30
38
  const protocol = await loadOperatingProtocol();
@@ -40,7 +48,10 @@ async function adapterHandoff(session) {
40
48
  expiresAt: session.expiresAt,
41
49
  roles: session.roles.map((role) => ({
42
50
  roleId: role,
43
- status: recorded.has(role) ? 'recorded' : 'pending',
51
+ status: recorded.has(role) ? 'recorded' : role in notEvaluated ? 'not-evaluated' : 'pending',
52
+ // A governed not-evaluated role carries its reason through the handoff;
53
+ // recorded/pending roles omit the field so existing roles stay byte-identical.
54
+ ...(role in notEvaluated ? { statusReason: notEvaluated[role] } : {}),
44
55
  inputDigest: session.roleInputDigests[role],
45
56
  })),
46
57
  });
@@ -97,6 +108,28 @@ export async function purgeBoardMachineLocalCaches(input) {
97
108
  const removedIncrementalBaselines = await removeMachineLocalCacheDir(path.join(paths.evidence, 'incremental'));
98
109
  return { removedAdvisorSessions, removedIncrementalBaselines };
99
110
  }
111
+ /**
112
+ * FR7 (T-006): sibling machine-local cleanup for OpenPlanr-owned scratch left
113
+ * behind by a session that never finalized. Deliberately NOT folded into
114
+ * `purgeBoardMachineLocalCaches` — scratch has its own narrower per-cycle
115
+ * lifecycle. Removes ONLY scratch a valid `openplanr-operate-scratch` manifest
116
+ * confirms this project wrote and whose lease window has lapsed; it never touches
117
+ * an unowned file found under the scratch root, nor any other machine-local
118
+ * cache. This is the owned-only removal the doctor's abandoned-scratch diagnostic
119
+ * points at, and the sibling `operate cache purge` invokes.
120
+ */
121
+ export async function purgeAbandonedOperatingScratch(input) {
122
+ const paths = resolveOperatingPaths(input.projectRoot, { localRoot: input.localRoot });
123
+ const abandoned = await listAbandonedOperatingScratch(paths, input.now ? { now: input.now } : {});
124
+ let removed = 0;
125
+ const cycles = [];
126
+ for (const entry of abandoned) {
127
+ const result = await cleanOperatingScratch(paths, entry.cycleId);
128
+ removed += result.removed;
129
+ cycles.push(entry.cycleId);
130
+ }
131
+ return { removed, cycles: cycles.sort() };
132
+ }
100
133
  /**
101
134
  * Surface the adapter session lease ergonomically (FR10 / T-008): the absolute
102
135
  * `expiresAt` plus the remaining time relative to the resolved clock. Included in
@@ -129,6 +162,367 @@ async function atomicBytesWrite(target, value) {
129
162
  await writeFile(temporary, value, { mode: 0o600 });
130
163
  await rename(temporary, target);
131
164
  }
165
+ // FR1 (SPEC-005 T-002): `harness record` and `harness heartbeat` both mutate ONE
166
+ // shared, lease-bound session file. T-001 widened the handoff so every pending
167
+ // role is authorized to record the instant it returns; that removed the batch
168
+ // barrier but, on the write side, two records returning together would each read
169
+ // the same `recordedRoles` snapshot and — last writer wins — silently drop the
170
+ // other already-recorded role. That trades lost-work-from-a-barrier for a
171
+ // nondeterministic lost-work-from-a-race, which is worse. T-001 states plainly
172
+ // that this handoff "never schedules concurrent session writes", so preventing
173
+ // the race is a write-side property owned here: serialize the session write per
174
+ // cycle and re-read the freshest snapshot inside the critical section before
175
+ // merging. The in-process chain serializes async writers in this process; the
176
+ // O_EXCL lockfile serializes concurrently spawned `planr operate harness record`
177
+ // processes. The per-role result file (`<cycle>.<role>.json`) is the durable
178
+ // cross-process record — re-adopted on prepare/resume — so a stolen stale lock
179
+ // never loses committed work.
180
+ const sessionCommitTails = new Map();
181
+ async function withInProcessSessionLock(key, operation) {
182
+ const previous = sessionCommitTails.get(key) ?? Promise.resolve();
183
+ let openGate;
184
+ const gate = new Promise((resolve) => {
185
+ openGate = resolve;
186
+ });
187
+ // `tail` resolves (never rejects) only once our gate opens, so the next writer
188
+ // in the chain waits for us regardless of whether our operation threw.
189
+ const tail = previous.then(() => gate);
190
+ sessionCommitTails.set(key, tail);
191
+ await previous;
192
+ try {
193
+ return await operation();
194
+ }
195
+ finally {
196
+ openGate();
197
+ if (sessionCommitTails.get(key) === tail)
198
+ sessionCommitTails.delete(key);
199
+ }
200
+ }
201
+ async function withCrossProcessSessionLock(sessionPath, operation) {
202
+ const lockPath = `${sessionPath}.commit.lock`;
203
+ await mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
204
+ const deadline = Date.now() + 10_000;
205
+ let handle;
206
+ for (;;) {
207
+ try {
208
+ handle = await open(lockPath, 'wx', 0o600);
209
+ break;
210
+ }
211
+ catch (error) {
212
+ if (error.code !== 'EEXIST')
213
+ throw error;
214
+ // Steal only a demonstrably abandoned lock (a crashed record process must
215
+ // never deadlock the session). The per-role result files own committed
216
+ // work, so stealing this mutex risks nothing durable.
217
+ const age = await stat(lockPath)
218
+ .then((info) => Date.now() - info.mtimeMs)
219
+ .catch(() => 0);
220
+ if (age > 30_000) {
221
+ await unlink(lockPath).catch(() => undefined);
222
+ continue;
223
+ }
224
+ if (Date.now() > deadline) {
225
+ throw new OperateError('E_OPERATE_STATE_INVALID', 'Timed out serializing the adapter session write.');
226
+ }
227
+ await new Promise((resolve) => setTimeout(resolve, 20));
228
+ }
229
+ }
230
+ try {
231
+ return await operation();
232
+ }
233
+ finally {
234
+ await handle.close().catch(() => undefined);
235
+ await unlink(lockPath).catch(() => undefined);
236
+ }
237
+ }
238
+ /**
239
+ * Apply a mutation to the shared adapter session under the cross-process lock,
240
+ * re-reading the freshest on-disk snapshot first so a sibling record committed
241
+ * between the caller's snapshot and now is merged forward rather than clobbered
242
+ * (FR1). The lease/idempotency binding and a recordable (`prepared`/`recording`)
243
+ * state are re-asserted inside the critical section, so a concurrent cancel or
244
+ * expiry cannot be silently overwritten. Callers must already hold the
245
+ * in-process session lock for the same `target`.
246
+ */
247
+ async function commitSessionWrite(target, input) {
248
+ return withCrossProcessSessionLock(target, async () => {
249
+ const current = await readAdapterSession(input.projectRoot, input.cycleId, input.localRoot, input.nowMs);
250
+ assertAdapterBinding(current, input.lease, input.idempotencyKey, input.evidenceDigest);
251
+ if (!['prepared', 'recording'].includes(current.state)) {
252
+ throw new OperateError('E_OPERATE_STATE_INVALID', `Adapter session is ${current.state}; its lease-bound write is no longer valid.`, { recoveryCommand: retryRunCommand(current) });
253
+ }
254
+ const next = input.apply(current);
255
+ await atomicPrivateWrite(target, next);
256
+ return next;
257
+ });
258
+ }
259
+ /**
260
+ * FR4/FR5: commit a just-recorded role's canonical `advisory.recorded` event and
261
+ * materialize the readable partial projection immediately, so `planr operate
262
+ * report`/`status` and `.planr/operate/cycles/<id>/` reflect every validated
263
+ * lens BEFORE Chair runs — not only at finalize. The commit is idempotent (a
264
+ * role already committed by a prior record or a finalize reconciliation is
265
+ * skipped; a different digest for the same role is the same isolation error
266
+ * finalize raises) and best-effort under concurrency: if another writer holds the
267
+ * project lock or advanced the event head between replay and append, the role's
268
+ * validated result is already durable in its machine-local file and session
269
+ * entry, and finalize reconciles the canonical event, so the record defers rather
270
+ * than fails.
271
+ */
272
+ async function materializeRecordedRole(input) {
273
+ const store = new OperatingEventStore(input.projectRoot, { localRoot: input.localRoot });
274
+ try {
275
+ const alreadyCommitted = new Map((await readPersistedOperatingRoleResults(store, input.cycleId)).map((result) => [
276
+ result.roleId,
277
+ result,
278
+ ]));
279
+ const prior = alreadyCommitted.get(input.result.roleId);
280
+ if (prior) {
281
+ if (prior.resultDigest !== input.result.resultDigest) {
282
+ throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Cycle ${input.cycleId} already committed a different ${input.result.roleId} result.`);
283
+ }
284
+ }
285
+ else {
286
+ const initial = await store.replay();
287
+ await withOperatingLock(input.projectRoot, {
288
+ projectKey: operatingProjectKey(input.projectRoot),
289
+ expectedEventHead: initial.eventHead,
290
+ currentEventHead: initial.eventHead,
291
+ localRoot: input.localRoot,
292
+ }, async (lock) => {
293
+ // Re-check under the lock: a concurrent writer may have committed this
294
+ // role between our replay and lock acquisition.
295
+ const committed = new Map((await readPersistedOperatingRoleResults(store, input.cycleId)).map((result) => [
296
+ result.roleId,
297
+ result,
298
+ ]));
299
+ if (committed.has(input.result.roleId))
300
+ return;
301
+ const record = await store.putRecord('advisor-result', input.result, { correlationId: input.session.idempotencyKey });
302
+ const advisorReportRecord = input.session.protocolVersion === '1.4.0' && input.advisorReport
303
+ ? await store.putRecord('advisor-report', input.advisorReport, {
304
+ correlationId: input.session.idempotencyKey,
305
+ })
306
+ : null;
307
+ const runtimeBinding = (await adapterHandoff(input.session)).binding;
308
+ const event = await store.append({
309
+ type: 'advisory.recorded',
310
+ cycleId: input.cycleId,
311
+ entityId: `${input.cycleId}-${input.result.roleId}`,
312
+ correlationId: input.session.idempotencyKey,
313
+ evidenceRefs: input.result.proposals.flatMap((proposal) => proposal.evidenceRefs),
314
+ payload: {
315
+ recordDigest: record.digest,
316
+ ...(advisorReportRecord ? { advisorReportDigest: advisorReportRecord.digest } : {}),
317
+ roleId: input.result.roleId,
318
+ runtimeBinding: {
319
+ runtime: runtimeBinding.runtime,
320
+ runtimeBinding: runtimeBinding.runtimeBinding,
321
+ crossRuntimeFallback: runtimeBinding.crossRuntimeFallback,
322
+ executionMode: runtimeBinding.executionMode,
323
+ assurance: runtimeBinding.assurance,
324
+ toolIsolation: runtimeBinding.toolIsolation,
325
+ },
326
+ },
327
+ ...(input.session.protocolVersion === '1.4.0'
328
+ ? { protocolVersion: '1.4.0' }
329
+ : {}),
330
+ expectedHead: initial.eventHead.hash,
331
+ actor: { kind: 'runtime', id: 'operate-adapter' },
332
+ });
333
+ await lock.advanceEventHead(initial.eventHead, {
334
+ sequence: event.sequence,
335
+ hash: event.eventHash,
336
+ });
337
+ await store.writeCheckpoint(await store.state());
338
+ });
339
+ }
340
+ // Refresh the readable projection from the freshest committed state so the
341
+ // recorded lens' Markdown lands under `.planr/operate/cycles/<id>/` at once.
342
+ const { persistOperatingProjections } = await import('./projection-persistence.js');
343
+ await persistOperatingProjections({
344
+ projectRoot: input.projectRoot,
345
+ localRoot: input.localRoot,
346
+ state: await store.state(),
347
+ revalidateEventHead: async () => (await store.replay()).eventHead,
348
+ });
349
+ }
350
+ catch (error) {
351
+ if (error instanceof OperateError &&
352
+ ['E_OPERATE_CYCLE_ACTIVE', 'E_OPERATE_ROUTE_DRIFT'].includes(error.code)) {
353
+ // A concurrent writer holds the project lock or advanced the head between
354
+ // our replay and commit. The validated result is already durable in its
355
+ // machine-local file and session entry; finalize reconciles the canonical
356
+ // event idempotently. Defer rather than fail the record.
357
+ return;
358
+ }
359
+ throw error;
360
+ }
361
+ }
362
+ function maximumOperatingOrdinal(records, prefix) {
363
+ return records.reduce((maximum, record) => {
364
+ const match = String(record.id ?? '').match(new RegExp(`^${prefix}-(\\d+)$`));
365
+ return Math.max(maximum, match ? Number(match[1]) : 0);
366
+ }, 0);
367
+ }
368
+ /**
369
+ * SPEC-005 T-019 — persist the citation gate's governed gaps on the agent-native
370
+ * adapter-lifecycle `record` path, exactly as the inline consolidation loop
371
+ * (`engine.ts`) persists its dispatch gaps.
372
+ *
373
+ * A lens whose every proposal is citation-rejected records a schema-legal `quiet`
374
+ * result; the `unresolvable-citation` / `missing-evidence` gaps that record WHY it
375
+ * grounded nothing were previously only echoed transiently in the record response
376
+ * (`citationGaps`) and never reached the event store. So the Chair board —
377
+ * assembled by a later `run` continuation, after this role is already recorded and
378
+ * is never re-dispatched — saw no citation signal in committed state and rendered
379
+ * the lens a false-clean `recorded-quiet` (reason `null`, gapId `null`): the exact
380
+ * dishonest surface SPEC-005 exists to eliminate, on the path real runtimes use.
381
+ * Persisting the governed gap here makes it durable, readable back from the event
382
+ * store, and reconstructible into the `citation-rejected` classification at
383
+ * consolidation (see `engine.ts`).
384
+ *
385
+ * This reuses T-017's landed `toPersistedDataGap` v1.2 projection verbatim — the
386
+ * SAME projection the inline path relies on (category/citations dropped, canonical
387
+ * `GAP-NNN` id) — so the gap validates against the append log / `operating-record`
388
+ * schemas instead of throwing `E_OPERATE_STATE_INVALID` (`$ matched 0/11
389
+ * branches`). It is NOT a second, forked persistence path; it carries T-017's
390
+ * signal across the record→consolidation boundary the inline path never crosses.
391
+ *
392
+ * Idempotent: the citation gate names every gap it opens for a role in that gap's
393
+ * `affectedRoles`, so a role already gap-named in committed state is a no-op —
394
+ * a resume, retry, or idempotent re-record never mints a duplicate under a fresh
395
+ * `GAP-NNN` id.
396
+ */
397
+ async function persistRecordedRoleGaps(input) {
398
+ if (input.gaps.length === 0)
399
+ return;
400
+ const store = new OperatingEventStore(input.projectRoot, { localRoot: input.localRoot });
401
+ const namesRole = (gap) => (gap.cycleId === undefined || gap.cycleId === input.cycleId) &&
402
+ Array.isArray(gap.affectedRoles) &&
403
+ gap.affectedRoles.includes(input.roleId);
404
+ if ((await store.state()).dataGaps.some(namesRole))
405
+ return;
406
+ try {
407
+ const initial = await store.replay();
408
+ await withOperatingLock(input.projectRoot, {
409
+ projectKey: operatingProjectKey(input.projectRoot),
410
+ expectedEventHead: initial.eventHead,
411
+ currentEventHead: initial.eventHead,
412
+ localRoot: input.localRoot,
413
+ }, async (lock) => {
414
+ // Re-check under the lock: a concurrent writer may have persisted this
415
+ // role's gaps between our replay and lock acquisition.
416
+ const current = await store.state();
417
+ if (current.dataGaps.some(namesRole))
418
+ return;
419
+ let head = initial.eventHead;
420
+ let gapOrdinal = maximumOperatingOrdinal(current.dataGaps, 'GAP');
421
+ for (const rawGap of input.gaps) {
422
+ const gap = toPersistedDataGap(rawGap, () => `GAP-${String(++gapOrdinal).padStart(3, '0')}`);
423
+ await assertOperatingArtifact('operating-data-gap', gap);
424
+ await store.putRecord('data-gap', structuredClone(gap), { correlationId: input.correlationId });
425
+ const event = await store.append({
426
+ type: 'gap.open',
427
+ cycleId: input.cycleId,
428
+ entityId: gap.id,
429
+ correlationId: input.correlationId,
430
+ evidenceRefs: gap.evidenceRefs,
431
+ payload: { record: gap },
432
+ expectedHead: head.hash,
433
+ actor: { kind: 'runtime', id: 'operate-adapter' },
434
+ });
435
+ const next = { sequence: event.sequence, hash: event.eventHash };
436
+ await lock.advanceEventHead(head, next);
437
+ head = next;
438
+ }
439
+ await store.writeCheckpoint(await store.state());
440
+ });
441
+ }
442
+ catch (error) {
443
+ if (error instanceof OperateError &&
444
+ ['E_OPERATE_CYCLE_ACTIVE', 'E_OPERATE_ROUTE_DRIFT'].includes(error.code)) {
445
+ // A concurrent writer holds the project lock or advanced the head between our
446
+ // replay and commit. The governed gap is idempotent and content-addressed;
447
+ // the next record/finalize reconciles it. Defer rather than fail the record.
448
+ return;
449
+ }
450
+ throw error;
451
+ }
452
+ const { persistOperatingProjections } = await import('./projection-persistence.js');
453
+ await persistOperatingProjections({
454
+ projectRoot: input.projectRoot,
455
+ localRoot: input.localRoot,
456
+ state: await store.state(),
457
+ revalidateEventHead: async () => (await store.replay()).eventHead,
458
+ });
459
+ }
460
+ /**
461
+ * SPEC-005 T-020 (FR13): the governed gap recording a lens terminal
462
+ * `not_evaluated` because it was dispatched but never returned a result — a true
463
+ * stall on the agent-native adapter-lifecycle path, distinct from T-019's
464
+ * citation-rejected case (a lens that DID return a result grounding zero
465
+ * evidence). Shaped `missing-evidence` in memory so `toPersistedDataGap` mints its
466
+ * canonical `GAP-NNN` id and strips `category` for the v1.2 committed projection —
467
+ * the same projection T-017/T-019 rely on. This gap, in committed state, IS the
468
+ * durable terminal signal: a role with NO committed result but named by such a
469
+ * cycle gap is reconstructed `not_evaluated` at consolidation (`engine.ts`), keyed
470
+ * on that committed-state fact, never the gap prose.
471
+ */
472
+ function buildTerminalNotEvaluatedGap(input) {
473
+ return {
474
+ kind: 'operating-data-gap',
475
+ schemaVersion: OPERATE_SCHEMA_VERSION,
476
+ protocolVersion: OPERATE_MISSION_PROTOCOL_VERSION,
477
+ id: `GAP-${canonicalDigest({
478
+ roleId: input.roleId,
479
+ cycleId: input.cycleId,
480
+ reason: 'stalled-not-evaluated',
481
+ }).slice('sha256:'.length)}`,
482
+ cycleId: input.cycleId,
483
+ category: 'missing-evidence',
484
+ question: `Why did ${input.roleId} not evaluate this cycle? It was dispatched but never recorded a result.`,
485
+ reason: input.reason,
486
+ unblocks: [],
487
+ affectedRoles: [input.roleId],
488
+ status: 'open',
489
+ owner: input.owner && input.owner.length > 0 ? input.owner : 'chair',
490
+ evidenceRefs: [],
491
+ createdAt: input.now,
492
+ updatedAt: input.now,
493
+ };
494
+ }
495
+ /**
496
+ * SPEC-005 T-020 — terminally govern one dispatched-but-unrecorded lens
497
+ * `not_evaluated` with a reason, in committed state. Shared by the runtime
498
+ * `harness abandon` action (lease-bound) and the operator escape
499
+ * (`reapStalledOperatingRoles`, keyed on a lapsed lease). It persists the governed
500
+ * gap through the SAME durable path T-019's citation gaps use
501
+ * (`persistRecordedRoleGaps` → `gap.open` event), so a terminal transition is
502
+ * always a governed event carrying its reason. Idempotent per role: a role already
503
+ * named by a committed gap is a no-op. Never fabricates a result — the lens
504
+ * contributes zero grounded proposals by construction (it recorded none).
505
+ */
506
+ async function governTerminalNotEvaluated(input) {
507
+ const config = await readOperatingConfig(input.projectRoot, {
508
+ localRoot: input.localRoot,
509
+ }).catch(() => null);
510
+ const gap = buildTerminalNotEvaluatedGap({
511
+ cycleId: input.cycleId,
512
+ roleId: input.roleId,
513
+ reason: input.reason,
514
+ owner: config?.decisionOwner ?? 'chair',
515
+ now: new Date().toISOString(),
516
+ });
517
+ await persistRecordedRoleGaps({
518
+ projectRoot: input.projectRoot,
519
+ localRoot: input.localRoot,
520
+ cycleId: input.cycleId,
521
+ roleId: input.roleId,
522
+ gaps: [gap],
523
+ correlationId: input.correlationId,
524
+ });
525
+ }
132
526
  async function regularFiles(root) {
133
527
  const files = [];
134
528
  async function visit(directory) {
@@ -193,15 +587,23 @@ export async function operatingCacheAction(input) {
193
587
  projectRoot: input.projectRoot,
194
588
  localRoot: input.localRoot,
195
589
  });
590
+ // FR7: sibling to the board purge — clear only confirmed OpenPlanr-owned stale
591
+ // scratch (its own per-cycle lifecycle, kept out of purgeBoardMachineLocalCaches).
592
+ const scratch = await purgeAbandonedOperatingScratch({
593
+ projectRoot: input.projectRoot,
594
+ localRoot: input.localRoot,
595
+ });
196
596
  return {
197
597
  removed: removed.length +
198
598
  sessions.removed +
199
599
  board.removedAdvisorSessions +
200
- board.removedIncrementalBaselines,
600
+ board.removedIncrementalBaselines +
601
+ scratch.removed,
201
602
  evidence: { removed: removed.length, entries: removed },
202
603
  sessions,
203
604
  adapterSessions: { removed: board.removedAdvisorSessions },
204
605
  incrementalBaselines: { removed: board.removedIncrementalBaselines },
606
+ scratch: { removed: scratch.removed, cycles: scratch.cycles },
205
607
  };
206
608
  }
207
609
  function integrityKeyPath(projectRoot, localRoot) {
@@ -892,7 +1294,31 @@ export async function operateAdapterLifecycle(input) {
892
1294
  ? recoverableSession.roleMandates
893
1295
  : await (async () => {
894
1296
  const roots = await deriveOperatingMandateRoots(input.projectRoot);
895
- const built = await Promise.all(roles.map(async (roleId) => [roleId, await buildOperatingMandate({ roleId, roots, runtime })]));
1297
+ // FR12 (SPEC-005 T-003): the native harness prepare path is the one real
1298
+ // runs use, so it must thread the SAME shared research guidance the inline
1299
+ // dispatch path does — otherwise native-runtime agents receive no FR12
1300
+ // targeting at all. Build the ONE shared, citation-bearing bootstrap map
1301
+ // once per cycle here (cached per project+head) and reference it from every
1302
+ // advisor role's mandate below, alongside the graceful per-role research
1303
+ // budget. The Chair is prepared alone (`['chair']`), never mixed with
1304
+ // advisors, so it derives an empty advisor set, builds no map, and its
1305
+ // mandate stays byte-identical. The map is body-free targeting layered over
1306
+ // the immutable mandate — never an evidence-pack input, never a size ceiling,
1307
+ // never a cap on what a lens may examine.
1308
+ const advisorRoles = roles.filter((roleId) => roleId !== 'chair');
1309
+ const bootstrapMap = advisorRoles.length > 0 ? await buildOperatingBootstrapMap(input.projectRoot) : null;
1310
+ const built = await Promise.all(roles.map(async (roleId) => [
1311
+ roleId,
1312
+ await buildOperatingMandate({
1313
+ roleId,
1314
+ roots,
1315
+ runtime,
1316
+ ...(roleId !== 'chair' && bootstrapMap ? { bootstrapMap } : {}),
1317
+ ...(roleId !== 'chair'
1318
+ ? { researchBudgetMs: DEFAULT_OPERATING_ROLE_RESEARCH_BUDGET_MS }
1319
+ : {}),
1320
+ }),
1321
+ ]));
896
1322
  return Object.fromEntries(built);
897
1323
  })();
898
1324
  // Every requested role dispatches; the bound role set is exactly `roles`.
@@ -960,6 +1386,104 @@ export async function operateAdapterLifecycle(input) {
960
1386
  cycleId: input.cycleId,
961
1387
  localRoot: input.localRoot,
962
1388
  }, session);
1389
+ if (input.action === 'heartbeat') {
1390
+ // FR2: renew the cycle session's lease WITHOUT recording a role result. A
1391
+ // slow lens can hold the session open — extending `expiresAt` by the same
1392
+ // configured lease duration — while siblings keep their already-recorded work
1393
+ // instead of the shared lease lapsing and stranding it. This is the sanctioned
1394
+ // renewal path; raising `adapterLeaseDurationMs` is explicitly not (SPEC-005).
1395
+ // It carries no `--role`/stdin, changes no role's recorded/pending status, and
1396
+ // is safe to call concurrently by any coordinator holding the current lease:
1397
+ // it goes through the same per-cycle session serialization as `record`, so it
1398
+ // can never clobber a concurrent record's `recordedRoles`.
1399
+ if (!['prepared', 'recording'].includes(session.state)) {
1400
+ throw new OperateError('E_OPERATE_STATE_INVALID', `Adapter heartbeat is not valid after the session is ${session.state}.`, { recoveryCommand: retryRunCommand(session) });
1401
+ }
1402
+ const renewed = await withInProcessSessionLock(target, () => commitSessionWrite(target, {
1403
+ projectRoot: input.projectRoot,
1404
+ cycleId: input.cycleId,
1405
+ localRoot: input.localRoot,
1406
+ lease: input.lease,
1407
+ idempotencyKey: input.idempotencyKey,
1408
+ evidenceDigest: input.evidenceDigest,
1409
+ nowMs,
1410
+ apply: (current) => ({
1411
+ ...current,
1412
+ expiresAt: new Date(nowMs + leaseDurationMs).toISOString(),
1413
+ }),
1414
+ }));
1415
+ return {
1416
+ session: adapterSessionSummary(renewed),
1417
+ leaseStatus: adapterLeaseStatus(renewed, nowMs),
1418
+ handoff: await adapterHandoff(renewed),
1419
+ };
1420
+ }
1421
+ if (input.action === 'abandon') {
1422
+ // SPEC-005 T-020 (FR13) — the runtime-side governed terminal path. The
1423
+ // orchestrating runtime, which dispatched this lens and holds the session
1424
+ // lease, invokes this when a dispatched lens exceeded its budget and never
1425
+ // returned (see the skill/command runtime workflow). The engine never spawned
1426
+ // the agent, so it cannot time it out unilaterally; this explicit governed
1427
+ // action, with a required reason, is the smallest honest substitute. It marks
1428
+ // ONE still-pending lens terminal `not_evaluated`, persists the governed gap
1429
+ // (a `gap.open` event carrying the reason), and lets the board consolidate
1430
+ // without it — its recorded siblings are untouched and the Chair names it as a
1431
+ // gap it must not synthesize around.
1432
+ if (!['prepared', 'recording'].includes(session.state)) {
1433
+ throw new OperateError('E_OPERATE_STATE_INVALID', `Adapter abandon is not valid after the session is ${session.state}.`, { recoveryCommand: retryRunCommand(session) });
1434
+ }
1435
+ if (!input.role) {
1436
+ throw new OperateError('E_OPERATE_CONFIG_INVALID', 'Adapter abandon requires --role naming the stalled lens.');
1437
+ }
1438
+ const reason = input.reason?.trim();
1439
+ if (!reason) {
1440
+ throw new OperateError('E_OPERATE_CONFIG_INVALID', 'Adapter abandon requires --reason recording why the lens is not_evaluated.');
1441
+ }
1442
+ if (!session.roles.includes(input.role)) {
1443
+ throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Role ${input.role} was not bound by adapter prepare.`);
1444
+ }
1445
+ if (session.recordedRoles.includes(input.role)) {
1446
+ throw new OperateError('E_OPERATE_STATE_INVALID', `Role ${input.role} already recorded a result and cannot be abandoned.`, { recoveryCommand: retryRunCommand(session) });
1447
+ }
1448
+ // Persist the governed terminal gap first (committed state is the source of
1449
+ // truth the later `run` continuation reconstructs from), then mark the role
1450
+ // terminal in the lease-bound session so finalize no longer waits on it and
1451
+ // the handoff renders it `not-evaluated`. Idempotent: re-abandoning a role
1452
+ // already governed is a no-op that returns the same terminal handoff.
1453
+ await governTerminalNotEvaluated({
1454
+ projectRoot: input.projectRoot,
1455
+ localRoot: input.localRoot,
1456
+ cycleId: input.cycleId,
1457
+ roleId: input.role,
1458
+ reason,
1459
+ correlationId: input.idempotencyKey,
1460
+ });
1461
+ const updated = await withInProcessSessionLock(target, () => commitSessionWrite(target, {
1462
+ projectRoot: input.projectRoot,
1463
+ cycleId: input.cycleId,
1464
+ localRoot: input.localRoot,
1465
+ lease: input.lease,
1466
+ idempotencyKey: input.idempotencyKey,
1467
+ evidenceDigest: input.evidenceDigest,
1468
+ nowMs,
1469
+ apply: (current) => ({
1470
+ ...current,
1471
+ state: 'recording',
1472
+ notEvaluatedRoles: {
1473
+ ...(current.notEvaluatedRoles ?? {}),
1474
+ [input.role]: reason,
1475
+ },
1476
+ }),
1477
+ }));
1478
+ return {
1479
+ abandoned: input.role,
1480
+ notEvaluated: true,
1481
+ reason,
1482
+ session: adapterSessionSummary(updated),
1483
+ leaseStatus: adapterLeaseStatus(updated, nowMs),
1484
+ handoff: await adapterHandoff(updated),
1485
+ };
1486
+ }
963
1487
  if (input.action === 'resume') {
964
1488
  if (!['prepared', 'recording'].includes(session.state)) {
965
1489
  throw new OperateError('E_OPERATE_STATE_INVALID', `Adapter resume is not valid after the session is ${session.state}.`, { recoveryCommand: retryRunCommand(session) });
@@ -999,11 +1523,20 @@ export async function operateAdapterLifecycle(input) {
999
1523
  if (!session.roles.includes(input.role)) {
1000
1524
  throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Role ${input.role} was not bound by adapter prepare.`);
1001
1525
  }
1526
+ // T-001 widened `record-required.next` to one record action per PENDING role,
1527
+ // so authorization is keyed by role, not by position: any pending role may
1528
+ // record the instant it returns, regardless of sibling order or completion
1529
+ // state (FR1). A role that is already recorded is not pending (so it has no
1530
+ // record action in `next`) but is still a legal idempotent replay target —
1531
+ // its identical-bytes no-op is enforced downstream by the resultDigest guard.
1532
+ // Only a role that is neither pending nor already recorded (e.g. a session
1533
+ // with no pending roles left) is rejected here, pointed at recovery.
1002
1534
  const currentHandoff = await adapterHandoff(session);
1003
- const authorizedRecord = currentHandoff.next.find((action) => ['adapter.record', 'harness.record'].includes(action.action));
1004
- if (!authorizedRecord || authorizedRecord.role !== input.role) {
1005
- throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Role ${input.role} is not the current serialized adapter record action.`, {
1006
- expectedRole: authorizedRecord?.role ?? null,
1535
+ const authorizedRecord = currentHandoff.next.find((action) => ['adapter.record', 'harness.record'].includes(action.action) && action.role === input.role);
1536
+ const alreadyRecorded = session.recordedRoles.includes(input.role);
1537
+ if (!authorizedRecord && !alreadyRecorded) {
1538
+ throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Role ${input.role} has no pending adapter record action.`, {
1539
+ expectedRole: currentHandoff.next.find((action) => ['adapter.record', 'harness.record'].includes(action.action))?.role ?? null,
1007
1540
  recoveryCommand: currentHandoff.recovery
1008
1541
  .find((action) => ['adapter.resume', 'harness.resume'].includes(action.action))
1009
1542
  ?.argv.join(' '),
@@ -1173,20 +1706,75 @@ export async function operateAdapterLifecycle(input) {
1173
1706
  throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Role ${input.role} already recorded a different result.`);
1174
1707
  }
1175
1708
  await atomicPrivateWrite(path.join(path.dirname(target), `${input.cycleId}.${input.role}.json`), result);
1709
+ const advisorReport = session.protocolVersion === '1.4.0' &&
1710
+ submitted &&
1711
+ typeof submitted === 'object' &&
1712
+ !Array.isArray(submitted)
1713
+ ? submitted
1714
+ : null;
1176
1715
  if (session.protocolVersion === '1.4.0') {
1177
1716
  await atomicPrivateWrite(path.join(path.dirname(target), `${input.cycleId}.${input.role}.response.json`), submitted);
1178
1717
  }
1179
- // A successful record refreshes the lease forward from now (FR10 / T-008):
1180
- // an advisor making steady progress across a multi-role dispatch keeps its
1181
- // session alive without a separate keep-alive call, while a session that goes
1182
- // idle past the refreshed window still lapses and is rejected on the next call.
1183
- const updated = {
1184
- ...session,
1185
- state: 'recording',
1186
- recordedRoles: [...new Set([...session.recordedRoles, input.role])].sort(),
1187
- expiresAt: new Date(nowMs + leaseDurationMs).toISOString(),
1188
- };
1189
- await atomicPrivateWrite(target, updated);
1718
+ // The role's validated result is now durable in its own machine-local file
1719
+ // (survives a sibling stalling, restart, lease expiry, and resume). Merging it
1720
+ // into the shared session's `recordedRoles`, committing its canonical event,
1721
+ // and materializing its projection all mutate cycle-shared state, so they run
1722
+ // inside the per-cycle in-process serialization — a concurrent record of a
1723
+ // DIFFERENT role can never lose this one (FR1). A successful record also
1724
+ // refreshes the lease forward from now (T-008): steady progress across a
1725
+ // multi-role dispatch keeps the session alive without a separate keep-alive
1726
+ // call, while an idle session past the refreshed window still lapses.
1727
+ const target2 = target;
1728
+ const updated = await withInProcessSessionLock(target2, async () => {
1729
+ const merged = await commitSessionWrite(target2, {
1730
+ projectRoot: input.projectRoot,
1731
+ cycleId: input.cycleId,
1732
+ localRoot: input.localRoot,
1733
+ lease: input.lease,
1734
+ idempotencyKey: input.idempotencyKey,
1735
+ evidenceDigest: input.evidenceDigest,
1736
+ nowMs,
1737
+ apply: (current) => ({
1738
+ ...current,
1739
+ state: 'recording',
1740
+ recordedRoles: [...new Set([...current.recordedRoles, input.role])].sort(),
1741
+ expiresAt: new Date(nowMs + leaseDurationMs).toISOString(),
1742
+ }),
1743
+ });
1744
+ // FR4/FR5: commit the canonical `advisory.recorded` event and refresh the
1745
+ // readable projection now, so `planr operate report`/`status` and
1746
+ // `.planr/operate/cycles/<id>/` reflect this lens' real analysis before
1747
+ // Chair runs — not only at finalize.
1748
+ await materializeRecordedRole({
1749
+ projectRoot: input.projectRoot,
1750
+ localRoot: input.localRoot,
1751
+ cycleId: input.cycleId,
1752
+ session: merged,
1753
+ result,
1754
+ advisorReport,
1755
+ });
1756
+ return merged;
1757
+ });
1758
+ // SPEC-005 T-019 (FR5/FR13): a citation-rejected lens commits a quiet result;
1759
+ // persist the governed gaps the citation gate opened so the Chair board — built
1760
+ // by a later `run` continuation, after this role is recorded and never
1761
+ // re-dispatched — can reconstruct the `citation-rejected` outcome from committed
1762
+ // state instead of rendering a false-clean `recorded-quiet`. Runs after the role
1763
+ // is materialized (its `advisory.recorded` event committed), acquiring the
1764
+ // event-store lock itself and idempotent per role, so a resume/retry never
1765
+ // duplicates. Empty on the normal healthy path (no citation rejection → no gaps).
1766
+ await persistRecordedRoleGaps({
1767
+ projectRoot: input.projectRoot,
1768
+ localRoot: input.localRoot,
1769
+ cycleId: input.cycleId,
1770
+ roleId: input.role,
1771
+ gaps: recordGaps,
1772
+ correlationId: input.idempotencyKey,
1773
+ });
1774
+ // FR7: the per-role result is now durably committed, so any OpenPlanr-owned
1775
+ // scratch this cycle used as a handoff is redundant. Clean only confirmed
1776
+ // owned scratch (a no-op on the normal stdin path, which writes none).
1777
+ await cleanOperatingScratch(resolveOperatingPaths(input.projectRoot, { localRoot: input.localRoot }), input.cycleId);
1190
1778
  return {
1191
1779
  recorded: input.role,
1192
1780
  result,
@@ -1217,7 +1805,12 @@ export async function operateAdapterLifecycle(input) {
1217
1805
  handoff: await adapterHandoff(session),
1218
1806
  };
1219
1807
  }
1220
- const missingRoles = session.roles.filter((role) => !session.recordedRoles.includes(role));
1808
+ // T-020: a role is terminal for finalize once it recorded a result OR was
1809
+ // governed terminal `not_evaluated` (a lens abandoned after a genuine stall).
1810
+ // Only an in-flight lens — never dispatched to a terminal outcome — blocks
1811
+ // finalize, matching the T-001 wire contract that accepts an all-terminal board.
1812
+ const notEvaluatedRoles = session.notEvaluatedRoles ?? {};
1813
+ const missingRoles = session.roles.filter((role) => !session.recordedRoles.includes(role) && !(role in notEvaluatedRoles));
1221
1814
  if (missingRoles.length > 0) {
1222
1815
  throw new OperateError('E_OPERATE_ADVISOR_FAILED', `Adapter finalize is incomplete; missing roles: ${missingRoles.join(', ')}.`);
1223
1816
  }
@@ -1301,6 +1894,9 @@ export async function operateAdapterLifecycle(input) {
1301
1894
  });
1302
1895
  const finalized = { ...session, state: 'finalized' };
1303
1896
  await atomicPrivateWrite(target, finalized);
1897
+ // FR7: the cycle's advisory work is fully committed; clear any confirmed
1898
+ // OpenPlanr-owned scratch this cycle used as a handoff.
1899
+ await cleanOperatingScratch(resolveOperatingPaths(input.projectRoot, { localRoot: input.localRoot }), input.cycleId);
1304
1900
  return {
1305
1901
  session: adapterSessionSummary(finalized),
1306
1902
  results: results.map((result) => ({
@@ -1313,4 +1909,102 @@ export async function operateAdapterLifecycle(input) {
1313
1909
  handoff: await adapterHandoff(finalized),
1314
1910
  };
1315
1911
  }
1912
+ /**
1913
+ * SPEC-005 T-020 (FR13) — the OPERATOR escape, independent of a well-behaved
1914
+ * runtime. When a runtime dispatched a lens and then reported nothing at all — it
1915
+ * crashed, or does not implement `harness abandon` — the cycle is stranded at
1916
+ * `phase: advisors` on every continuation: the stalled lens is never recorded, so
1917
+ * it stays runnable-and-required and Chair is never assembled. The only prior
1918
+ * recourse was `cycles cancel`, which discards the whole cycle including the
1919
+ * siblings that DID record.
1920
+ *
1921
+ * This governed escape reaches a reviewable cycle without discarding it. It is
1922
+ * gated on a LAPSED lease — the legitimate signal that no runtime is actively
1923
+ * working the session — and an explicit operator confirmation. It never touches
1924
+ * the runtime lifecycle (`harness abandon`/`record`/`finalize`), so it works when
1925
+ * the runtime is gone. For each still-unrecorded advisor lens it commits the SAME
1926
+ * governed terminal `not_evaluated` gap the runtime path commits
1927
+ * (`governTerminalNotEvaluated`), so the next `run` continuation reconstructs the
1928
+ * lens `not_evaluated` from committed state and consolidation proceeds. Recorded
1929
+ * siblings are never read, re-dispatched, or altered. It fabricates nothing.
1930
+ *
1931
+ * It does not itself run consolidation — the operator reaches `reviewable` with a
1932
+ * following `planr operate run` (offline when no runtime is available), so the
1933
+ * choice of a real or offline Chair stays the operator's.
1934
+ */
1935
+ export async function reapStalledOperatingRoles(input) {
1936
+ if (!input.confirmed) {
1937
+ throw new OperateError('E_OPERATE_AUTHORITY_REQUIRED', 'Abandoning a stalled lens is a governed action; re-run with --yes to confirm.');
1938
+ }
1939
+ const nowMs = (input.now?.() ?? new Date()).getTime();
1940
+ const store = new OperatingEventStore(input.projectRoot, { localRoot: input.localRoot });
1941
+ const state = await store.state();
1942
+ const cycle = state.cycles.find((record) => record.id === input.cycleId);
1943
+ if (!cycle || !['advising', 'blocked'].includes(cycle.state)) {
1944
+ throw new OperateError('E_OPERATE_STATE_INVALID', `Cycle ${input.cycleId} must be advising or blocked to abandon a stalled lens.`);
1945
+ }
1946
+ // Read the machine-local session RAW (never `readAdapterSession`, which throws on
1947
+ // a lapsed lease — the exact condition this escape keys on).
1948
+ const target = adapterSessionPath(input.projectRoot, input.cycleId, input.localRoot);
1949
+ const session = await readFile(target, 'utf8')
1950
+ .then((raw) => JSON.parse(raw))
1951
+ .catch(() => null);
1952
+ if (!session) {
1953
+ throw new OperateError('E_OPERATE_STATE_INVALID', `No adapter session exists for cycle ${input.cycleId}; nothing was dispatched to abandon.`);
1954
+ }
1955
+ if (session.phase !== 'advisors') {
1956
+ throw new OperateError('E_OPERATE_STATE_INVALID', 'Only an advisors-phase session can have stalled lenses abandoned.');
1957
+ }
1958
+ // The lapsed-lease gate: a still-live lease means the runtime may yet record or
1959
+ // heartbeat, so the operator must not race it. Once the lease has lapsed, the
1960
+ // runtime is not governing the session and the operator may terminate the
1961
+ // lenses it left unrecorded.
1962
+ if (!sessionExpired(session, nowMs)) {
1963
+ throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', 'The adapter lease has not lapsed; the runtime may still be working. Wait for the lease to ' +
1964
+ 'expire, or use `planr operate harness abandon` with the active lease.', { recoveryCommand: retryRunCommand(session) });
1965
+ }
1966
+ const recorded = new Set(session.recordedRoles);
1967
+ const alreadyNotEvaluated = new Set(Object.keys(session.notEvaluatedRoles ?? {}));
1968
+ const candidates = session.roles.filter((role) => role !== 'chair' && !recorded.has(role) && !alreadyNotEvaluated.has(role));
1969
+ const targets = input.role ? candidates.filter((role) => role === input.role) : candidates;
1970
+ if (input.role && !session.roles.includes(input.role)) {
1971
+ throw new OperateError('E_OPERATE_CONFIG_INVALID', `Role ${input.role} was not part of the stalled session.`);
1972
+ }
1973
+ if (input.role && recorded.has(input.role)) {
1974
+ throw new OperateError('E_OPERATE_STATE_INVALID', `Role ${input.role} already recorded a result and cannot be abandoned.`);
1975
+ }
1976
+ const reason = input.reason?.trim() ||
1977
+ `Operator terminated this lens not_evaluated after its adapter lease lapsed on ` +
1978
+ `${new Date(nowMs).toISOString()} with no recorded result.`;
1979
+ const reaped = [];
1980
+ const notEvaluated = { ...(session.notEvaluatedRoles ?? {}) };
1981
+ for (const roleId of targets) {
1982
+ await governTerminalNotEvaluated({
1983
+ projectRoot: input.projectRoot,
1984
+ localRoot: input.localRoot,
1985
+ cycleId: input.cycleId,
1986
+ roleId,
1987
+ reason,
1988
+ correlationId: session.idempotencyKey,
1989
+ });
1990
+ // The committed gap is the source of truth; look it back up so the caller can
1991
+ // reference the durable governed gap id.
1992
+ const gap = (await store.state()).dataGaps.find((entry) => Array.isArray(entry.affectedRoles) && entry.affectedRoles.includes(roleId));
1993
+ reaped.push({ roleId, reason, gapId: String(gap?.id ?? '') });
1994
+ notEvaluated[roleId] = reason;
1995
+ }
1996
+ // Best-effort: reflect the terminal status in the (already lapsed) machine-local
1997
+ // session so an inspecting `harness resume`/handoff reads honestly. The committed
1998
+ // gap above — not this file — is what unblocks consolidation, so a failure here
1999
+ // is non-fatal.
2000
+ if (reaped.length > 0) {
2001
+ await atomicPrivateWrite(target, { ...session, notEvaluatedRoles: notEvaluated }).catch(() => undefined);
2002
+ }
2003
+ return {
2004
+ cycleId: input.cycleId,
2005
+ reaped,
2006
+ alreadyRecorded: [...recorded].sort(),
2007
+ next: [`planr operate run --cycle-id ${input.cycleId} --offline --yes`],
2008
+ };
2009
+ }
1316
2010
  //# sourceMappingURL=maintenance.js.map