@adhdev/daemon-core 1.0.18-rc.6 → 1.0.18-rc.8

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.
@@ -68,6 +68,41 @@ export interface CoordinatorOperatingNote {
68
68
  category?: 'provider_quirk' | 'pattern_to_avoid' | 'recovery_lesson';
69
69
  createdAt?: string;
70
70
  sourceCoordinator?: string;
71
+ /**
72
+ * Operating-notes lifecycle (minimal first cut). When true the note ALWAYS
73
+ * rides into the coordinator prompt — it is never dropped by TTL expiry and
74
+ * survives the injection cap ahead of unpinned notes. Legacy notes without
75
+ * this field default to false.
76
+ */
77
+ pinned?: boolean;
78
+ /**
79
+ * Optional explicit expiry ISO timestamp. When set and in the past, an
80
+ * UNPINNED note is dropped from the injected prompt (read-side only — the
81
+ * ledger entry is never pruned by age). When absent, the category→TTL map
82
+ * (see isNoteExpired) governs expiry; pinned notes never expire regardless.
83
+ */
84
+ expiresAt?: string;
85
+ /**
86
+ * Phase 2 — the ledger id of this note, when known. Threaded from the ledger
87
+ * entry id at launch so version-supersede can target a specific note and
88
+ * same-class folding can list the subsumed ids. Repo-declared notes may lack
89
+ * one; absent is fine (folding/supersede fall back to the subject key).
90
+ */
91
+ noteId?: string;
92
+ /**
93
+ * Phase 2 (b) version-supersede — an optional note-id or stable subject-key
94
+ * this note replaces. At injection, any earlier LIVE note whose `noteId` or
95
+ * `subjectKey` matches this value is hidden from the prompt (the ledger entry
96
+ * is retained for audit). Optional and lossless: absent = supersedes nothing.
97
+ */
98
+ supersedes?: string;
99
+ /**
100
+ * Phase 2 (b)/(c) — an optional stable subject-key grouping notes about the
101
+ * same subject. Drives version-supersede targeting and same-class folding.
102
+ * When absent, folding derives a key from a leading `[tag]` bracket instead,
103
+ * so legacy notes still collapse by their conventional tag prefix.
104
+ */
105
+ subjectKey?: string;
71
106
  }
72
107
  export interface CoordinatorPromptContext {
73
108
  mesh: LocalMeshEntry;
@@ -102,6 +137,46 @@ export interface CoordinatorPromptContext {
102
137
  magiKindPanels?: MagiKindPanelMap;
103
138
  }
104
139
  export declare function buildCoordinatorSystemPrompt(ctx: CoordinatorPromptContext): string;
140
+ /**
141
+ * Phase 2 (c) — deterministic same-class fold. Given ranked notes (already
142
+ * highest-priority first), collapse runs of UNPINNED notes that share the same
143
+ * category AND subject key into a single injected entry: keep the highest-ranked
144
+ * (first-seen) note and record the note-ids/count it subsumes so the rendered
145
+ * line can say "(+N earlier)". Pinned notes never fold — each pinned note is
146
+ * author-opted-in and shown verbatim. The store is untouched; this is a pure,
147
+ * model-free text fold at injection time.
148
+ *
149
+ * Returns the folded note list (order preserved) with per-entry subsumed info.
150
+ */
151
+ interface FoldedNote {
152
+ note: CoordinatorOperatingNote;
153
+ /** note-ids of same-class notes this entry subsumes (may be empty). */
154
+ subsumedIds: string[];
155
+ /** count of subsumed notes (== subsumedIds.length, but counts id-less ones too). */
156
+ subsumedCount: number;
157
+ }
158
+ /**
159
+ * Operating-notes lifecycle selection (read-side). Given the effective notes
160
+ * (oldest-first, ledger order) and the current time, produce the ordered list
161
+ * that rides into the prompt:
162
+ * (i) ALWAYS include pinned notes.
163
+ * (ii) drop expired UNPINNED notes (per category TTL / explicit expiresAt).
164
+ * (iii) Phase 2 (b) drop any note SUPERSEDED by a later live note (by noteId
165
+ * or subjectKey); pinned notes are never dropped by supersede.
166
+ * (iv) rank pinned-first, then durable-category, then recency (newest first).
167
+ * (v) Phase 2 (c) fold same-category/same-subject unpinned runs into one.
168
+ * (vi) Phase 2 (d) fill up to a byte budget AND a count cap; pinned always
169
+ * kept even if they alone exceed the byte budget.
170
+ * Pure + deterministic given `now`. Returns { shown, omittedCount } where `shown`
171
+ * carries per-entry fold info for the renderer.
172
+ *
173
+ * Ranking is stable on the original ledger index so, within a tier, newest notes
174
+ * lead. The caps keep the leading (highest-priority) entries.
175
+ */
176
+ export declare function selectOperatingNotesForPrompt(notes: CoordinatorOperatingNote[], now: number, cap?: number, byteBudget?: number): {
177
+ shown: FoldedNote[];
178
+ omittedCount: number;
179
+ };
105
180
  /**
106
181
  * Render the machine-local MAGI kind-panel bindings so the coordinator KNOWS
107
182
  * which cross-verification panels (rca / design / claim_audit / freeform) are
@@ -114,3 +189,4 @@ export declare function buildCoordinatorSystemPrompt(ctx: CoordinatorPromptConte
114
189
  * That keeps a MAGI-less mesh's prompt byte-identical to before.
115
190
  */
116
191
  export declare function buildMagiKindPanelsSection(panels: MagiKindPanelMap | undefined | null): string | null;
192
+ export {};
@@ -0,0 +1,105 @@
1
+ import type { SessionHostSurfaceRecordLike } from '../session-host/runtime-surface.js';
2
+ import type { LocalMeshEntry } from '../repo-mesh-types.js';
3
+ export declare const DAY_MS: number;
4
+ /** JSONL ledger files are legacy after the SQLite ledger — 30-day lifetime. */
5
+ export declare const LEDGER_JSONL_MAX_AGE_MS: number;
6
+ /** Terminated session-host runtimes: conservative 14-day retention. */
7
+ export declare const SESSION_HOST_RUNTIME_MAX_AGE_MS: number;
8
+ /** mesh-runtime.db.bak-* backups: 7-day retention. */
9
+ export declare const DB_BAK_MAX_AGE_MS: number;
10
+ /** A file candidate for age-based pruning: absolute path + last-modified epoch ms. */
11
+ export interface AgedFile {
12
+ path: string;
13
+ mtimeMs: number;
14
+ }
15
+ /**
16
+ * PURE. Select the JSONL ledger files whose mtime is older than `maxAgeMs`
17
+ * relative to `now`. A file exactly at the threshold is KEPT (strict `>`), so a
18
+ * 30-day-old file survives its 30th day and is pruned on the 31st. Deterministic:
19
+ * no fs access, no clock read.
20
+ */
21
+ export declare function selectExpiredLedgerJsonl(files: AgedFile[], now: number, maxAgeMs?: number): AgedFile[];
22
+ /** A parsed session-host runtime file: its path, mtime, and the wrapped record. */
23
+ export interface SessionHostRuntimeFile {
24
+ path: string;
25
+ mtimeMs: number;
26
+ /** The `record` object from the on-disk `{ record, snapshot, updatedAt }` file. */
27
+ record: SessionHostSurfaceRecordLike | null;
28
+ }
29
+ /**
30
+ * PURE. Select the session-host runtime files safe to delete: ONLY those whose
31
+ * runtime is terminated/dead (NOT a live runtime — decided by the session-host-core
32
+ * SSOT `isSessionHostLiveRuntime`) AND older than `maxAgeMs`. A live runtime, or a
33
+ * dead-but-recent one, is always kept. A file whose record failed to parse is treated
34
+ * as NON-live but is still age-gated, so a corrupt-but-fresh file is never removed.
35
+ */
36
+ export declare function selectExpiredSessionHostRuntimes(files: SessionHostRuntimeFile[], now: number, maxAgeMs?: number): SessionHostRuntimeFile[];
37
+ /** True for a `mesh-runtime.db.bak-*` backup filename (basename only). */
38
+ export declare function isDbBackupFileName(name: string): boolean;
39
+ /** PURE. Select `.bak-*` backups older than `maxAgeMs`. Strict `>` (see ledger). */
40
+ export declare function selectExpiredDbBackups(files: AgedFile[], now: number, maxAgeMs?: number): AgedFile[];
41
+ /** Minimal shape needed to decide whether a worktree path is orphaned. */
42
+ export interface WorktreePathLike {
43
+ path: string;
44
+ bare?: boolean;
45
+ }
46
+ /** A live mesh node's known workspace paths (self workspace + repoRoot, normalized). */
47
+ export interface LiveNodeWorkspaceLike {
48
+ workspace?: string;
49
+ repoRoot?: string;
50
+ }
51
+ /**
52
+ * PURE. Given the worktrees git reports on disk and the set of live-node workspace
53
+ * paths, return the worktrees that have NO matching live node — the orphan
54
+ * cleanup_candidates.
55
+ *
56
+ * SAFETY:
57
+ * - `mainWorktreePath` (the primary repo checkout, i.e. worktree[0]) is NEVER an
58
+ * orphan — it is the base repo, not a mesh clone.
59
+ * - `bare` worktrees are skipped (git's internal bookkeeping, not a node checkout).
60
+ * - Matching is path-equality after trailing-separator normalization against the
61
+ * union of every live node's `workspace` and `repoRoot`.
62
+ * This is DETECTION ONLY — the caller signals a cleanup_candidate; it must not delete.
63
+ */
64
+ export declare function detectOrphanWorktrees(worktrees: WorktreePathLike[], liveNodes: LiveNodeWorkspaceLike[], mainWorktreePath: string): WorktreePathLike[];
65
+ /**
66
+ * Prune expired legacy JSONL ledger files under ~/.adhdev/mesh-ledger/.
67
+ * Matches only `*.jsonl` (never the SQLite .db / -wal / -shm files). Returns the
68
+ * count deleted. Best-effort: individual unlink failures are logged, not thrown.
69
+ */
70
+ export declare function pruneExpiredLedgerJsonl(now?: number): number;
71
+ /**
72
+ * Prune expired `mesh-runtime.db.bak-*` backups under ~/.adhdev/mesh-ledger/.
73
+ * Never touches the live DB (only names matching the .bak- prefix). Returns count.
74
+ */
75
+ export declare function pruneExpiredDbBackups(now?: number): number;
76
+ /**
77
+ * Prune terminated session-host runtime files older than 14 days across every
78
+ * ~/.adhdev/session-host/<app>/runtimes/ directory. A LIVE runtime is never deleted
79
+ * regardless of age (isSessionHostLiveRuntime SSOT). Returns count deleted.
80
+ */
81
+ export declare function pruneExpiredSessionHostRuntimes(now?: number): number;
82
+ /**
83
+ * Run the file-deleting retention passes (JSONL ledger, DB backups, session-host
84
+ * runtimes) once. Each pass is isolated so one failing pass never blocks the others.
85
+ * Orphan-worktree DETECTION is driven separately in the reconcile loop (it needs the
86
+ * live mesh config + git worktree list and emits a ledger signal rather than deleting).
87
+ */
88
+ export declare function runDiskRetentionSweep(now?: number): {
89
+ ledgerJsonl: number;
90
+ dbBackups: number;
91
+ sessionHostRuntimes: number;
92
+ };
93
+ /**
94
+ * Detect orphaned worktrees for ONE mesh and emit a `worktree_cleanup_candidate`
95
+ * ledger signal for each — DETECTION ONLY, never deletes. An orphan is a git worktree
96
+ * on disk with no matching live mesh node (compared by path). It:
97
+ * 1. picks a base (non-worktree) node owned by this mesh to anchor `git worktree list`;
98
+ * 2. diffs the reported worktrees against the union of every node's workspace/repoRoot;
99
+ * 3. skips the main worktree + bare entries (detectOrphanWorktrees safety);
100
+ * 4. suppresses a repeat for a worktreePath already signalled within the recent window
101
+ * (idempotent re-emit guard), so the hourly sweep doesn't spam the ledger.
102
+ * Returns the list of newly-signalled orphan paths. Best-effort: git/ledger failures are
103
+ * logged and yield an empty result, never thrown.
104
+ */
105
+ export declare function detectAndSignalOrphanWorktrees(mesh: LocalMeshEntry, now?: number): Promise<string[]>;
@@ -15,7 +15,7 @@
15
15
  import { EventEmitter } from 'events';
16
16
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
17
17
  import { type MeshLedgerOriginatingCoordinatorV2 } from './contracts.js';
18
- export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'event_held_requeued' | 'task_reclaimed' | 'coordinator_operating_note' | 'coordinator_operating_note_tombstone' | 'mission_created' | 'mission_status_changed' | 'mission_goal_updated' | 'magi_dispatched' | 'magi_synthesis' | 'key_injection';
18
+ export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'event_held_requeued' | 'task_reclaimed' | 'coordinator_operating_note' | 'coordinator_operating_note_tombstone' | 'mission_created' | 'mission_status_changed' | 'mission_goal_updated' | 'magi_dispatched' | 'magi_synthesis' | 'key_injection' | 'worktree_cleanup_candidate';
19
19
  export interface MeshLedgerEntry {
20
20
  id: string;
21
21
  meshId: string;
@@ -162,6 +162,33 @@ export declare const OPERATING_NOTE_KIND: MeshLedgerKind;
162
162
  export declare const OPERATING_NOTE_TOMBSTONE_KIND: MeshLedgerKind;
163
163
  export declare const OPERATING_NOTE_DEDUPE_WINDOW = 40;
164
164
  export declare const OPERATING_NOTE_KEEP_LATEST = 100;
165
+ export declare const OPERATING_NOTE_CATEGORY_TTL_DAYS: Readonly<Record<string, number>>;
166
+ /**
167
+ * Shape isNoteExpired reads. Structural so mesh-ledger stays free of a
168
+ * coordinator-prompt import (CoordinatorOperatingNote satisfies this).
169
+ */
170
+ export interface OperatingNoteExpiryInput {
171
+ category?: string;
172
+ pinned?: boolean;
173
+ createdAt?: string;
174
+ /** Explicit expiry override; wins over the category TTL when parseable. */
175
+ expiresAt?: string;
176
+ /** Fallback creation time (ledger entry timestamp) when createdAt absent. */
177
+ timestamp?: string;
178
+ }
179
+ /**
180
+ * Pure helper: is this UNPINNED operating note expired as of `now` (epoch ms)?
181
+ *
182
+ * Rules:
183
+ * - pinned notes NEVER expire (always false).
184
+ * - an explicit, parseable `expiresAt` in the past → expired.
185
+ * - otherwise the category TTL applies; a durable category (provider_quirk,
186
+ * uncategorized, or any category not in the TTL map) never expires.
187
+ * - age is measured from createdAt, falling back to `timestamp` (ledger entry
188
+ * time). If neither is a valid date, the note is treated as NOT expired
189
+ * (never silently drop a note we cannot age).
190
+ */
191
+ export declare function isNoteExpired(note: OperatingNoteExpiryInput, now: number): boolean;
165
192
  export declare function getLedgerDir(): string;
166
193
  /**
167
194
  * Footer to append to worker task messages so workers output structured results
@@ -23,6 +23,17 @@ export declare class MeshRuntimeStore {
23
23
  private static loggedGetInstanceFailure;
24
24
  static getInstance(): MeshRuntimeStore;
25
25
  static resetForTests(): void;
26
+ /**
27
+ * VACUUM the SQLite database to reclaim on-disk space. Retention prunes rows
28
+ * with DELETE, which frees pages inside the file but does NOT shrink it — the
29
+ * mesh-runtime.db grew to hundreds of MB (mission 86def38d disk-accumulation
30
+ * bootstrap failure) precisely because the file was never compacted. This
31
+ * rewrites the DB into a minimal footprint. Best-effort: a VACUUM failure (e.g.
32
+ * insufficient temp space, a read lock) is logged and swallowed so it can never
33
+ * block daemon shutdown. Called once on shutdown (see daemon-lifecycle), never
34
+ * on the hot path — VACUUM takes an exclusive lock and rewrites the whole file.
35
+ */
36
+ vacuum(): void;
26
37
  close(): void;
27
38
  transaction<T>(fn: () => T): T;
28
39
  private migrate;
@@ -39,6 +39,8 @@ export declare const COMPLETED_FINALIZATION_RETRY_MS = 1000;
39
39
  export declare const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30000;
40
40
  export declare const NATIVE_HISTORY_MESH_IDLE_SETTLE_MS = 4000;
41
41
  export declare const PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS = 1200;
42
+ export declare const ANTIGRAVITY_HOLD_QUIET_DWELL_MS = 3000;
43
+ export declare const ANTIGRAVITY_HOLD_HARD_CAP_MS: number;
42
44
  export declare const BACKGROUND_TASK_HOLD_MAX_MS: number;
43
45
  export declare const USER_INPUT_ACK_DEDUP_WINDOW_MS = 60000;
44
46
  export declare const STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS = 12000;
@@ -201,6 +201,7 @@ export declare class CliProviderInstance implements ProviderInstance {
201
201
  private generatingDebounceTimer;
202
202
  private generatingDebouncePending;
203
203
  private lastApprovalEventFingerprint;
204
+ private lastInteractivePromptEventKey;
204
205
  private autoApproveBusy;
205
206
  private autoApproveBusyTimer;
206
207
  private lastAutoApprovalSignature;
@@ -473,6 +474,7 @@ export declare class CliProviderInstance implements ProviderInstance {
473
474
  private completionFinalSummary;
474
475
  private buildCompletedFinalizationDiagnostic;
475
476
  private hasAdapterPendingResponse;
477
+ private antigravityHoldPtyStillActive;
476
478
  private shouldSuppressStaleParsedBusyStatus;
477
479
  private getCompletedFinalizationBlock;
478
480
  private hasApprovalResolutionEvidence;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.18-rc.6",
3
+ "version": "1.0.18-rc.8",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "1.0.18-rc.6",
51
- "@adhdev/session-host-core": "1.0.18-rc.6",
50
+ "@adhdev/mesh-shared": "1.0.18-rc.8",
51
+ "@adhdev/session-host-core": "1.0.18-rc.8",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -39,6 +39,7 @@ import type { IdeProviderInstance } from '../providers/ide-provider-instance.js'
39
39
  import { createDefaultGitCommandServices } from '../git/git-commands.js';
40
40
  import { setupMeshEventForwarding } from '../mesh/mesh-events.js';
41
41
  import { setupMeshReconcileLoop } from '../mesh/mesh-reconcile-loop.js';
42
+ import { MeshRuntimeStore } from '../mesh/mesh-runtime-store.js';
42
43
  import { loadMeshCoordinatorRegistry } from '../mesh/coordinator-registry.js';
43
44
  import { applyProcessHardening } from './process-hardening.js';
44
45
  import { installProviderProcessShim } from '../providers/sdk/v1/sandbox/require-whitelist.js';
@@ -503,4 +504,13 @@ export async function shutdownDaemonComponents(components: DaemonComponents): Pr
503
504
  try { m.disconnect(); } catch { /* noop */ }
504
505
  }
505
506
  cdpManagers.clear();
507
+
508
+ // 7. VACUUM the mesh runtime DB. Retention prunes rows with DELETE (frees pages
509
+ // inside the file but never shrinks it); the mesh-runtime.db grew to hundreds of
510
+ // MB (mission 86def38d disk-accumulation bootstrap failure) because it was never
511
+ // compacted. Do this LAST, after all writers (reconcile loop, CLIs, instances)
512
+ // are stopped, so nothing contends for the exclusive VACUUM lock. Best-effort —
513
+ // vacuum() swallows its own errors; the try/catch guards the getInstance() throw
514
+ // when the store never opened (degraded JSONL-only mode).
515
+ try { MeshRuntimeStore.getInstance().vacuum(); } catch { /* store unavailable — nothing to vacuum */ }
506
516
  }
@@ -115,8 +115,12 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
115
115
  try {
116
116
  const { readOperatingNotes } = await import('../../mesh/mesh-ledger.js');
117
117
  // readOperatingNotes filters out tombstoned (forgotten) notes so a
118
- // retracted lesson never rides into the prompt. Newest last; tail 20.
119
- const noteEntries = readOperatingNotes(id, { tail: 20 });
118
+ // retracted lesson never rides into the prompt. Newest last.
119
+ // Phase 2 (d): the byte-budget/count cap now bounds the injected list
120
+ // (selectOperatingNotesForPrompt), so read a larger candidate tail than
121
+ // the old fixed 20 and let injection-side ranking + budget do the
122
+ // bounding. Keep a sane store-read ceiling to avoid unbounded arrays.
123
+ const noteEntries = readOperatingNotes(id, { tail: 100 });
120
124
  const notes = noteEntries
121
125
  .map((e) => {
122
126
  const p = (e.payload || {}) as Record<string, unknown>;
@@ -131,6 +135,17 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
131
135
  category,
132
136
  createdAt: typeof p.createdAt === 'string' ? p.createdAt : e.timestamp,
133
137
  sourceCoordinator: typeof p.sourceCoordinator === 'string' ? p.sourceCoordinator : undefined,
138
+ // Operating-notes lifecycle: thread pinned/expiresAt so the
139
+ // injection-side selection can honor them. Legacy notes lack
140
+ // these → pinned defaults false, expiry governed by category TTL.
141
+ pinned: p.pinned === true,
142
+ ...(typeof p.expiresAt === 'string' ? { expiresAt: p.expiresAt } : {}),
143
+ // Phase 2 (b)/(c): thread the ledger id + supersedes/subjectKey
144
+ // so version-supersede targeting and same-class folding work.
145
+ // Legacy notes lack them → no supersede, fold by leading [tag].
146
+ noteId: e.id,
147
+ ...(typeof p.supersedes === 'string' ? { supersedes: p.supersedes } : {}),
148
+ ...(typeof p.subjectKey === 'string' ? { subjectKey: p.subjectKey } : {}),
134
149
  };
135
150
  })
136
151
  .filter((n): n is NonNullable<typeof n> => n !== null);
@@ -162,13 +162,31 @@ function quotePowerShellLiteral(value: string): string {
162
162
  function pinStagedShims(prefix: string, portableNode: string, cliEntry: string): void {
163
163
  const cmdPath = path.join(prefix, 'adhdev.cmd');
164
164
  const ps1Path = path.join(prefix, 'adhdev.ps1');
165
+ // The no-extension `adhdev` shim is what `where.exe adhdev` can resolve first
166
+ // and what git-bash / MSYS / WSL and `spawnSync('adhdev')` invoke. npm generates
167
+ // it as a POSIX `sh` shim whose ELSE branch falls back to the FIRST `node` on
168
+ // PATH (`else exec node ...`). On a box where system PATH node is v24, that
169
+ // fallback trips adhdev's own Node-24 guard and `adhdev doctor` reports the
170
+ // runtime surface broken. Rewrite it too so it hard-codes the portable Node 22
171
+ // absolute path with NO system-node fallback — mirroring the .cmd/.ps1 pins.
172
+ const noExtPath = path.join(prefix, 'adhdev');
165
173
  const cmd = `@echo off\r\n"${portableNode}" "${cliEntry}" %*\r\n`;
166
174
  const ps1 = `#!/usr/bin/env pwsh\r\n& ${quotePowerShellLiteral(portableNode)} ${quotePowerShellLiteral(cliEntry)} @args\r\nexit $LASTEXITCODE\r\n`;
175
+ // sh shim: single unconditional exec of the pinned node — no `if -x node` /
176
+ // `else exec node` branch, so PATH ordering can never shadow the runtime.
177
+ const noExt = `#!/bin/sh\nexec "${portableNode}" "${cliEntry}" "$@"\n`;
167
178
  fs.writeFileSync(cmdPath, cmd, 'ascii');
168
179
  fs.writeFileSync(ps1Path, ps1, 'utf8');
180
+ fs.writeFileSync(noExtPath, noExt, 'ascii');
169
181
  const cmdReadback = fs.readFileSync(cmdPath, 'utf8');
170
182
  const ps1Readback = fs.readFileSync(ps1Path, 'utf8');
171
- if (!cmdReadback.includes(portableNode) || !ps1Readback.includes(portableNode)) {
183
+ const noExtReadback = fs.readFileSync(noExtPath, 'utf8');
184
+ if (
185
+ !cmdReadback.includes(portableNode)
186
+ || !ps1Readback.includes(portableNode)
187
+ || !noExtReadback.includes(portableNode)
188
+ || /(^|\s)exec\s+node(\s|$)/m.test(noExtReadback)
189
+ ) {
172
190
  throw new Error('portable Node 22 pin validation failed');
173
191
  }
174
192
  }
@@ -160,6 +160,14 @@ function normalizeOperatingNote(value: unknown): CoordinatorOperatingNote | null
160
160
  ...(category ? { category } : {}),
161
161
  ...(typeof value.createdAt === 'string' ? { createdAt: value.createdAt } : {}),
162
162
  ...(typeof value.sourceCoordinator === 'string' ? { sourceCoordinator: value.sourceCoordinator } : {}),
163
+ // Operating-notes lifecycle: a repo-declared note may pin itself or set an
164
+ // explicit expiry, same as a runtime-recorded one. Phase 2 (b)/(c): it may
165
+ // also declare supersedes/subjectKey to retire an earlier note or group
166
+ // same-subject notes for folding.
167
+ ...(value.pinned === true ? { pinned: true } : {}),
168
+ ...(typeof value.expiresAt === 'string' ? { expiresAt: value.expiresAt } : {}),
169
+ ...(typeof value.supersedes === 'string' ? { supersedes: value.supersedes } : {}),
170
+ ...(typeof value.subjectKey === 'string' ? { subjectKey: value.subjectKey } : {}),
163
171
  };
164
172
  }
165
173