@staix/agent-hub 0.12.24 → 0.12.25
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.
- package/CHANGELOG.md +11 -1
- package/README.md +2 -2
- package/docs/agent-notes/adapters.md +2 -0
- package/docs/agent-notes/tasks.md +4 -0
- package/docs/agent-notes/tests.md +3 -0
- package/docs/data-policy.md +1 -1
- package/docs/operations.md +24 -14
- package/docs/quickstart.md +2 -2
- package/docs/smoke.md +16 -10
- package/docs/specs/2026-09-19-agent-hub-design.md +32 -0
- package/docs/text-projection.md +2 -2
- package/docs/verification/2026-10-14-0.12.24-audit.md +123 -0
- package/docs/verification/2026-10-14-0.12.25.md +27 -0
- package/docs/verified.json +11 -11
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/adapters/pi.ts +79 -5
- package/src/hub/daemon.ts +7 -3
- package/src/hub/path-policy.ts +25 -6
- package/src/hub/tasks.ts +17 -6
- package/src/hub/text-projection.ts +36 -11
- package/src/hub/tooling.ts +19 -8
- package/src/memory/store.ts +9 -2
- package/src/pi/extension.ts +40 -5
- package/src/ui/index.html +5 -4
package/src/adapters/pi.ts
CHANGED
|
@@ -28,6 +28,10 @@ export interface PiExit {
|
|
|
28
28
|
export interface PiToolContext { userBash: boolean; isCurrent: () => boolean; onApproval: (provenance: ApprovalProvenance) => void; }
|
|
29
29
|
export interface PiToolResult { text: string; exitCode: number | null; }
|
|
30
30
|
class PiToolSettlementFailure extends Error {}
|
|
31
|
+
class PiPromptNotStarted extends Error {
|
|
32
|
+
constructor(readonly promptId: string) { super("Pi native session changed before prompt insertion; reverify its retained terminal before retrying"); }
|
|
33
|
+
}
|
|
34
|
+
class PiPromptUncertain extends Error {}
|
|
31
35
|
export interface PiOptions {
|
|
32
36
|
cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
|
|
33
37
|
model?: string;
|
|
@@ -101,6 +105,11 @@ export class PiPeer extends BasePeer {
|
|
|
101
105
|
private stopping = true;
|
|
102
106
|
private rosterDetached = false;
|
|
103
107
|
private rosterEpoch = 0;
|
|
108
|
+
private rosterLifecycleRevision = 0;
|
|
109
|
+
private fencedPrompt = false;
|
|
110
|
+
private hubPromptId: string | undefined;
|
|
111
|
+
private hubPromptGeneration: number | undefined;
|
|
112
|
+
private pendingPromptId: string | undefined;
|
|
104
113
|
/** A personal native turn observed on reattach has no hub reply audience. */
|
|
105
114
|
private rosterUnownedRun = false;
|
|
106
115
|
private exitReported = false;
|
|
@@ -319,6 +328,7 @@ export class PiPeer extends BasePeer {
|
|
|
319
328
|
this.rosterDetached = false; this.rosterUnownedRun = false;
|
|
320
329
|
this.requestedStopReason = undefined;
|
|
321
330
|
this.approvalTurnAbort = false;
|
|
331
|
+
this.fencedPrompt = false; this.hubPromptId = undefined; this.hubPromptGeneration = undefined; this.pendingPromptId = undefined;
|
|
322
332
|
this.exitReported = false; this.started = false; this.lastToolName = undefined; this.shutdownExit = undefined;
|
|
323
333
|
this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
|
|
324
334
|
mkdirSync(this.opts.stateDir, { recursive: true });
|
|
@@ -347,7 +357,13 @@ export class PiPeer extends BasePeer {
|
|
|
347
357
|
if (url.pathname === "/ack") {
|
|
348
358
|
const pending = this.tuiCommands.get(String(body.id));
|
|
349
359
|
if (!pending) return Response.json({ ok: false }, { status: 404 });
|
|
350
|
-
this.tuiCommands.delete(String(body.id));
|
|
360
|
+
this.tuiCommands.delete(String(body.id));
|
|
361
|
+
const refusal = body.refusal;
|
|
362
|
+
const boundRefusal = this.fencedPrompt && this.verifiedOwner() && pending.command.type === "prompt" && pending.command.sessionId === this.sessionId
|
|
363
|
+
&& refusal?.promptId === pending.command.id && refusal.sessionId === this.sessionId
|
|
364
|
+
&& Number.isSafeInteger(refusal.generation) && refusal.generation >= Number(pending.command.generation);
|
|
365
|
+
body.ok ? pending.resolve(body.result) : pending.reject(boundRefusal && refusal.kind === "prompt_not_started" ? new PiPromptNotStarted(String(body.id))
|
|
366
|
+
: boundRefusal && refusal.kind === "prompt_uncertain" ? new PiPromptUncertain("Pi native prompt insertion is uncertain; inspect its retained terminal before retrying") : new Error(String(body.error ?? "Pi TUI command failed")));
|
|
351
367
|
return Response.json({ ok: true });
|
|
352
368
|
}
|
|
353
369
|
if (url.pathname === "/tool") return this.handleToolRequest(body);
|
|
@@ -437,10 +453,14 @@ export class PiPeer extends BasePeer {
|
|
|
437
453
|
async resumeFromRoster(): Promise<void> {
|
|
438
454
|
if (!this.rosterDetached) return;
|
|
439
455
|
if (this.opts.mode !== "tui" || this.stopping || !this.rosterDetached || !this.server || !this.verifiedOwner()) throw new Error("Pi roster return has no verified live TUI owner");
|
|
440
|
-
|
|
456
|
+
if (!this.fencedPrompt) throw new Error("Pi roster return needs a fenced-prompt extension; restart the retained terminal with the current hub extension");
|
|
457
|
+
if (this.pendingPromptId) throw new Error("Pi prompt admission is still pending; wait before reverifying the retained terminal");
|
|
458
|
+
const epoch = this.rosterEpoch, lifecycle = this.rosterLifecycleRevision, session = this.sessionId, file = this.sessionFile;
|
|
441
459
|
const snapshot = await this.sendTui({ type: "get_session_state" });
|
|
442
460
|
if (epoch !== this.rosterEpoch || this.stopping || !this.verifiedOwner() || this.activeTools || snapshot?.sessionId !== session || snapshot?.sessionFile !== file || typeof snapshot?.idle !== "boolean") throw new Error("Pi roster return could not verify the current native session");
|
|
461
|
+
if (lifecycle !== this.rosterLifecycleRevision) throw new Error("Pi native activity changed during roster return; retry with a fresh session snapshot");
|
|
443
462
|
this.rosterDetached = false;
|
|
463
|
+
this.hubPromptId = undefined; this.hubPromptGeneration = undefined;
|
|
444
464
|
this.rosterUnownedRun = !snapshot.idle; this.agentRunning = !snapshot.idle;
|
|
445
465
|
this.setState(snapshot.idle ? "idle" : "busy");
|
|
446
466
|
if (!snapshot.idle) this.clearWatchdog();
|
|
@@ -534,6 +554,7 @@ export class PiPeer extends BasePeer {
|
|
|
534
554
|
this.setState("busy"); this.activeEnvs = envs; this.currentReply = replyParent(envs); this.settledText = "";
|
|
535
555
|
const rosterEpoch = this.rosterEpoch;
|
|
536
556
|
let attempted = false;
|
|
557
|
+
let promptId: string | undefined;
|
|
537
558
|
try {
|
|
538
559
|
const requested = await this.opts.selectModel?.(envs);
|
|
539
560
|
if (rosterEpoch !== this.rosterEpoch || !this.acceptingTools) throw new Error("Pi is off roster or stopped");
|
|
@@ -542,10 +563,32 @@ export class PiPeer extends BasePeer {
|
|
|
542
563
|
this.noteActivity();
|
|
543
564
|
attempted = true;
|
|
544
565
|
if (deliveryId) this.activeDeliveryIds.add(deliveryId);
|
|
545
|
-
if (this.opts.mode === "tui")
|
|
566
|
+
if (this.opts.mode === "tui") { const pending = this.sendTui({ type: "prompt", message: renderDigest(envs, true) }); promptId = this.pendingPromptId; await pending; }
|
|
546
567
|
else await this.sendRpc({ type: "prompt", message: renderDigest(envs, true) });
|
|
568
|
+
if (promptId !== undefined && promptId !== this.pendingPromptId) return;
|
|
569
|
+
if (this.fencedPrompt && this.rosterDetached) {
|
|
570
|
+
if (deliveryId && this.activeDeliveryIds.delete(deliveryId)) this.delivery({ id: deliveryId, state: "needs_review", reason: "Pi native activity changed before prompt acceptance was confirmed" });
|
|
571
|
+
this.setState("offline");
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
547
574
|
if (deliveryId && this.activeDeliveryIds.has(deliveryId)) this.delivery({ id: deliveryId, state: "accepted" });
|
|
548
575
|
} catch (error) {
|
|
576
|
+
if (promptId !== undefined && promptId !== this.pendingPromptId) return;
|
|
577
|
+
if (error instanceof PiPromptUncertain) { this.pendingPromptId = undefined; await this.detachForRoster(); return; }
|
|
578
|
+
if (error instanceof PiPromptNotStarted && error.promptId === this.pendingPromptId) {
|
|
579
|
+
this.pendingPromptId = undefined;
|
|
580
|
+
if (deliveryId) this.activeDeliveryIds.delete(deliveryId);
|
|
581
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: error.message });
|
|
582
|
+
else this.onFailed?.(envs);
|
|
583
|
+
await this.detachForRoster();
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (this.fencedPrompt && this.pendingPromptId && this.rosterDetached) {
|
|
587
|
+
this.pendingPromptId = undefined;
|
|
588
|
+
if (deliveryId && this.activeDeliveryIds.delete(deliveryId)) this.delivery({ id: deliveryId, state: "needs_review", reason: "Pi prompt acknowledgement is uncertain after personal native activity" });
|
|
589
|
+
this.setState("offline");
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
549
592
|
if (rosterEpoch !== this.rosterEpoch) {
|
|
550
593
|
if (!attempted && deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: "Pi withdrawn before native dispatch" });
|
|
551
594
|
return;
|
|
@@ -563,11 +606,13 @@ export class PiPeer extends BasePeer {
|
|
|
563
606
|
this.delivery({ id: deliveryId, state: "failed_safe", reason });
|
|
564
607
|
}
|
|
565
608
|
await this.terminateFailedTurn(error instanceof Error ? error : new Error(reason));
|
|
609
|
+
} finally {
|
|
610
|
+
if (promptId !== undefined && this.pendingPromptId === promptId) this.pendingPromptId = undefined;
|
|
566
611
|
}
|
|
567
612
|
}
|
|
568
613
|
|
|
569
614
|
async steer(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
570
|
-
if (this.state !== "busy" || !this.acceptingTools || envs.some((e) => e.kind !== "chat" || e.private || e.priority !== "important")) {
|
|
615
|
+
if (this.state !== "busy" || !this.acceptingTools || (this.fencedPrompt && this.pendingPromptId && this.hubPromptGeneration === undefined) || envs.some((e) => e.kind !== "chat" || e.private || e.priority !== "important")) {
|
|
571
616
|
const reason = `Pi workflow/private messages must queue${this.rosterDetached ? ": off roster" : this.rosterUnownedRun ? ": native turn pending" : ""}`;
|
|
572
617
|
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason });
|
|
573
618
|
throw new Error(reason);
|
|
@@ -597,14 +642,39 @@ export class PiPeer extends BasePeer {
|
|
|
597
642
|
}
|
|
598
643
|
|
|
599
644
|
private handleBridgeEvent(event: any): void {
|
|
645
|
+
if (this.fencedPrompt && this.opts.mode === "tui" && !this.rosterUnownedRun && ["agent_start", "agent_end", "agent_settled"].includes(event.type)
|
|
646
|
+
&& typeof event.promptId === "string" && event.promptId !== this.hubPromptId) return;
|
|
647
|
+
if (this.fencedPrompt && this.opts.mode === "tui" && (this.activeEnvs.length > 0 || this.pendingPromptId || this.hubPromptId) && !this.rosterDetached && !this.rosterUnownedRun && !this.stopping && ["agent_start", "agent_end", "agent_settled"].includes(event.type)) {
|
|
648
|
+
const bound = this.verifiedOwner() && (event.sessionId === undefined || event.sessionId === this.sessionId);
|
|
649
|
+
if (!bound || !Number.isSafeInteger(event.generation) || event.generation < this.budgetGeneration) return;
|
|
650
|
+
const owned = this.hubPromptId !== undefined && event.promptId === this.hubPromptId;
|
|
651
|
+
if (event.type === "agent_start" && event.generation > this.budgetGeneration) {
|
|
652
|
+
if (owned) this.hubPromptGeneration = event.generation;
|
|
653
|
+
else {
|
|
654
|
+
if (this.pendingPromptId || this.activeEnvs.length) {
|
|
655
|
+
// Await the prompt's exact ACK before deciding whether its effect was absent or uncertain.
|
|
656
|
+
this.rosterDetached = true; this.rosterEpoch++; this.clearWatchdog();
|
|
657
|
+
this.activeEnvs = []; this.currentReply = undefined; this.settledText = ""; this.settledError = "";
|
|
658
|
+
this.abortTools();
|
|
659
|
+
if (!this.pendingPromptId) {
|
|
660
|
+
for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: "Pi personal native activity replaced the accepted hub turn" });
|
|
661
|
+
this.activeDeliveryIds.clear(); this.setState("offline");
|
|
662
|
+
}
|
|
663
|
+
} else { this.rosterUnownedRun = true; this.setState("busy"); this.clearWatchdog(); }
|
|
664
|
+
}
|
|
665
|
+
} else if (!this.rosterUnownedRun && (!owned || event.generation !== this.hubPromptGeneration)) return;
|
|
666
|
+
}
|
|
600
667
|
if (this.rosterDetached || this.rosterUnownedRun) {
|
|
601
668
|
// The shipped extension's legacy agent_start omits sessionId. Its authenticated bridge
|
|
602
669
|
// and still-verified owner bind that event to this session; an explicit mismatch never does.
|
|
603
670
|
const bound = !this.stopping && this.verifiedOwner() && (event.sessionId === undefined || event.sessionId === this.sessionId);
|
|
604
671
|
if (bound && event.type === "agent_start" && Number.isSafeInteger(event.generation) && event.generation > this.budgetGeneration) {
|
|
672
|
+
this.rosterLifecycleRevision++;
|
|
605
673
|
this.budgetGeneration = event.generation;
|
|
606
674
|
this.noteActivity(); // personal native activity also revokes an earlier ID-only empty-resume proof
|
|
607
675
|
}
|
|
676
|
+
// Snapshot ACKs and lifecycle events use separate requests, so their arrival order is not native state order.
|
|
677
|
+
if (bound && event.type === "agent_settled" && (event.generation === undefined || event.generation === this.budgetGeneration)) this.rosterLifecycleRevision++;
|
|
608
678
|
if (!bound && event.type !== "session_shutdown") return;
|
|
609
679
|
}
|
|
610
680
|
if (this.rosterDetached && event.type !== "session_shutdown") return;
|
|
@@ -619,6 +689,7 @@ export class PiPeer extends BasePeer {
|
|
|
619
689
|
if (event.type === "session_start") {
|
|
620
690
|
if (!this.ownerClaimed) { this.ownerClaimed = true; this.ownerToken = String(event.ownerToken ?? ""); }
|
|
621
691
|
this.approvalTurnAbort = event.approvalTurnAbort === true;
|
|
692
|
+
this.fencedPrompt ||= event.fencedPrompt === true;
|
|
622
693
|
this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
|
|
623
694
|
this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
|
|
624
695
|
if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) { this.opts.log?.(`[${this.id}] Pi session identity mismatch`); return; }
|
|
@@ -699,6 +770,7 @@ export class PiPeer extends BasePeer {
|
|
|
699
770
|
for (const id of this.activeDeliveryIds) this.delivery({ id, state: approvalStopReason ? "completed" : error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
|
|
700
771
|
this.activeDeliveryIds.clear();
|
|
701
772
|
this.currentReply = undefined; this.activeEnvs = [];
|
|
773
|
+
this.hubPromptId = undefined; this.hubPromptGeneration = undefined;
|
|
702
774
|
this.executionAbort?.abort(); this.executionAbort = undefined;
|
|
703
775
|
this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
|
|
704
776
|
this.abortedToolIds.clear(); this.abortIdsOverflow = false;
|
|
@@ -717,7 +789,9 @@ export class PiPeer extends BasePeer {
|
|
|
717
789
|
}
|
|
718
790
|
private sendTui(command: Record<string, unknown>): Promise<any> {
|
|
719
791
|
const id = `ahub-${++this.seq}`;
|
|
720
|
-
const
|
|
792
|
+
const fenced = this.fencedPrompt && command.type === "prompt";
|
|
793
|
+
if (fenced) { this.pendingPromptId = id; this.hubPromptId = id; this.hubPromptGeneration = undefined; }
|
|
794
|
+
const wire = { id, ...command, ...(fenced ? { sessionId: this.sessionId, generation: this.budgetGeneration } : {}) };
|
|
721
795
|
return new Promise((resolvePromise, reject) => {
|
|
722
796
|
const timer = setTimeout(() => {
|
|
723
797
|
this.tuiCommands.delete(id);
|
package/src/hub/daemon.ts
CHANGED
|
@@ -419,6 +419,8 @@ interface Client {
|
|
|
419
419
|
peer?: PeerId;
|
|
420
420
|
tail?: () => void;
|
|
421
421
|
watch?: () => void;
|
|
422
|
+
/** The one-launch Kimi TUI admission this socket attached under; its close clears only that admission. */
|
|
423
|
+
kimiAdmission?: string;
|
|
422
424
|
}
|
|
423
425
|
type Sock = ServerWebSocket<Client>;
|
|
424
426
|
|
|
@@ -655,7 +657,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
655
657
|
const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
|
|
656
658
|
if (peer instanceof WsPeer) {
|
|
657
659
|
peer.detach(sock);
|
|
658
|
-
if (sock.data.peer === "kimi" && peer.state === "offline" && !peer.claiming) clearKimiAdmission();
|
|
660
|
+
if (sock.data.peer === "kimi" && peer.state === "offline" && !peer.claiming && sock.data.kimiAdmission === kimiAdmissionId) clearKimiAdmission();
|
|
659
661
|
}
|
|
660
662
|
},
|
|
661
663
|
},
|
|
@@ -3869,7 +3871,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3869
3871
|
preparedSessionRoles.delete(c.peer); // an external transport has no receipt for this daemon's native preamble
|
|
3870
3872
|
preparedSessionRoleIdentities.delete(c.peer);
|
|
3871
3873
|
ws.claim(sock, msg.channel === false);
|
|
3872
|
-
if (kimiPull) { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; }
|
|
3874
|
+
if (kimiPull) { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; c.kimiAdmission = kimiAdmissionId; }
|
|
3873
3875
|
if (c.peer === "claude") logPermissionStart(c.peer);
|
|
3874
3876
|
writeStatus(); // the claim has to be visible before the preface, or a standing-by session takes the id back
|
|
3875
3877
|
void ensurePreface(c.peer).finally(() => {
|
|
@@ -4154,7 +4156,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4154
4156
|
if (c.role !== "console") return void reply({ ok: false, error: "watch is a person-only console command" });
|
|
4155
4157
|
if (msg.peer !== "kimi" && msg.peer !== "qwen") return void reply({ ok: false, error: "watch supports headless kimi and qwen only" });
|
|
4156
4158
|
const peer = msg.peer as "kimi" | "qwen";
|
|
4157
|
-
|
|
4159
|
+
// Judged by the live owner: a headless Kimi started by --mode headless is watchable under a TUI start mode.
|
|
4160
|
+
const kimiOwner = bus.peers.get("kimi");
|
|
4161
|
+
if (peer === "kimi" && !(kimiOwner instanceof AcpPeer) && (kimiOwner instanceof WsPeer || (kimiSessionMode ?? startMode("kimi")) === "tui")) return void reply({ ok: false, error: "Kimi is in pull-only TUI mode; use its native terminal or start headless" });
|
|
4158
4162
|
if (!membershipAllowed(peer)) return void reply({ ok: false, error: `${peer} is off roster` });
|
|
4159
4163
|
c.watch?.();
|
|
4160
4164
|
c.watch = activityRings[peer].subscribe(item => {
|
package/src/hub/path-policy.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { lstatSync, opendirSync, statSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { realPath } from "./project.ts";
|
|
4
|
+
import { foldSegment } from "./permission-mode.ts";
|
|
4
5
|
/** Bounded project-relative exact paths/subtrees or *, ? and whole-segment ** globs. */
|
|
5
6
|
export function policyPaths(raw: unknown, field: string): string[] {
|
|
6
7
|
if (raw === undefined) return [];
|
|
@@ -15,16 +16,27 @@ export function policyRealPath(path: string): string {
|
|
|
15
16
|
while (!existing(ancestor)) { const parent = dirname(ancestor); if (parent === ancestor) throw new Error("path cannot be resolved"); ancestor = parent; }
|
|
16
17
|
return join(realPath(ancestor), relative(ancestor, path));
|
|
17
18
|
}
|
|
18
|
-
const
|
|
19
|
+
const within = (path: string, root: string) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
20
|
+
// Either spelling protects, as for globs: lowercase as before, or folded like a case-insensitive disk opens names (NFD/NFC,
|
|
21
|
+
// compatibility forms, long s, ignorable code points). Folding is not a function of the lowercase form for every name.
|
|
22
|
+
const beneath = (path: string, root: string) => within(path.toLowerCase(), root.toLowerCase()) || within(foldSegment(path), foldSegment(root));
|
|
23
|
+
const lowerBeneath = (path: string, root: string) => within(path.toLowerCase(), root.toLowerCase());
|
|
19
24
|
export function policyTargets(cwd: string, paths: string[]): string[] {
|
|
20
25
|
const root = realPath(cwd);
|
|
21
26
|
return [...new Set(paths.flatMap(path => { const lexical = resolve(root, path); return [lexical, policyRealPath(lexical)]; }))];
|
|
22
27
|
}
|
|
23
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Either spelling protects: the lowercase comparison keeps `?` matching one code point whose fold expands (ß, fi), and
|
|
30
|
+
* the folded one catches NFD/NFC, compatibility and long-s aliases. Adding a match only ever adds protection.
|
|
31
|
+
*/
|
|
24
32
|
function segmentMatches(pattern: string, name: string): boolean {
|
|
25
|
-
|
|
33
|
+
return wildcard(pattern.toLowerCase(), name.toLowerCase()) || wildcard(foldSegment(pattern), foldSegment(name));
|
|
34
|
+
}
|
|
35
|
+
/** Linear-space wildcard DP, avoiding regex backtracking on untrusted paths. */
|
|
36
|
+
function wildcard(pattern: string, name: string): boolean {
|
|
37
|
+
const chars = [...name];
|
|
26
38
|
let previous = new Array<boolean>(chars.length + 1).fill(false); previous[0] = true;
|
|
27
|
-
for (const char of pattern
|
|
39
|
+
for (const char of pattern) {
|
|
28
40
|
const next = new Array<boolean>(chars.length + 1).fill(false);
|
|
29
41
|
next[0] = char === "*" && previous[0]!;
|
|
30
42
|
for (let i = 1; i <= chars.length; i++) next[i] = char === "*" ? previous[i]! || next[i - 1]! : previous[i - 1]! && (char === "?" || char === chars[i - 1]);
|
|
@@ -63,10 +75,17 @@ function globScope(root: string, lexical: string, actual: string, path: string,
|
|
|
63
75
|
if (patternScope(pattern, start, relative(root, lexical), includeParents) || patternScope(pattern, start, relative(root, actual), includeParents)) return true;
|
|
64
76
|
const visit = (dir: string, states: Set<number>, depth: number, ancestors: Set<string>): boolean => {
|
|
65
77
|
const real = policyRealPath(dir);
|
|
66
|
-
if (states.has(pattern.length))
|
|
78
|
+
if (states.has(pattern.length)) {
|
|
79
|
+
if (beneath(lexical, real) || beneath(actual, real) || includeParents && (beneath(real, lexical) || beneath(real, actual))) return true;
|
|
80
|
+
// An earlier `**` can still match deeper: an alias below this match (`ss/ß -> other` for `**/ß`) is searched too.
|
|
81
|
+
// A trailing `**` only re-matches everything below, so it never walks the subtree (as before, for paths outside it).
|
|
82
|
+
states = new Set([...states].filter(i => i !== pattern.length && !(i === pattern.length - 1 && pattern[i] === "**")));
|
|
83
|
+
if (!states.size) return false;
|
|
84
|
+
}
|
|
67
85
|
if (patternScope(pattern, states, relative(real, actual), includeParents)) return true;
|
|
68
86
|
if (!existing(dir)) return false;
|
|
69
|
-
|
|
87
|
+
// Unfolded: a sibling whose folded spelling looks like the project is outside it, and discovery there fails closed.
|
|
88
|
+
if (!lowerBeneath(real, root)) throw new Error("policy glob discovery outside project");
|
|
70
89
|
const info = statSync(dir);
|
|
71
90
|
if (!info.isDirectory()) return false;
|
|
72
91
|
if (depth > 32 || ancestors.has(real) || budget.entries > 4096) throw new Error("policy glob discovery unavailable");
|
package/src/hub/tasks.ts
CHANGED
|
@@ -564,6 +564,10 @@ export class Tasks {
|
|
|
564
564
|
private plannerPullRecord(task: Task): HistoryEntry["planning"] {
|
|
565
565
|
return task.history.findLast(entry => entry.event === "backlog pulled" || entry.event === "backlog returned")?.planning;
|
|
566
566
|
}
|
|
567
|
+
private hasPersonReservation(task: Task): boolean {
|
|
568
|
+
const reservation = task.history.findLast(entry => entry.event === "reserved" || entry.planning?.fields.includes("reserved") && !["backlog pulled", "backlog returned"].includes(entry.event)) ?? (task.history[0]?.event === "backlogged" ? task.history[0] : undefined);
|
|
569
|
+
return !!task.reserved && reservation?.by === USER;
|
|
570
|
+
}
|
|
567
571
|
wip(peer: PeerId, except?: number): number {
|
|
568
572
|
return this.d.board.plannerWip(peer, except);
|
|
569
573
|
}
|
|
@@ -604,8 +608,7 @@ export class Tasks {
|
|
|
604
608
|
}
|
|
605
609
|
const assignment = this.plannerAssignment(task);
|
|
606
610
|
if (!assignment.owner) throw new PreEffectToolRefusal("no peer has eligible planner WIP capacity");
|
|
607
|
-
|
|
608
|
-
if (task.reserved && reservation?.by === USER && assignment.owner !== task.reserved) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
611
|
+
if (this.hasPersonReservation(task) && assignment.owner !== task.reserved) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
609
612
|
return { task, owner: assignment.owner };
|
|
610
613
|
};
|
|
611
614
|
const initial = validate(), withheld = await this.withholds("note", input.reason, initial.task);
|
|
@@ -723,6 +726,7 @@ export class Tasks {
|
|
|
723
726
|
if (task.state === "approved") throw new PreEffectToolRefusal("approved work is final");
|
|
724
727
|
if (by !== USER && (task.state !== "backlog" || taskHasStarted(task) || task.pinned || Object.hasOwn(patch, "pinned"))) throw new PreEffectToolRefusal("planner changes only unpinned backlog work");
|
|
725
728
|
if (by !== USER && patch.rank !== undefined && task.history.some(entry => entry.by === USER && entry.planning?.fields.includes("rank"))) throw new PreEffectToolRefusal("a person's backlog order is retained");
|
|
729
|
+
if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
726
730
|
if (Object.hasOwn(patch, "deps") || Object.hasOwn(patch, "reserved")) {
|
|
727
731
|
if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
|
|
728
732
|
if (patch.reserved && !(SUPPORTED_PEERS as readonly string[]).includes(patch.reserved) && !this.d.bus.knownPeers().includes(patch.reserved) && !this.d.routing().classes[task.class]?.peers.includes(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer");
|
|
@@ -1611,7 +1615,11 @@ export class Tasks {
|
|
|
1611
1615
|
if (next.phase && !this.isPii(task) && !this.nameable(JSON.stringify(next.phase))) throw new PreEffectToolRefusal("phase metadata matches a PII pattern; use private work");
|
|
1612
1616
|
if (next.phase) validatePhaseLinks(task, next.phase, id => this.d.board.get(id), t => this.isPii(t));
|
|
1613
1617
|
}
|
|
1614
|
-
|
|
1618
|
+
// Recording a head is the reviewer's; a head dropped because the repo or PR changed is not a recording, and a
|
|
1619
|
+
// head supplied with a new PR identity is one even when its value is unchanged (#382).
|
|
1620
|
+
const suppliedHead = !!input && typeof input === "object" && (input as { reviewedHead?: unknown }).reviewedHead !== undefined;
|
|
1621
|
+
const artifactChanged = next.repo !== task.sdlc?.repo || next.pr !== task.sdlc?.pr;
|
|
1622
|
+
if (next.reviewedHead !== undefined && (next.reviewedHead !== task.sdlc?.reviewedHead || (suppliedHead && artifactChanged))) this.mine(task, by, "reviewer", true);
|
|
1615
1623
|
return this.d.board.update(task.id, by, "lifecycle", { sdlc: next });
|
|
1616
1624
|
}
|
|
1617
1625
|
|
|
@@ -1816,7 +1824,7 @@ export class Tasks {
|
|
|
1816
1824
|
}
|
|
1817
1825
|
if (task.state === "proposed" || task.state === "changes_requested") task = this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE); // done without a separate accept
|
|
1818
1826
|
const command = this.d.runCheck ? this.d.check?.(task.class, task) : undefined;
|
|
1819
|
-
if (!command) return this.complete(task, by, summary, refs, "", withheld);
|
|
1827
|
+
if (!command) return this.complete(task, by, summary, refs, "", withheld, "review");
|
|
1820
1828
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
1821
1829
|
this.checking.set(task.id, task.owner);
|
|
1822
1830
|
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
|
|
@@ -1942,12 +1950,15 @@ export class Tasks {
|
|
|
1942
1950
|
this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
|
|
1943
1951
|
}
|
|
1944
1952
|
|
|
1945
|
-
|
|
1953
|
+
/** `gated`: the transition `done()` already gated with no await since, so it is not read back twice (#382). */
|
|
1954
|
+
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false, gated?: "review"): Promise<Task> {
|
|
1946
1955
|
let reviewer = task.reviewer;
|
|
1947
|
-
if (this.d.sdlc && this.d.sdlc.config().mode !== "off") {
|
|
1956
|
+
if (this.d.sdlc && this.d.sdlc.config().mode !== "off" && gated !== (reviewer ? "review" : "approval")) {
|
|
1948
1957
|
await this.lifecycleGate(task, reviewer ? "review" : "approval");
|
|
1949
1958
|
task = this.need(task.id, false, true);
|
|
1950
1959
|
this.mine(task, by, "owner", true);
|
|
1960
|
+
// In every mode: another done may have landed during the readback.
|
|
1961
|
+
if (task.state === "in_review" || task.state === "approved") throw new Error(`task #${task.id} is already ${task.state}`);
|
|
1951
1962
|
reviewer = task.reviewer;
|
|
1952
1963
|
}
|
|
1953
1964
|
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", ...(task.sdlc ? { sdlc: { ...task.sdlc, stage: lifecycleStage(this.d.sdlc?.config() ?? sdlcConfig(undefined), reviewer ? "review" : "merge") } } : {}), refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary, { withheld: withheld || undefined });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { closeSync, constants, fstatSync, mkdirSync, mkdtempSync, openSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { extname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { homedir, tmpdir } from "node:os";
|
|
6
6
|
import { sbplString } from "../local/deny.ts";
|
|
@@ -8,6 +8,7 @@ import { spawnSync } from "node:child_process";
|
|
|
8
8
|
import { realPath } from "./project.ts";
|
|
9
9
|
import { profile, sandboxAvailable } from "../local/sandbox.ts";
|
|
10
10
|
export type ProjectionConfig = Record<string, string[]>;
|
|
11
|
+
const EMPTY_ROOT = "/var/empty";
|
|
11
12
|
export const MAX_PROJECTION_INPUT = 16 * 1024 * 1024;
|
|
12
13
|
const MAX_INPUT = MAX_PROJECTION_INPUT, MAX_TEXT = 256 * 1024;
|
|
13
14
|
/** Only machine-local config chooses programs; no implicit shell or command-string parsing. */
|
|
@@ -23,14 +24,18 @@ export function projectionConfig(raw: unknown): ProjectionConfig {
|
|
|
23
24
|
}
|
|
24
25
|
export interface ProjectedText { inputHash: string; rawHash: string; rawBlob: string; configHash: string; text: string }
|
|
25
26
|
const hash = (data: string | Buffer) => createHash("sha256").update(data).digest("hex");
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
/** The program and script files an argv runs, resolved as the converter will see them. */
|
|
28
|
+
function programFiles(cwd: string, argv: string[]): { index: number; file: string; size: number; mtimeMs: number; ctimeMs: number }[] {
|
|
29
|
+
const out: { index: number; file: string; size: number; mtimeMs: number; ctimeMs: number }[] = [];
|
|
28
30
|
for (const [index, arg] of argv.entries()) {
|
|
29
|
-
if (index !== 0 && !/\.(?:sh|py|js|ts|rb)$/.test(arg)) continue;
|
|
31
|
+
if (index !== 0 && !/\.(?:sh|py|js|mjs|cjs|ts|rb|pl)$/.test(arg)) continue;
|
|
30
32
|
const choices = isAbsolute(arg) ? [arg] : index === 0 && !arg.includes("/") ? (process.env.PATH ?? "/usr/bin:/bin").split(":").slice(0, 32).map(dir => join(dir, arg)) : [resolve(cwd, arg)];
|
|
31
|
-
for (const file of choices) try { const st = statSync(file); if (!st.isFile()) continue;
|
|
33
|
+
for (const file of choices) try { const st = statSync(file); if (!st.isFile()) continue; out.push({ index, file, size: st.size, mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs }); break; } catch { /* another PATH entry */ }
|
|
32
34
|
}
|
|
33
|
-
return
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function commandFingerprint(cwd: string, argv: string[]): string {
|
|
38
|
+
return hash([JSON.stringify(argv), ...programFiles(cwd, argv).flatMap(p => [p.file, String(p.size), String(p.mtimeMs), String(p.ctimeMs)])].join("\\n"));
|
|
34
39
|
}
|
|
35
40
|
function boundedCache(file: string): string {
|
|
36
41
|
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
@@ -47,8 +52,12 @@ function boundedCache(file: string): string {
|
|
|
47
52
|
export class TextProjections {
|
|
48
53
|
private readonly sandbox: string;
|
|
49
54
|
private readonly warned = new Set<string>();
|
|
55
|
+
/** Inputs whose full-budget conversion failed: the same bytes under the same command are not converted again. */
|
|
56
|
+
private readonly failed = new Set<string>();
|
|
50
57
|
constructor(private readonly options: { cwd: string; stateDir: string; config: ProjectionConfig; deny?: string[]; readAllow?: string[]; log?: (line: string) => void }) {
|
|
51
|
-
|
|
58
|
+
// Rooted at an empty system directory, not the project: the converter reads its snapshot and program files only.
|
|
59
|
+
// Only where conversion can run: /var/empty is a macOS directory, and a host without the sandbox never converts.
|
|
60
|
+
this.sandbox = Object.keys(options.config).length && sandboxAvailable() ? profile(EMPTY_ROOT, false, options.readAllow, options.deny, ["."]) : "";
|
|
52
61
|
}
|
|
53
62
|
private warn(ext: string): void {
|
|
54
63
|
if (this.warned.has(ext)) return;
|
|
@@ -103,17 +112,33 @@ export class TextProjections {
|
|
|
103
112
|
if (value.inputHash === inputHash && value.rawHash === rawHash && value.rawBlob === rawBlob && value.configHash === configHash && typeof value.text === "string" && Buffer.byteLength(value.text) <= MAX_TEXT) return value;
|
|
104
113
|
}
|
|
105
114
|
} catch { /* missing/invalid cache is not evidence */ }
|
|
115
|
+
if (this.failed.has(key)) throw new Error("conversion failed before");
|
|
106
116
|
if (!sandboxAvailable()) throw new Error("sandbox unavailable");
|
|
107
117
|
scratch = realPath(mkdtempSync(join(tmpdir(), "ahub-projection-input-"))); const snapshot = join(scratch, "input" + ext);
|
|
108
118
|
writeFileSync(snapshot, bytes, { mode: 0o400 });
|
|
119
|
+
// A read_allow entry may enclose the project. Seatbelt lets a later rule win only for the operation it names, so the
|
|
120
|
+
// deny and the literal grants after it both name file-read-data. The converter runs in its scratch directory (getcwd
|
|
121
|
+
// needs that directory's data), with its program and script arguments resolved to absolute paths.
|
|
122
|
+
const root = realPath(this.options.cwd), projectDeny = "\n(deny file-read-data (subpath " + sbplString(root) + "))";
|
|
123
|
+
const resolved = programFiles(this.options.cwd, argv);
|
|
124
|
+
// A module loader lists the directories above its script: grant those listings (not their files) up to the root.
|
|
125
|
+
const listings = new Set<string>();
|
|
126
|
+
for (const program of resolved) for (let dir = dirname(realPath(program.file)); dir.startsWith(root + "/") || dir === root; dir = dirname(dir)) { listings.add(dir); if (dir === root) break; }
|
|
127
|
+
const programs = resolved.map(p => "\n(allow file-read* file-read-data process-exec (literal " + sbplString(realPath(p.file)) + "))").join("")
|
|
128
|
+
+ [...listings].map(dir => "\n(allow file-read-data (literal " + sbplString(dir) + "))").join("")
|
|
129
|
+
+ "\n(allow file-read* file-read-data (literal " + sbplString(scratch) + "))";
|
|
130
|
+
const absolute = new Map(resolved.map(p => [p.index, p.file]));
|
|
109
131
|
const result = spawnSync(process.execPath, [join(import.meta.dir, "projection-exec.ts")], {
|
|
110
|
-
cwd:
|
|
111
|
-
input: JSON.stringify({ cwd:
|
|
132
|
+
cwd: scratch, timeout: deadline === undefined ? 8000 : Math.ceil(Math.max(1, Math.min(8000, deadline - performance.now()))), maxBuffer: MAX_TEXT * 2, encoding: "utf8",
|
|
133
|
+
input: JSON.stringify({ cwd: scratch, sandbox: this.sandbox + projectDeny + "\n(allow file-read* file-read-data (literal " + sbplString(snapshot) + "))" + programs, argv: argv.map((arg, index) => arg === "{input}" ? snapshot : absolute.get(index) ?? arg), ...(deadline === undefined ? {} : { timeoutMs: Math.max(1, Math.min(5000, deadline - performance.now() - 100)) }) }),
|
|
112
134
|
env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: "en_US.UTF-8", TERM: "dumb" },
|
|
113
135
|
});
|
|
114
|
-
|
|
136
|
+
// ponytail: in-memory for this process, cleared at 1024 keys; persist it if restarts make retries costly.
|
|
137
|
+
const failure = () => { if (deadline === undefined) { if (this.failed.size >= 1024) this.failed.clear(); this.failed.add(key); } return new Error("conversion failed"); };
|
|
138
|
+
if (result.error) throw new Error("conversion helper failed"); // a spawn or helper failure may be transient: not remembered
|
|
139
|
+
if (result.status !== 0) throw failure();
|
|
115
140
|
const converted = JSON.parse(result.stdout) as { ok?: boolean; text?: string };
|
|
116
|
-
if (converted.ok !== true || typeof converted.text !== "string" || Buffer.byteLength(converted.text) > MAX_TEXT || converted.text.includes("\0")) throw
|
|
141
|
+
if (converted.ok !== true || typeof converted.text !== "string" || Buffer.byteLength(converted.text) > MAX_TEXT || converted.text.includes("\0")) throw failure();
|
|
117
142
|
verify?.();
|
|
118
143
|
const value = { inputHash, rawHash, rawBlob, configHash, text: converted.text };
|
|
119
144
|
mkdirSync(cacheDir, { recursive: true });
|
package/src/hub/tooling.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import { accessSync, constants, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { accessSync, closeSync, constants, fstatSync, openSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { delimiter, join, relative, resolve, isAbsolute } from "node:path";
|
|
5
5
|
import { realPath } from "./project.ts";
|
|
@@ -32,8 +32,18 @@ const PROBE_MS = 2000, PROBE_BYTES = 4096;
|
|
|
32
32
|
/** `env` supplies CLAUDE_CONFIG_DIR and CODEX_HOME, the runtimes' own config roots (the hub's environment by default). */
|
|
33
33
|
export interface ToolingSources { home?: string; cwd?: string; path?: string; env?: Record<string, string | undefined> }
|
|
34
34
|
|
|
35
|
+
/** A regular file of at most 1 MiB, opened without blocking: a FIFO or device under a project's `.claude/` reads as absent. */
|
|
36
|
+
const text = (file: string): string | undefined => {
|
|
37
|
+
let fd: number | undefined;
|
|
38
|
+
try {
|
|
39
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
40
|
+
const st = fstatSync(fd);
|
|
41
|
+
if (!st.isFile() || st.size > 1024 * 1024) return undefined;
|
|
42
|
+
return readFileSync(fd, "utf8");
|
|
43
|
+
} catch { return undefined; } finally { if (fd !== undefined) closeSync(fd); }
|
|
44
|
+
};
|
|
35
45
|
const json = (file: string): Record<string, unknown> | undefined => {
|
|
36
|
-
try { const v = JSON.parse(
|
|
46
|
+
try { const v = JSON.parse(text(file) ?? ""); return v && typeof v === "object" ? v as Record<string, unknown> : undefined; } catch { return undefined; }
|
|
37
47
|
};
|
|
38
48
|
/** A version string as data, never anything longer or stranger. */
|
|
39
49
|
const version = (v: unknown): string | null => typeof v === "string" && /^[0-9A-Za-z.+_-]{1,40}$/.test(v) ? v : null;
|
|
@@ -58,12 +68,13 @@ function claudePlugins(root: string, cwd: string | undefined): Addon[] {
|
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
function codexPlugins(root: string): Addon[] {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const found = new Map<string, string>(); // plugin name -> marketplace
|
|
64
|
-
// `[plugins."name@market"]` with no `enabled = false` below it, and plugins whose hooks Codex was told to trust
|
|
65
|
-
|
|
66
|
-
for (const m of
|
|
71
|
+
const config = text(join(root, "config.toml"));
|
|
72
|
+
if (config === undefined) return [];
|
|
73
|
+
const found = new Map<string, string>(), disabled = new Set<string>(); // plugin name -> marketplace
|
|
74
|
+
// `[plugins."name@market"]` with no `enabled = false` below it, and plugins whose hooks Codex was told to trust
|
|
75
|
+
// unless their plugin entry disables them: a trust entry outlives the plugin's own switch.
|
|
76
|
+
for (const m of config.matchAll(/^\[plugins\."([^"@]+)@([^"]+)"\]\s*\n((?:(?!\[)[^\n]*\n?)*)/gm)) if (!/^\s*enabled\s*=\s*false\b/m.test(m[3] ?? "")) found.set(m[1]!, m[2]!); else disabled.add(m[1]! + "@" + m[2]!);
|
|
77
|
+
for (const m of config.matchAll(/^\[hooks\.state\."([^"@]+)@([^":]+):/gm)) if (!disabled.has(m[1]! + "@" + m[2]!)) found.set(m[1]!, m[2]!);
|
|
67
78
|
const out: Addon[] = [];
|
|
68
79
|
for (const [plugin, market] of found) {
|
|
69
80
|
const name = PLUGINS.get(plugin);
|
package/src/memory/store.ts
CHANGED
|
@@ -533,13 +533,18 @@ export class MemoryStore {
|
|
|
533
533
|
return gone.size;
|
|
534
534
|
}
|
|
535
535
|
|
|
536
|
+
/** Index deferred writes before any note, goal or suggestion read. */
|
|
537
|
+
private ensureFresh(): void {
|
|
538
|
+
// Another process (a CLI, another project's hub, a sync) may have written since this one last read the segments.
|
|
539
|
+
if (this.stale || this.shape() !== this.seen) try { this.stale = false; this.refresh(); } catch { this.stale = true; /* still busy: the next read tries again */ }
|
|
540
|
+
}
|
|
541
|
+
|
|
536
542
|
/**
|
|
537
543
|
* What a read sees: rows that expired and promotions that became due while the store sat idle are settled first, so a
|
|
538
544
|
* read, and the index after it, never differ from a rebuild.
|
|
539
545
|
*/
|
|
540
546
|
private live(): View[] {
|
|
541
|
-
|
|
542
|
-
if (this.stale || this.shape() !== this.seen) try { this.stale = false; this.refresh(); } catch { this.stale = true; /* still busy: the next read tries again */ }
|
|
547
|
+
this.ensureFresh();
|
|
543
548
|
this.prune(); this.promoteHeld(); return this.views();
|
|
544
549
|
}
|
|
545
550
|
/** A write whose index update failed: the next read refreshes first. */
|
|
@@ -677,6 +682,7 @@ export class MemoryStore {
|
|
|
677
682
|
|
|
678
683
|
/** Every long-term task with its derived state, links and lease, newest first; `project` keeps its own and shared ones. */
|
|
679
684
|
goals(project?: string): GoalView[] {
|
|
685
|
+
this.ensureFresh();
|
|
680
686
|
const now = this.now();
|
|
681
687
|
const rows = this.db.query("SELECT * FROM goals").all() as { id: string; at: string; machine: string; project: string; scope: string; title: string; milestones: string; depends: string }[];
|
|
682
688
|
const ops = this.db.query("SELECT op, at, machine, target, by FROM ops WHERE op IN ('goal_state', 'goal_link', 'lease', 'lease_force') ORDER BY at, id").all() as { op: string; at: string; machine: string; target: string; by: string | null }[];
|
|
@@ -726,6 +732,7 @@ export class MemoryStore {
|
|
|
726
732
|
return s;
|
|
727
733
|
}
|
|
728
734
|
suggestions(project?: string): Suggestion[] {
|
|
735
|
+
this.ensureFresh();
|
|
729
736
|
const visible = project ? new Set(this.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }).map((v) => v.id)) : undefined;
|
|
730
737
|
const ops = this.db.query("SELECT id, op, at, target, by FROM ops WHERE op IN ('suggest', 'confirm', 'reject') ORDER BY at, id").all() as { id: string; op: string; at: string; target: string; by: string | null }[];
|
|
731
738
|
const verdict = new Map<string, "confirmed" | "rejected">();
|