pi-pier 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -11
- package/package.json +2 -2
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +4 -5
- package/src/ansi-text.ts +62 -11
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +113 -112
- package/src/bootstrap.ts +8 -12
- package/src/compact-coordinator.ts +278 -261
- package/src/compact-economics-core.ts +77 -145
- package/src/config-catalog-core.ts +77 -197
- package/src/config-command.ts +271 -64
- package/src/dashboard-command.ts +38 -19
- package/src/efficiency-config-core.ts +287 -484
- package/src/efficiency-store.ts +16 -29
- package/src/focus-poller.ts +14 -35
- package/src/gc-core.ts +10 -36
- package/src/herdr-client.ts +82 -208
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +24 -18
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +17 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +4 -8
- package/src/index.ts +305 -564
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +137 -87
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +28 -56
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +52 -100
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +54 -50
- package/src/pipe-channel.ts +27 -59
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +8 -10
- package/src/{core → plugins}/heat-plan.ts +20 -22
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +167 -127
- package/src/{core → plugins}/todo.ts +61 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +24 -60
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +76 -87
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +23 -28
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -40
- package/src/session-tail.ts +65 -41
- package/src/settle-wake-core.ts +52 -21
- package/src/slim-frame.ts +25 -122
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +14 -43
- package/src/subagent-core.ts +253 -118
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +367 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +530 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +125 -106
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +26 -51
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +15 -35
- package/src/tool-error.ts +6 -13
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +52 -25
- package/src/ask-multi.ts +0 -260
- package/src/config-guide.ts +0 -361
- package/src/core/observation.ts +0 -555
- package/src/core/subagent.ts +0 -633
- package/src/efficiency-config-io.ts +0 -53
- package/src/git-adapter.ts +0 -113
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-execute.ts +0 -328
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -114
- package/src/subagent-list-action.ts +0 -69
- package/src/subagent-output-action.ts +0 -104
- package/src/subagent-output-adapter.ts +0 -27
- package/src/subagent-output-core.ts +0 -263
- package/src/subagent-poll-loop.ts +0 -305
- package/src/subagent-port.ts +0 -27
- package/src/subagent-ready.ts +0 -89
- package/src/subagent-registry.ts +0 -70
- package/src/subagent-resolution.ts +0 -41
- package/src/subagent-scope.ts +0 -41
- package/src/subagent-session-io.ts +0 -230
package/src/todos-service.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Single owner of todo state; every mutation path (tool, human edit, M17 reconcile, archive) lands here (D65, D75). */
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
|
-
import {
|
|
4
|
-
applyTodoEdits,
|
|
5
|
-
foldLatestTodosMeta,
|
|
6
|
-
listsEqual,
|
|
7
|
-
type TodoEdit,
|
|
8
|
-
type TodoItem,
|
|
9
|
-
} from './todo-core.ts';
|
|
10
|
-
import { countCompletedTransitions } from './progress-core.ts';
|
|
3
|
+
import { applyTodoEdits, completionTransitions, foldLatestTodosMeta, listsEqual, type TodoEdit, type TodoItem } from './todo-core.ts';
|
|
11
4
|
import type { RuntimeRoleManifest } from './tool-gate.ts';
|
|
12
5
|
|
|
13
6
|
export interface TodosConfig {
|
|
@@ -17,21 +10,13 @@ export interface TodosConfig {
|
|
|
17
10
|
|
|
18
11
|
export type TodoCompletionSource = 'tool' | 'reconcile' | 'human' | 'archive';
|
|
19
12
|
|
|
20
|
-
export interface TodoCompletedEvent {
|
|
21
|
-
count: number;
|
|
22
|
-
at: number;
|
|
23
|
-
source: TodoCompletionSource;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
13
|
export class TodosService extends EventEmitter {
|
|
27
14
|
private _items: TodoItem[] = [];
|
|
28
|
-
/**
|
|
29
|
-
* Live list (read-only). Mutations must go through replace/applyEdits/rebuild.
|
|
30
|
-
*/
|
|
15
|
+
/** Live list (read-only). Mutations must go through replace/applyEdits/rebuild. */
|
|
31
16
|
get items(): readonly TodoItem[] {
|
|
32
17
|
return this._items;
|
|
33
18
|
}
|
|
34
|
-
/**
|
|
19
|
+
/** Clock anchor for staleness/archiving; null until the first write. */
|
|
35
20
|
lastWriteAt: number | null = null;
|
|
36
21
|
readonly config: TodosConfig;
|
|
37
22
|
|
|
@@ -40,17 +25,14 @@ export class TodosService extends EventEmitter {
|
|
|
40
25
|
this.config = config;
|
|
41
26
|
}
|
|
42
27
|
|
|
43
|
-
/**
|
|
28
|
+
/** D75: subagents are strict (one in_progress); the role manifest may force parallel mode. */
|
|
44
29
|
static configFromRuntime(manifest: RuntimeRoleManifest | null, isSubagent: boolean): TodosConfig {
|
|
45
30
|
const strict = isSubagent;
|
|
46
31
|
const mode = manifest?.services?.todos?.mode;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
/** Defensive copy for callers that must not mutate service state. */
|
|
52
|
-
getSnapshot(): TodoItem[] {
|
|
53
|
-
return this._items.map((it) => ({ ...it }));
|
|
32
|
+
return {
|
|
33
|
+
strict,
|
|
34
|
+
allowParallelInProgress: mode === 'parallel' || mode === 'serial' ? mode === 'parallel' : !strict,
|
|
35
|
+
};
|
|
54
36
|
}
|
|
55
37
|
|
|
56
38
|
replace(items: readonly TodoItem[], opts?: { source?: TodoCompletionSource }): { changed: boolean } {
|
|
@@ -76,24 +58,22 @@ export class TodosService extends EventEmitter {
|
|
|
76
58
|
return { changed: true };
|
|
77
59
|
}
|
|
78
60
|
|
|
79
|
-
/**
|
|
61
|
+
/** M16: ETA and compaction boundaries feed off this single completion diff (same rule as the tool's D36 report). */
|
|
80
62
|
private emitCompletedTransitions(
|
|
81
63
|
before: readonly TodoItem[],
|
|
82
64
|
after: readonly TodoItem[],
|
|
83
65
|
source: TodoCompletionSource = 'tool',
|
|
84
66
|
): void {
|
|
85
|
-
const count =
|
|
67
|
+
const count = completionTransitions(before, after).length;
|
|
86
68
|
if (count > 0) this.emit('todo.completed', { count, at: Date.now(), source });
|
|
87
69
|
}
|
|
88
70
|
|
|
71
|
+
/** Branch replay: fold the JSONL to the authoritative list. A branch with no snapshot (/new, or /tree
|
|
72
|
+
* back before the first todo_write) has no list, so the previous session's items must not survive. */
|
|
89
73
|
rebuild(entries: readonly unknown[]): void {
|
|
90
|
-
// Why: Preserve the established compatibility and safety behavior.
|
|
91
|
-
// Why: Preserve the established compatibility and safety behavior.
|
|
92
74
|
const folded = foldLatestTodosMeta(entries as Parameters<typeof foldLatestTodosMeta>[0]);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
this.lastWriteAt = folded.writtenAt;
|
|
96
|
-
}
|
|
75
|
+
this._items = folded ? folded.items : [];
|
|
76
|
+
this.lastWriteAt = folded ? folded.writtenAt : null;
|
|
97
77
|
this.emit('todo.updated', { items: this._items, reason: 'rebuild' });
|
|
98
78
|
}
|
|
99
79
|
}
|
package/src/tool-error.ts
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* (
|
|
2
|
+
* pi only marks a tool result as failed when `execute()` throws; returning a value never sets the
|
|
3
|
+
* error flag (pi docs/extensions.md, "Signaling errors"). Every hard failure goes through
|
|
4
|
+
* `toolError()`, while deliberate non-failures (empty result set, "no match within Xms", a pane that
|
|
5
|
+
* has not produced output yet) stay normal results.
|
|
5
6
|
*
|
|
6
|
-
* The
|
|
7
|
-
*
|
|
8
|
-
* reported no output" self-heal was dead code for exactly that reason). Every hard failure now goes
|
|
9
|
-
* through `toolError()`; deliberate non-failures (empty result set, "no match within Xms", a pane
|
|
10
|
-
* that simply has not produced output yet) stay normal results.
|
|
11
|
-
*
|
|
12
|
-
* The "Error: " prefix the old text returns carried is dropped: the provider's tool-result error
|
|
13
|
-
* channel and pi's red rendering already mark it, and keeping it would double the prefix once pi
|
|
14
|
-
* formats a thrown error.
|
|
7
|
+
* The leading "Error: " text is stripped: the provider's tool-result error channel and pi's red
|
|
8
|
+
* rendering already mark it, and keeping it would double the prefix once pi formats a thrown error.
|
|
15
9
|
*/
|
|
16
10
|
export class ToolError extends Error {
|
|
17
11
|
constructor(message: string) {
|
|
@@ -20,7 +14,6 @@ export class ToolError extends Error {
|
|
|
20
14
|
}
|
|
21
15
|
}
|
|
22
16
|
|
|
23
|
-
/** Throw a tool failure (never returns). */
|
|
24
17
|
export function toolError(message: string): never {
|
|
25
18
|
throw new ToolError(message);
|
|
26
19
|
}
|
package/src/tool-gate.ts
CHANGED
|
@@ -1,31 +1,40 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Worker execution enforcement (C7, D76/D77/D82) — pure. RuntimeRoleManifest is THE runtime manifest
|
|
3
|
+
* shape; the file format lives in role-manifest.ts and the composed/persisted shapes derive from it
|
|
4
|
+
* (manifest-compose.ts, role-state.ts).
|
|
5
|
+
*/
|
|
2
6
|
import type { PermissionAction, UnknownToolStance } from './role-manifest.ts';
|
|
3
7
|
|
|
4
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
5
8
|
export interface RuntimeRoleManifest {
|
|
6
9
|
role: string;
|
|
7
10
|
version?: string;
|
|
8
11
|
tools: string[];
|
|
9
12
|
permissions: Record<string, PermissionAction>;
|
|
10
|
-
/**
|
|
13
|
+
/** Stance for tools outside `tools`; 'allow' keeps user-installed extensions visible (D82). */
|
|
11
14
|
unknownTools?: UnknownToolStance;
|
|
12
15
|
services?: {
|
|
13
16
|
todos?: {
|
|
14
17
|
mode?: 'serial' | 'parallel';
|
|
15
18
|
};
|
|
16
19
|
};
|
|
20
|
+
/** P0 per-role guidelines (RFC §4.6): injected as a `pier-role` prompt section each turn. */
|
|
21
|
+
guidelines?: string[];
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
/**
|
|
24
|
+
/** Parse the env-carried manifest; malformed input is null (fail-open: no gate) rather than a crash. */
|
|
20
25
|
export function parseRuntimeManifest(envValue: string | undefined): RuntimeRoleManifest | null {
|
|
21
26
|
if (!envValue) return null;
|
|
22
27
|
try {
|
|
23
28
|
const m = JSON.parse(envValue) as RuntimeRoleManifest;
|
|
24
29
|
if (!m || typeof m.role !== 'string' || !Array.isArray(m.tools)) return null;
|
|
25
|
-
// Why: Preserve the established compatibility and safety behavior (D82).
|
|
26
30
|
if (m.unknownTools !== undefined && m.unknownTools !== 'allow' && m.unknownTools !== 'deny') {
|
|
27
31
|
m.unknownTools = 'deny';
|
|
28
32
|
}
|
|
33
|
+
if (m.guidelines !== undefined) {
|
|
34
|
+
m.guidelines = Array.isArray(m.guidelines)
|
|
35
|
+
? m.guidelines.filter((g) => typeof g === 'string' && g.trim() !== '')
|
|
36
|
+
: undefined;
|
|
37
|
+
}
|
|
29
38
|
return m;
|
|
30
39
|
} catch {
|
|
31
40
|
return null;
|
|
@@ -43,7 +52,6 @@ export function planToolGate(toolName: string, manifest: RuntimeRoleManifest | n
|
|
|
43
52
|
const perm = manifest.permissions[toolName] ?? manifest.permissions['*'] ?? 'allow';
|
|
44
53
|
const known = manifest.tools.includes(toolName);
|
|
45
54
|
const stance = manifest.unknownTools ?? 'deny';
|
|
46
|
-
// Why: Preserve the established compatibility and safety behavior (D82).
|
|
47
55
|
if (perm === 'deny' || (!known && stance === 'deny')) {
|
|
48
56
|
return {
|
|
49
57
|
kind: 'deny',
|
|
@@ -59,27 +67,36 @@ export function planToolGate(toolName: string, manifest: RuntimeRoleManifest | n
|
|
|
59
67
|
return { kind: 'allow' };
|
|
60
68
|
}
|
|
61
69
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
/**
|
|
71
|
+
* 'allow' keeps every tool but the explicit deny rules (D82 user-installed-extension axis); 'deny'
|
|
72
|
+
* intersects with the manifest. Shared by planActiveTools (visibility) and role-state's switch
|
|
73
|
+
* planning (all-tools universe).
|
|
74
|
+
*/
|
|
75
|
+
export function filterToolsByStance(
|
|
76
|
+
manifestTools: readonly string[],
|
|
77
|
+
universe: readonly string[],
|
|
66
78
|
opts?: { unknownTools?: UnknownToolStance; permissions?: Record<string, PermissionAction> },
|
|
67
|
-
):
|
|
68
|
-
if (manifestTools.length === 0 || currentActive.length === 0) return null;
|
|
79
|
+
): string[] {
|
|
69
80
|
if ((opts?.unknownTools ?? 'deny') === 'allow') {
|
|
70
81
|
const denied = new Set(
|
|
71
82
|
Object.entries(opts?.permissions ?? {})
|
|
72
|
-
.filter(([,
|
|
73
|
-
.map(([
|
|
83
|
+
.filter(([, action]) => action === 'deny')
|
|
84
|
+
.map(([name]) => name),
|
|
74
85
|
);
|
|
75
|
-
|
|
76
|
-
if (next.length === 0) return null;
|
|
77
|
-
return { next, changed: next.length !== currentActive.length };
|
|
86
|
+
return universe.filter((name) => !denied.has(name));
|
|
78
87
|
}
|
|
79
88
|
const wanted = new Set(manifestTools);
|
|
80
|
-
|
|
81
|
-
if (next.length === 0) return null;
|
|
82
|
-
const changed = next.length !== currentActive.length;
|
|
83
|
-
return { next, changed };
|
|
89
|
+
return universe.filter((name) => wanted.has(name));
|
|
84
90
|
}
|
|
85
91
|
|
|
92
|
+
/** Null means "keep the current active set": an empty result must never clear every tool (D77). */
|
|
93
|
+
export function planActiveTools(
|
|
94
|
+
manifestTools: readonly string[],
|
|
95
|
+
currentActive: readonly string[],
|
|
96
|
+
opts?: { unknownTools?: UnknownToolStance; permissions?: Record<string, PermissionAction> },
|
|
97
|
+
): { next: string[]; changed: boolean } | null {
|
|
98
|
+
if (manifestTools.length === 0 || currentActive.length === 0) return null;
|
|
99
|
+
const next = filterToolsByStance(manifestTools, currentActive, opts);
|
|
100
|
+
if (next.length === 0) return null;
|
|
101
|
+
return { next, changed: next.length !== currentActive.length };
|
|
102
|
+
}
|
package/src/vocab.ts
CHANGED
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-herdr bridge vocabulary
|
|
3
|
-
*
|
|
4
|
-
* DESIGN.md §4.2: no new transport. Wire = herdr NDJSON; todo authority = pi
|
|
5
|
-
* session JSONL; display = pane.report_metadata title / state_labels.
|
|
2
|
+
* pi-herdr bridge vocabulary. DESIGN.md §4.2: no new transport — wire = herdr NDJSON, todo authority
|
|
3
|
+
* = pi session JSONL, display = pane.report_metadata title / state_labels.
|
|
6
4
|
*/
|
|
7
5
|
|
|
8
|
-
/**
|
|
6
|
+
/** Five states (D34): three-state + blocked/abandoned, aligned with OMP. */
|
|
9
7
|
export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'blocked' | 'abandoned';
|
|
10
8
|
|
|
11
9
|
export const TODO_STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'completed', 'blocked', 'abandoned'];
|
|
12
10
|
|
|
13
|
-
/** Todo item: content + status;
|
|
11
|
+
/** Todo item: content + status; blocker only when blocked; optional phase groups (D43). No id (last-write-wins). */
|
|
14
12
|
export interface TodoItem {
|
|
15
13
|
content: string;
|
|
16
14
|
status: TodoStatus;
|
|
17
|
-
/** Non-empty string only when status==='blocked' (D34). */
|
|
18
15
|
blocker?: string;
|
|
19
16
|
/** Optional group name (D43); non-empty, ≤30 chars. */
|
|
20
17
|
phase?: string;
|
|
@@ -27,58 +24,88 @@ export interface TodoSnapshot {
|
|
|
27
24
|
items: TodoItem[];
|
|
28
25
|
}
|
|
29
26
|
|
|
30
|
-
/** Snapshot counts (confirmation copy and title projection). */
|
|
31
27
|
export interface TodoCounts {
|
|
32
28
|
pending: number;
|
|
33
29
|
inProgress: number;
|
|
34
30
|
completed: number;
|
|
35
|
-
/** D91: blocked
|
|
31
|
+
/** D91: blocked is its own column (title quartet ▶○■✓). */
|
|
36
32
|
blocked: number;
|
|
37
33
|
}
|
|
38
34
|
|
|
39
35
|
/** Source name for pane.report-agent. */
|
|
40
36
|
export const REPORT_AGENT_SOURCE = 'pi-herdr';
|
|
41
37
|
|
|
42
|
-
/** First-upgrade key used to clear the old 16-key chunked tokens (M13b
|
|
38
|
+
/** First-upgrade key used to clear the old 16-key chunked tokens (M13b). */
|
|
43
39
|
export const PI_HERDR_META_KEY = 'pi-herdr';
|
|
44
40
|
|
|
45
|
-
/** Model-facing todo tool name (DSH-compatible). */
|
|
46
41
|
export const TODO_TOOL_NAME = 'todo_write';
|
|
47
42
|
|
|
48
43
|
/** Snapshot key in tool-result details (persisted in session JSONL). */
|
|
49
44
|
export const TODO_DETAILS_KEY = 'pi-herdr.todo';
|
|
50
45
|
|
|
51
|
-
/** Confirmation copy (byte-aligned with DSH so model vocab transfers
|
|
46
|
+
/** Confirmation copy (byte-aligned with DSH so model vocab transfers). */
|
|
52
47
|
export function formatTodoConfirmation(items: readonly TodoItem[]): string {
|
|
53
48
|
const c = countTodos(items);
|
|
54
49
|
const base = `Updated todo list: ${c.pending} pending, ${c.inProgress} in progress, ${c.completed} completed.`;
|
|
55
|
-
//
|
|
56
|
-
// blocked
|
|
50
|
+
// Append blocked only when non-zero — the model must not read "nothing pending" while items sit
|
|
51
|
+
// blocked on a human. Zero keeps the DSH-aligned wording intact.
|
|
57
52
|
return c.blocked > 0 ? `${base.slice(0, -1)}, ${c.blocked} blocked.` : base;
|
|
58
53
|
}
|
|
59
54
|
|
|
55
|
+
/** Every status the todo model knows; anything else in a snapshot is corrupt data (D34). */
|
|
56
|
+
const STATUS_LOOKUP: Readonly<Record<TodoStatus, true>> = {
|
|
57
|
+
pending: true,
|
|
58
|
+
in_progress: true,
|
|
59
|
+
completed: true,
|
|
60
|
+
blocked: true,
|
|
61
|
+
abandoned: true,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Corrupt snapshots carry arbitrary JSON as `status` (`extractSnapshotFromDetails` does not validate),
|
|
66
|
+
* so every table lookup keyed by status must pass through this first — `TODO_MARKS['constructor']`
|
|
67
|
+
* would otherwise render a function, and `groups['constructor'].push` would throw.
|
|
68
|
+
*/
|
|
69
|
+
export const isTodoStatus = (value: unknown): value is TodoStatus =>
|
|
70
|
+
typeof value === 'string' && Object.hasOwn(STATUS_LOOKUP, value);
|
|
71
|
+
|
|
72
|
+
const COUNTED_STATUSES = {
|
|
73
|
+
pending: 'pending',
|
|
74
|
+
in_progress: 'inProgress',
|
|
75
|
+
completed: 'completed',
|
|
76
|
+
blocked: 'blocked',
|
|
77
|
+
} as const satisfies Partial<Record<TodoStatus, keyof TodoCounts>>;
|
|
78
|
+
|
|
79
|
+
/** The four states that occupy a count column (`abandoned` is terminal and deliberately uncounted). */
|
|
80
|
+
const isCountedStatus = (status: TodoStatus): status is keyof typeof COUNTED_STATUSES =>
|
|
81
|
+
Object.hasOwn(COUNTED_STATUSES, status);
|
|
82
|
+
|
|
60
83
|
export function countTodos(items: readonly TodoItem[]): TodoCounts {
|
|
61
|
-
|
|
62
|
-
let inProgress = 0;
|
|
63
|
-
let completed = 0;
|
|
64
|
-
let blocked = 0;
|
|
84
|
+
const counts: TodoCounts = { pending: 0, inProgress: 0, completed: 0, blocked: 0 };
|
|
65
85
|
for (const it of items) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
else if (it.status === 'completed') completed++;
|
|
69
|
-
else if (it.status === 'blocked') blocked++;
|
|
70
|
-
// abandoned is omitted (D34); blocked is its own column (D91)
|
|
86
|
+
const status = it?.status;
|
|
87
|
+
if (isTodoStatus(status) && isCountedStatus(status)) counts[COUNTED_STATUSES[status]] += 1;
|
|
71
88
|
}
|
|
72
|
-
return
|
|
89
|
+
return counts;
|
|
73
90
|
}
|
|
74
91
|
|
|
92
|
+
/** Why a settlement carried no closing text (p24-class mis-attribution vs a truly silent worker). */
|
|
93
|
+
export type SettlementNullReason = 'silent' | 'attribution-suspect' | 'extraction-failed';
|
|
94
|
+
|
|
95
|
+
const NULL_CLOSING_SENTENCE: Record<SettlementNullReason, string> = {
|
|
96
|
+
'attribution-suspect': 'Its closing message could NOT be read — the session attribution is suspect (a wrong transcript may have been read). Check `subagent action output` / the ledger sessionFile before assuming it produced nothing; its work may be complete.',
|
|
97
|
+
'extraction-failed': 'It appears to have ended with a final report, but the closing text could not be extracted — read it from the session transcript.',
|
|
98
|
+
silent: 'It left no closing message.',
|
|
99
|
+
};
|
|
100
|
+
|
|
75
101
|
/** Subagent settlement notice (aligned with DSH). */
|
|
76
102
|
export function formatSettlementNotice(
|
|
77
103
|
agentId: string,
|
|
78
104
|
closingMessage: string | null,
|
|
105
|
+
nullReason: SettlementNullReason = 'silent',
|
|
79
106
|
): string {
|
|
80
107
|
const head = `Background subagent ${agentId} finished and will do no further work unless you send it more.`;
|
|
81
108
|
return closingMessage
|
|
82
109
|
? `${head} Its closing message: ${closingMessage}`
|
|
83
|
-
: `${head}
|
|
110
|
+
: `${head} ${NULL_CLOSING_SENTENCE[nullReason]}`;
|
|
84
111
|
}
|
package/src/ask-multi.ts
DELETED
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ask_user_question multi-select: pure state machine + a thin TUI component.
|
|
3
|
-
*
|
|
4
|
-
* Why: the previous multi path asked the human to *type* "1,3" into a text prompt,
|
|
5
|
-
* which is the weakest part of the human gate. This module renders a real toggle
|
|
6
|
-
* list (space to toggle, enter to confirm, esc to decline) with the authored
|
|
7
|
-
* choices, an optional trailing free-text row, and a live selection count.
|
|
8
|
-
*
|
|
9
|
-
* The transition function is pure so the UX is testable without a terminal; the
|
|
10
|
-
* component only maps keys to it. Installing the free-text row is opt-out
|
|
11
|
-
* (`allowOther: false`) for pure-choice questions.
|
|
12
|
-
*/
|
|
13
|
-
import { truncateStyled } from './ansi-text.ts';
|
|
14
|
-
|
|
15
|
-
export interface MultiSelectOption {
|
|
16
|
-
label: string;
|
|
17
|
-
description?: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface MultiSelectConfig {
|
|
21
|
-
options: readonly MultiSelectOption[];
|
|
22
|
-
/** Render the trailing free-text row (pi-herdr's "Other"). */
|
|
23
|
-
allowOther: boolean;
|
|
24
|
-
/** 0-based index of the recommended option, marked in the list. */
|
|
25
|
-
recommended?: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface MultiSelectState {
|
|
29
|
-
/** Cursor row: 0..options.length-1 are choices, options.length is the Other row. */
|
|
30
|
-
cursor: number;
|
|
31
|
-
/** Toggle state per authored option (never includes the Other row). */
|
|
32
|
-
selected: readonly boolean[];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export type MultiSelectKey = 'up' | 'down' | 'toggle' | 'confirm' | 'cancel' | 'all' | 'ignore';
|
|
36
|
-
|
|
37
|
-
export type MultiSelectOutcome =
|
|
38
|
-
| { kind: 'selected'; labels: string[] }
|
|
39
|
-
| { kind: 'other' }
|
|
40
|
-
| { kind: 'cancel' };
|
|
41
|
-
|
|
42
|
-
export interface MultiSelectStep {
|
|
43
|
-
state: MultiSelectState;
|
|
44
|
-
outcome?: MultiSelectOutcome;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function createMultiSelectState(config: MultiSelectConfig): MultiSelectState {
|
|
48
|
-
return { cursor: 0, selected: config.options.map(() => false) };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Total rows including the optional Other row; the cursor wraps inside this. */
|
|
52
|
-
export function rowCount(config: MultiSelectConfig): number {
|
|
53
|
-
return config.options.length + (config.allowOther ? 1 : 0);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function isOtherRow(state: MultiSelectState, config: MultiSelectConfig): boolean {
|
|
57
|
-
return config.allowOther && state.cursor === config.options.length;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function selectedLabels(state: MultiSelectState, config: MultiSelectConfig): string[] {
|
|
61
|
-
const labels: string[] = [];
|
|
62
|
-
config.options.forEach((option, i) => {
|
|
63
|
-
if (state.selected[i]) labels.push(option.label);
|
|
64
|
-
});
|
|
65
|
-
return labels;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Apply one key. A returned outcome ends the interaction. */
|
|
69
|
-
export function stepMultiSelect(state: MultiSelectState, key: MultiSelectKey, config: MultiSelectConfig): MultiSelectStep {
|
|
70
|
-
const rows = rowCount(config);
|
|
71
|
-
if (rows === 0) return key === 'cancel' ? { state, outcome: { kind: 'cancel' } } : { state };
|
|
72
|
-
switch (key) {
|
|
73
|
-
case 'cancel':
|
|
74
|
-
return { state, outcome: { kind: 'cancel' } };
|
|
75
|
-
case 'up':
|
|
76
|
-
case 'down': {
|
|
77
|
-
const delta = key === 'up' ? -1 : 1;
|
|
78
|
-
const cursor = (state.cursor + delta + rows) % rows;
|
|
79
|
-
return { state: { ...state, cursor } };
|
|
80
|
-
}
|
|
81
|
-
case 'all': {
|
|
82
|
-
const anyUnselected = state.selected.some((v) => !v);
|
|
83
|
-
return { state: { ...state, selected: state.selected.map(() => anyUnselected) } };
|
|
84
|
-
}
|
|
85
|
-
case 'toggle': {
|
|
86
|
-
if (isOtherRow(state, config)) return { state };
|
|
87
|
-
const selected = state.selected.map((v, i) => (i === state.cursor ? !v : v));
|
|
88
|
-
return { state: { ...state, selected } };
|
|
89
|
-
}
|
|
90
|
-
case 'confirm': {
|
|
91
|
-
if (isOtherRow(state, config)) return { state, outcome: { kind: 'other' } };
|
|
92
|
-
// Enter on a choice row confirms the current toggles; a single-choice
|
|
93
|
-
// question (multi: false) needs no separate space press.
|
|
94
|
-
if (config.options.length === 1) {
|
|
95
|
-
return { state, outcome: { kind: 'selected', labels: [config.options[0]!.label] } };
|
|
96
|
-
}
|
|
97
|
-
return { state, outcome: { kind: 'selected', labels: selectedLabels(state, config) } };
|
|
98
|
-
}
|
|
99
|
-
default:
|
|
100
|
-
return { state };
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/* ── Key matching ──────────────────────────────────────────────────── */
|
|
105
|
-
|
|
106
|
-
export type KeyMatcher = (data: string, key: Exclude<MultiSelectKey, 'ignore'>) => boolean;
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Fallback matcher for raw key sequences, used when pi-tui cannot be imported.
|
|
110
|
-
* Ordering inside `handleInput` matters for escape: arrow sequences are matched
|
|
111
|
-
* before the bare ESC key because both start with 0x1b.
|
|
112
|
-
*/
|
|
113
|
-
export const FALLBACK_KEY_SEQUENCES: Record<Exclude<MultiSelectKey, 'ignore'>, readonly string[]> = {
|
|
114
|
-
up: ['\x1b[A', '\x1bOA', 'k'],
|
|
115
|
-
down: ['\x1b[B', '\x1bOB', 'j'],
|
|
116
|
-
toggle: [' '],
|
|
117
|
-
confirm: ['\r', '\n'],
|
|
118
|
-
cancel: ['\x1b', '\x03'],
|
|
119
|
-
all: ['a', 'A'],
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
export const fallbackKeyMatcher: KeyMatcher = (data, key) => FALLBACK_KEY_SEQUENCES[key].includes(data);
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Prefer pi-tui's `matchesKey` (it understands terminal variations and keybindings
|
|
126
|
-
* remaps); fall back to the built-in sequences when the package is unavailable.
|
|
127
|
-
*/
|
|
128
|
-
export async function loadKeyMatcher(): Promise<KeyMatcher> {
|
|
129
|
-
try {
|
|
130
|
-
const tui = await import('@earendil-works/pi-tui') as {
|
|
131
|
-
matchesKey?: (data: string, key: unknown) => boolean;
|
|
132
|
-
Key?: Record<string, unknown>;
|
|
133
|
-
};
|
|
134
|
-
const Key = tui.Key;
|
|
135
|
-
if (typeof tui.matchesKey === 'function' && Key && typeof Key.up === 'string') {
|
|
136
|
-
const map: Record<string, unknown> = {
|
|
137
|
-
up: Key.up,
|
|
138
|
-
down: Key.down,
|
|
139
|
-
toggle: Key.space,
|
|
140
|
-
confirm: Key.enter,
|
|
141
|
-
cancel: Key.escape,
|
|
142
|
-
all: 'a',
|
|
143
|
-
};
|
|
144
|
-
return (data, key) => tui.matchesKey!(data, map[key]);
|
|
145
|
-
}
|
|
146
|
-
} catch {
|
|
147
|
-
/* pi-tui absent: use the built-in sequences. */
|
|
148
|
-
}
|
|
149
|
-
return fallbackKeyMatcher;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/* ── TUI component ─────────────────────────────────────────────────── */
|
|
153
|
-
|
|
154
|
-
export interface MinimalTheme {
|
|
155
|
-
fg(color: string, text: string): string;
|
|
156
|
-
bold(text: string): string;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export interface MultiSelectComponent {
|
|
160
|
-
render(width: number): string[];
|
|
161
|
-
handleInput(data: string): void;
|
|
162
|
-
invalidate(): void;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export interface MultiSelectUiOptions {
|
|
166
|
-
title: string;
|
|
167
|
-
config: MultiSelectConfig;
|
|
168
|
-
theme: MinimalTheme;
|
|
169
|
-
matcher: KeyMatcher;
|
|
170
|
-
/** Called with the outcome; the host resolves ctx.ui.custom. */
|
|
171
|
-
done(outcome: MultiSelectOutcome): void;
|
|
172
|
-
/** Ask for the gate label used by the renderer (tests only). */
|
|
173
|
-
requestRender?: () => void;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const RECOMMENDED = ' (Recommended)';
|
|
177
|
-
|
|
178
|
-
export function multiSelectLines(state: MultiSelectState, config: MultiSelectConfig, theme: MinimalTheme): string[] {
|
|
179
|
-
const lines: string[] = [];
|
|
180
|
-
config.options.forEach((option, i) => {
|
|
181
|
-
const cursor = i === state.cursor ? theme.fg('accent', '> ') : ' ';
|
|
182
|
-
const box = state.selected[i] ? theme.fg('success', '[x]') : '[ ]';
|
|
183
|
-
const label = `${option.label}${config.recommended === i ? RECOMMENDED : ''}`;
|
|
184
|
-
const styled = state.selected[i] ? theme.bold(label) : label;
|
|
185
|
-
const desc = option.description ? theme.fg('dim', ` — ${option.description}`) : '';
|
|
186
|
-
lines.push(`${cursor}${box} ${styled}${desc}`);
|
|
187
|
-
});
|
|
188
|
-
if (config.allowOther) {
|
|
189
|
-
const cursor = isOtherRow(state, config) ? theme.fg('accent', '> ') : ' ';
|
|
190
|
-
lines.push(`${cursor}${theme.fg('dim', '[ ] Other (type your own)')}`);
|
|
191
|
-
}
|
|
192
|
-
return lines;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export function multiSelectFooter(state: MultiSelectState, config: MultiSelectConfig, theme: MinimalTheme): string {
|
|
196
|
-
const picked = selectedLabels(state, config).length;
|
|
197
|
-
const count = picked > 0 ? theme.fg('success', ` · ${picked} selected`) : '';
|
|
198
|
-
return theme.fg('dim', '↑/↓ move · space toggle · a all · enter confirm · esc cancel') + count;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export function createMultiSelectComponent(opts: MultiSelectUiOptions): MultiSelectComponent {
|
|
202
|
-
let state = createMultiSelectState(opts.config);
|
|
203
|
-
let finished = false;
|
|
204
|
-
const finish = (outcome: MultiSelectOutcome): void => {
|
|
205
|
-
if (finished) return;
|
|
206
|
-
finished = true;
|
|
207
|
-
opts.done(outcome);
|
|
208
|
-
};
|
|
209
|
-
return {
|
|
210
|
-
render(width: number): string[] {
|
|
211
|
-
const head = opts.theme.bold(opts.title);
|
|
212
|
-
const body = multiSelectLines(state, opts.config, opts.theme);
|
|
213
|
-
const footer = multiSelectFooter(state, opts.config, opts.theme);
|
|
214
|
-
return [head, ...body, '', footer].map((line) => truncateStyled(line, width));
|
|
215
|
-
},
|
|
216
|
-
handleInput(data: string): void {
|
|
217
|
-
if (finished) return;
|
|
218
|
-
// Arrow sequences are checked before the bare ESC key (shared 0x1b prefix).
|
|
219
|
-
const key: MultiSelectKey = opts.matcher(data, 'up') ? 'up'
|
|
220
|
-
: opts.matcher(data, 'down') ? 'down'
|
|
221
|
-
: opts.matcher(data, 'toggle') ? 'toggle'
|
|
222
|
-
: opts.matcher(data, 'confirm') ? 'confirm'
|
|
223
|
-
: opts.matcher(data, 'cancel') ? 'cancel'
|
|
224
|
-
: opts.matcher(data, 'all') ? 'all'
|
|
225
|
-
: 'ignore';
|
|
226
|
-
if (key === 'ignore') return;
|
|
227
|
-
const next = stepMultiSelect(state, key, opts.config);
|
|
228
|
-
state = next.state;
|
|
229
|
-
opts.requestRender?.();
|
|
230
|
-
if (next.outcome) finish(next.outcome);
|
|
231
|
-
},
|
|
232
|
-
invalidate(): void { /* No cached lines. */ },
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Run the toggle list through `ctx.ui.custom`.
|
|
238
|
-
* Returns null when the host cannot render custom components (RPC mode / older pi),
|
|
239
|
-
* so the caller can fall back to the typed-index prompt.
|
|
240
|
-
*/
|
|
241
|
-
export async function runMultiSelect(
|
|
242
|
-
custom: (factory: unknown) => Promise<unknown>,
|
|
243
|
-
title: string,
|
|
244
|
-
config: MultiSelectConfig,
|
|
245
|
-
): Promise<MultiSelectOutcome | null> {
|
|
246
|
-
const matcher = await loadKeyMatcher();
|
|
247
|
-
const result = await custom((tui: unknown, theme: unknown, _keybindings: unknown, done: (value: unknown) => void) =>
|
|
248
|
-
createMultiSelectComponent({
|
|
249
|
-
title,
|
|
250
|
-
config,
|
|
251
|
-
theme: theme as MinimalTheme,
|
|
252
|
-
matcher,
|
|
253
|
-
done: (outcome) => done(outcome),
|
|
254
|
-
requestRender: () => (tui as { requestRender?: () => void } | undefined)?.requestRender?.(),
|
|
255
|
-
}));
|
|
256
|
-
if (result === undefined || result === null) return null;
|
|
257
|
-
const outcome = result as MultiSelectOutcome;
|
|
258
|
-
if (outcome.kind === 'cancel' || outcome.kind === 'other' || outcome.kind === 'selected') return outcome;
|
|
259
|
-
return null;
|
|
260
|
-
}
|