pi-pier 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/schemas/role-manifest.schema.json +1 -2
- package/src/bootstrap.ts +18 -22
- package/src/core/grid-shape.ts +13 -14
- package/src/core/subagent.ts +325 -857
- package/src/core/terminal.ts +25 -28
- package/src/core/todo.ts +47 -53
- package/src/gc-core.ts +15 -15
- package/src/git-adapter.ts +104 -0
- package/src/herdr-client.ts +76 -68
- package/src/history-store.ts +70 -36
- package/src/index-locks.ts +105 -0
- package/src/index-master.ts +113 -0
- package/src/index-notices.ts +43 -0
- package/src/index-pipe.ts +73 -0
- package/src/index-runtime.ts +23 -0
- package/src/index-worker.ts +39 -0
- package/src/index.ts +134 -386
- package/src/ledger.ts +15 -17
- package/src/lock-core.ts +15 -29
- package/src/manifest-compose.ts +9 -23
- package/src/notice-buffer.ts +9 -14
- package/src/pane-title.ts +24 -24
- package/src/pi-surface.ts +22 -29
- package/src/pipe-channel.ts +67 -34
- package/src/platform-paths.ts +72 -0
- package/src/progress-core.ts +26 -24
- package/src/reconcile-core.ts +25 -24
- package/src/role-loader.ts +32 -35
- package/src/role-manifest.ts +24 -28
- package/src/roles/master.json +2 -2
- package/src/roles/worker-default.json +3 -3
- package/src/runtime-policy.ts +66 -0
- package/src/session-tail.ts +46 -45
- package/src/settle-wake-core.ts +19 -18
- package/src/slim-frame.ts +45 -44
- package/src/stale-core.ts +23 -21
- package/src/storage-layout.ts +80 -0
- package/src/subagent-core.ts +151 -94
- package/src/subagent-gc.ts +237 -0
- package/src/subagent-git-io.ts +80 -0
- package/src/subagent-launch.ts +110 -0
- package/src/subagent-poll-loop.ts +285 -0
- package/src/subagent-poller.ts +90 -0
- package/src/subagent-port.ts +27 -0
- package/src/subagent-scope.ts +4 -4
- package/src/subagent-session-io.ts +143 -0
- package/src/subagent-spawn.ts +156 -0
- package/src/terminal-core.ts +56 -54
- package/src/todo-core.ts +27 -64
- package/src/todo-read-hook.ts +28 -24
- package/src/todo-reminder-core.ts +89 -0
- package/src/todos-service.ts +36 -25
- package/src/tool-gate.ts +7 -28
- package/src/vocab.ts +18 -19
package/src/pipe-channel.ts
CHANGED
|
@@ -1,49 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* M11
|
|
2
|
+
* M11 separates the human/machine channels (D45–D47) by having the pi extension
|
|
3
|
+
* create its own Windows named pipe.
|
|
3
4
|
*
|
|
4
|
-
* -
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
5
|
+
* - Controller (client): one connection per request, matching the herdr client;
|
|
6
|
+
* - Child pane (server): when PI_HERDR_SUBAGENT=1, the extension starts a server at
|
|
7
|
+
* session_start and injects prompt/follow_up through pi.sendUserMessage (visible in TUI);
|
|
8
|
+
* - Completion data is not sent back; authoritative state lives in herdr, session JSONL,
|
|
9
|
+
* and history (D47).
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
* pi
|
|
11
|
+
* This file contains only pure transport-layer logic for isolated tests; index.ts
|
|
12
|
+
* performs pi injection in the server callback.
|
|
11
13
|
*/
|
|
12
14
|
import * as net from 'node:net';
|
|
13
|
-
import { sessionDirName } from './
|
|
15
|
+
import { sessionDirCandidates, sessionDirName } from './storage-layout.ts';
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
function encodePaneId(paneId: string): string {
|
|
18
|
+
return String(paneId).replace(/[^A-Za-z0-9_-]/g, '-');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Pipe name: workspace-scoped (same encoding as history dirs) + paneId. */
|
|
16
22
|
export function pipeNameFor(cwd: string, paneId: string): string {
|
|
17
|
-
|
|
18
|
-
const encoded = String(paneId).replace(/[^A-Za-z0-9_-]/g, '-');
|
|
19
|
-
return `pi-herdr-${dir}-${encoded}`;
|
|
23
|
+
return `pi-herdr-${sessionDirName(cwd)}-${encodePaneId(paneId)}`;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
/**
|
|
26
|
+
/** New encoding first, then legacy — client retries so mixed-version peers still connect. */
|
|
27
|
+
export function pipeNameCandidates(cwd: string, paneId: string): string[] {
|
|
28
|
+
return sessionDirCandidates(cwd).map((dir) => `pi-herdr-${dir}-${encodePaneId(paneId)}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Windows named-pipe address in the kernel namespace, not a filesystem path. */
|
|
23
32
|
export function pipePathFor(name: string): string {
|
|
24
33
|
return process.platform === 'win32'
|
|
25
34
|
? (name.startsWith('\\\\.\\pipe\\') ? name : `\\\\.\\pipe\\${name}`)
|
|
26
35
|
: `/tmp/${name}.sock`;
|
|
27
36
|
}
|
|
28
37
|
|
|
29
|
-
/**
|
|
38
|
+
/** Pipe message in the JSON-lines protocol; one connection carries one request and response. */
|
|
30
39
|
export type PipeRequest =
|
|
31
40
|
| { type: 'ping'; id: string }
|
|
32
41
|
| {
|
|
33
42
|
type: 'prompt' | 'follow_up';
|
|
34
43
|
id: string;
|
|
35
44
|
text: string;
|
|
36
|
-
/**
|
|
45
|
+
/** D49: reply follows the sender's pipe name, avoiding hard-coded controller/child roles. */
|
|
37
46
|
from?: string | null;
|
|
38
|
-
/** D50
|
|
47
|
+
/** D50: whether settlement pushes a reply (background true, foreground false because the caller reads synchronously). */
|
|
39
48
|
push?: boolean;
|
|
40
|
-
/** B3
|
|
41
|
-
*
|
|
49
|
+
/** B3: deliver follow_up as steer so it arrives between tool calls within seconds. Missing/false keeps
|
|
50
|
+
* queue semantics (delivered when the run ends); the initial prompt stays queued while continuations steer. */
|
|
42
51
|
steer?: boolean;
|
|
43
52
|
}
|
|
44
53
|
| { type: 'interrupt'; id: string }
|
|
45
54
|
| {
|
|
46
|
-
/** D50
|
|
55
|
+
/** D50: fast-path settlement reply from child to requester, with summary and session path for progressive disclosure. */
|
|
47
56
|
type: 'reply';
|
|
48
57
|
id: string;
|
|
49
58
|
paneId: string;
|
|
@@ -55,7 +64,7 @@ export type PipeResponse =
|
|
|
55
64
|
| { type: 'ok'; id: string; detail?: string }
|
|
56
65
|
| { type: 'error'; id: string; message: string };
|
|
57
66
|
|
|
58
|
-
/**
|
|
67
|
+
/** Parse one JSON line, returning null for malformed input. */
|
|
59
68
|
export function parsePipeLine(line: string): PipeRequest | PipeResponse | null {
|
|
60
69
|
const t = (line ?? '').trim();
|
|
61
70
|
if (!t) return null;
|
|
@@ -63,14 +72,14 @@ export function parsePipeLine(line: string): PipeRequest | PipeResponse | null {
|
|
|
63
72
|
const obj = JSON.parse(t);
|
|
64
73
|
if (obj && typeof obj === 'object' && typeof obj.type === 'string') return obj as PipeRequest | PipeResponse;
|
|
65
74
|
} catch {
|
|
66
|
-
/*
|
|
75
|
+
/* Malformed line. */
|
|
67
76
|
}
|
|
68
77
|
return null;
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
81
|
+
* One connection, one request: connect → write JSON line → wait for JSON line → close.
|
|
82
|
+
* Timeout / connect failure / bad frame throw (caller retries).
|
|
74
83
|
*/
|
|
75
84
|
export function pipeRequest(
|
|
76
85
|
pipeName: string,
|
|
@@ -117,19 +126,43 @@ export function pipeRequest(
|
|
|
117
126
|
});
|
|
118
127
|
}
|
|
119
128
|
|
|
120
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Try new then legacy pipe names so a new client can still reach an old server.
|
|
131
|
+
*/
|
|
132
|
+
export async function pipeRequestTo(
|
|
133
|
+
cwd: string,
|
|
134
|
+
paneId: string,
|
|
135
|
+
payload: PipeRequest,
|
|
136
|
+
timeoutMs = 8000,
|
|
137
|
+
): Promise<PipeResponse> {
|
|
138
|
+
const names = pipeNameCandidates(cwd, paneId);
|
|
139
|
+
let lastErr: unknown;
|
|
140
|
+
for (const name of names) {
|
|
141
|
+
try {
|
|
142
|
+
return await pipeRequest(name, payload, timeoutMs);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
lastErr = err;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
throw lastErr instanceof Error ? lastErr : new Error(`pipe ${names[0] ?? paneId}: unreachable`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Ping with retry (readiness probe). Accepts one name or new+legacy candidates. */
|
|
121
151
|
export async function pingUntilReady(
|
|
122
|
-
pipeName: string,
|
|
152
|
+
pipeName: string | readonly string[],
|
|
123
153
|
timeoutMs: number,
|
|
124
154
|
intervalMs = 1000,
|
|
125
155
|
): Promise<boolean> {
|
|
156
|
+
const names = typeof pipeName === 'string' ? [pipeName] : pipeName;
|
|
126
157
|
const deadline = Date.now() + timeoutMs;
|
|
127
158
|
while (Date.now() < deadline) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
159
|
+
for (const name of names) {
|
|
160
|
+
try {
|
|
161
|
+
const res = await pipeRequest(name, { type: 'ping', id: `ping-${Date.now()}` }, 3000);
|
|
162
|
+
if (res.type === 'ok') return true;
|
|
163
|
+
} catch {
|
|
164
|
+
/* not ready on this name */
|
|
165
|
+
}
|
|
133
166
|
}
|
|
134
167
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
135
168
|
}
|
|
@@ -139,8 +172,8 @@ export async function pingUntilReady(
|
|
|
139
172
|
export type PipeMessageHandler = (req: PipeRequest) => Promise<PipeResponse>;
|
|
140
173
|
|
|
141
174
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
175
|
+
* Server: one line in → handler → one line out → close.
|
|
176
|
+
* Returns the server (caller closes it).
|
|
144
177
|
*/
|
|
145
178
|
export function startPipeServer(
|
|
146
179
|
pipeName: string,
|
|
@@ -173,11 +206,11 @@ export function startPipeServer(
|
|
|
173
206
|
});
|
|
174
207
|
});
|
|
175
208
|
sock.on('error', () => {
|
|
176
|
-
/*
|
|
209
|
+
/* client disconnect: silent */
|
|
177
210
|
});
|
|
178
211
|
});
|
|
179
212
|
server.on('error', () => {
|
|
180
|
-
/*
|
|
213
|
+
/* address in use: caller observes listen errors */
|
|
181
214
|
});
|
|
182
215
|
server.listen(pipePathFor(pipeName));
|
|
183
216
|
return server;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Platform-aware storage and workspace path resolution.
|
|
6
|
+
*
|
|
7
|
+
* Why: Centralizes platform-specific path logic to support Windows (LOCALAPPDATA),
|
|
8
|
+
* Unix XDG conventions, and testability through injection.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface PlatformPaths {
|
|
12
|
+
/** Base directory for agent sessions and state */
|
|
13
|
+
readonly agentDataDir: string
|
|
14
|
+
/** Base directory for worktrees */
|
|
15
|
+
readonly worktreeBaseDir: string
|
|
16
|
+
/** Sessions subdirectory under agent data */
|
|
17
|
+
readonly sessionsDir: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getDefaultDataDir(): string {
|
|
21
|
+
const platform = process.platform
|
|
22
|
+
|
|
23
|
+
if (platform === 'win32') {
|
|
24
|
+
// Windows: use LOCALAPPDATA or fallback to homedir
|
|
25
|
+
return process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Unix: XDG_DATA_HOME or ~/.local/share on Linux, ~/.local/share on macOS
|
|
29
|
+
if (platform === 'linux') {
|
|
30
|
+
return process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// macOS: use ~/.local/share for consistency, but .pi is acceptable legacy
|
|
34
|
+
return process.env.XDG_DATA_HOME || join(homedir(), '.pi')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getDefaultWorktreeDir(): string {
|
|
38
|
+
const platform = process.platform
|
|
39
|
+
|
|
40
|
+
if (platform === 'win32') {
|
|
41
|
+
const localAppData = process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local')
|
|
42
|
+
return join(localAppData, 'herdr', 'worktrees')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Unix: use XDG_DATA_HOME or ~/.local/share
|
|
46
|
+
if (platform === 'linux') {
|
|
47
|
+
const dataHome = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share')
|
|
48
|
+
return join(dataHome, 'herdr', 'worktrees')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// macOS legacy: ~/.herdr/worktrees
|
|
52
|
+
return join(homedir(), '.herdr', 'worktrees')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createPlatformPaths(overrides?: Partial<PlatformPaths>): PlatformPaths {
|
|
56
|
+
const defaultAgentDataDir = join(getDefaultDataDir(), 'agent')
|
|
57
|
+
const agentDataDir = overrides?.agentDataDir || defaultAgentDataDir
|
|
58
|
+
const sessionsDir = overrides?.sessionsDir || join(agentDataDir, 'sessions')
|
|
59
|
+
const worktreeBaseDir = overrides?.worktreeBaseDir || getDefaultWorktreeDir()
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
agentDataDir,
|
|
63
|
+
sessionsDir,
|
|
64
|
+
worktreeBaseDir,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Singleton platform paths for production use.
|
|
70
|
+
* Tests should inject via createPlatformPaths().
|
|
71
|
+
*/
|
|
72
|
+
export const platformPaths = createPlatformPaths()
|
package/src/progress-core.ts
CHANGED
|
@@ -1,39 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* M16
|
|
2
|
+
* M16 pure core for progress visibility (development plan §M16; prioritize conservative output, with no new D3 protocol).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
4
|
+
* Channels in the post-M22 world:
|
|
5
|
+
* - Progress badge (N/M plus optional ETA) is embedded in the pane title (state_labels has only five keys, so it cannot fit there);
|
|
6
|
+
* - Tool badge (🔧 tool) is sent through report_agent.message;
|
|
7
|
+
* - Rate estimates follow kimi estimator semantics: rate window, unfinished cap, and confidence gate.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Conservative principle (risk ⑥ decision): when an estimate is not trustworthy, fall back to plain `N/M` and
|
|
10
|
+
* never display a misleading ETA.
|
|
10
11
|
*/
|
|
12
|
+
|
|
11
13
|
import { countTodos, type TodoItem } from './vocab.ts';
|
|
12
14
|
|
|
13
|
-
/** ETA
|
|
15
|
+
/** Minimum completed points needed for an ETA (<2 cannot establish a rate). */
|
|
14
16
|
export const ETA_MIN_SAMPLES = 2;
|
|
15
|
-
/**
|
|
17
|
+
/** Rate sample window (kimi rate-window semantics; use the most recent K completion points). */
|
|
16
18
|
export const RATE_WINDOW_MS = 45_000;
|
|
17
|
-
/**
|
|
19
|
+
/** Cap for displayed unfinished progress (kimi unfinished-cap semantics; prevent a false "almost done" impression). */
|
|
18
20
|
export const UNFINISHED_CAP = 0.85;
|
|
19
|
-
/**
|
|
21
|
+
/** Completion freshness gate: if the most recent completion is older than this, data is stale and no ETA is estimated. */
|
|
20
22
|
export const PROGRESS_STALE_MS = 5 * 60_000;
|
|
21
|
-
/**
|
|
23
|
+
/** Number of recent completion points used for rate fitting. */
|
|
22
24
|
const RATE_SAMPLE_POINTS = 5;
|
|
23
25
|
|
|
24
26
|
export interface EtaEstimate {
|
|
25
|
-
/**
|
|
27
|
+
/** Remaining steps. */
|
|
26
28
|
remaining: number;
|
|
27
|
-
/**
|
|
29
|
+
/** Estimated remaining time (milliseconds). */
|
|
28
30
|
etaMs: number;
|
|
29
31
|
confidence: 'ok';
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
|
-
*
|
|
34
|
-
* -
|
|
35
|
-
* -
|
|
36
|
-
* -
|
|
35
|
+
* Estimate ETA from completion timestamps and remaining steps.
|
|
36
|
+
* - Fewer than 2 completion points → null (no rate can be inferred);
|
|
37
|
+
* - Most recent completion older than PROGRESS_STALE_MS → null (stale after a burst followed by a pause or manual hold);
|
|
38
|
+
* - Rate = use the latest K(≤5) points, (k-1) / (t_k - t_1); eta = remaining / rate.
|
|
37
39
|
*/
|
|
38
40
|
export function estimateEta(opts: {
|
|
39
41
|
completedAt: readonly number[];
|
|
@@ -56,7 +58,7 @@ export function estimateEta(opts: {
|
|
|
56
58
|
return { remaining, etaMs: Math.round(remaining / ratePerMs), confidence: 'ok' };
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
/**
|
|
61
|
+
/** Title progress suffix: conservative `3/7`; trusted ETA gives `3/7 ~4m`; total=0 → empty; all complete → `5/5 ✓`. */
|
|
60
62
|
export function formatProgressSuffix(opts: {
|
|
61
63
|
completed: number;
|
|
62
64
|
total: number;
|
|
@@ -74,27 +76,27 @@ export function formatProgressSuffix(opts: {
|
|
|
74
76
|
return `${base} ${human}`;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
/**
|
|
79
|
+
/** Tool badge (report_agent.message): one tool name; multiple use the first plus a count; empty → null (do not overwrite). */
|
|
78
80
|
export function planToolBadge(runningToolNames: readonly string[]): string | null {
|
|
79
81
|
if (runningToolNames.length === 0) return null;
|
|
80
82
|
const first = runningToolNames[0];
|
|
81
83
|
return runningToolNames.length === 1 ? `🔧 ${first}` : `🔧 ${first} +${runningToolNames.length - 1}`;
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
/**
|
|
86
|
+
/** Derive progress input from a list (completed count / total open-work count). */
|
|
85
87
|
export function progressOf(items: readonly TodoItem[]): { completed: number; total: number } {
|
|
86
88
|
const c = countTodos(items);
|
|
87
89
|
const blockedCount = items.filter((it) => it.status === 'blocked').length;
|
|
88
90
|
return { completed: c.completed, total: c.completed + c.pending + c.inProgress + blockedCount };
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
/**
|
|
93
|
+
/** Hide the progress badge after it has been marked for the full lifecycle (avoid persistent completed-state noise; callers compare this). */
|
|
92
94
|
export const PROGRESS_HIDE_MS = 60_000;
|
|
93
95
|
|
|
94
96
|
/**
|
|
95
|
-
*
|
|
96
|
-
* completed
|
|
97
|
-
*
|
|
97
|
+
* Count completed transitions between two lists (match by content; an item completed in the new list whose old status
|
|
98
|
+
* was not completed, including items newly added as completed). M16 ETA estimation uses this as input: every edit path
|
|
99
|
+
* (todo_write / human edit / M17 reconciliation) passes through TodosService, so the diff is centralized here.
|
|
98
100
|
*/
|
|
99
101
|
export function countCompletedTransitions(
|
|
100
102
|
before: readonly TodoItem[],
|
package/src/reconcile-core.ts
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* M17
|
|
2
|
+
* M17: pure planner for automatic todo↔subagent reconciliation (development plan §M17; P2 = do not check off low-confidence matches, only prompt).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
* → substring
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
4
|
+
* Timing (adapter): after the controller receives a settlement push (pollLoop / pipe reply), before injecting followUp.
|
|
5
|
+
* Semantics (aligned with omp reconcileTodosWithSubagents):
|
|
6
|
+
* - Match description ↔ todo.content: normalize (lowercase + collapse whitespace), then exact → bidirectional prefix
|
|
7
|
+
* → substring, in the same family as D38 fuzzyFind;
|
|
8
|
+
* - Auto-complete only when settled and the unique best tier is in {exact, prefix}; at most one item per settlement;
|
|
9
|
+
* - Unblock blocked items whose blocker ↔ description matches at any tier (exact/prefix)
|
|
10
|
+
* (multiple allowed; failed/low-confidence matches do not unblock);
|
|
11
|
+
* - Low confidence/ambiguity/failure: leave the list unchanged and add a noteLines prompt to the settlement notice (P2 decision);
|
|
12
|
+
* - Persistence: edits use the D38 authoritative path (pi-herdr.todo-edit custom entry, effective during branch replay).
|
|
13
13
|
*/
|
|
14
|
+
|
|
14
15
|
import { applyTodoEdits, type TodoEdit, type TodoItem } from './todo-core.ts';
|
|
15
16
|
|
|
16
17
|
export type ReconcileOutcome = 'settled' | 'failed';
|
|
17
18
|
export type MatchTier = 'exact' | 'prefix' | 'substring' | null;
|
|
18
|
-
|
|
19
19
|
export interface ReconcilePlan {
|
|
20
|
-
/**
|
|
20
|
+
/** List after applying edits (original reference when there are no edits). */
|
|
21
21
|
items: TodoItem[];
|
|
22
|
-
/**
|
|
22
|
+
/** Edits persisted through the authoritative path (at most one done plus any unblocks). */
|
|
23
23
|
edits: TodoEdit[];
|
|
24
|
-
/**
|
|
24
|
+
/** Item automatically checked off (at most one). */
|
|
25
25
|
completed: TodoItem | null;
|
|
26
|
-
/**
|
|
26
|
+
/** Best matching tier for auto-completion (null when there are no candidates). */
|
|
27
27
|
tier: MatchTier;
|
|
28
|
-
/**
|
|
28
|
+
/** Items unblocked by a blocker match. */
|
|
29
29
|
unblocked: TodoItem[];
|
|
30
|
-
/**
|
|
30
|
+
/** Prompt lines injected into the settlement notice (aligned with D36 feedback tone; empty when none). */
|
|
31
31
|
noteLines: string[];
|
|
32
|
-
/**
|
|
32
|
+
/** Match-rate metric (P2: collected with the settlement notice in session JSONL). */
|
|
33
33
|
metric: {
|
|
34
34
|
description: string;
|
|
35
35
|
outcome: ReconcileOutcome;
|
|
@@ -64,7 +64,7 @@ export function reconcileTodos(
|
|
|
64
64
|
const edits: TodoEdit[] = [];
|
|
65
65
|
const noteLines: string[] = [];
|
|
66
66
|
|
|
67
|
-
// 1)
|
|
67
|
+
// 1) Check-off candidates: pending / in_progress (completed/abandoned never participate).
|
|
68
68
|
const matchable = prev.filter((t) => t.status === 'pending' || t.status === 'in_progress');
|
|
69
69
|
const scored = matchable
|
|
70
70
|
.map((t) => ({ item: t, tier: matchTier(description, t.content) }))
|
|
@@ -80,25 +80,26 @@ export function reconcileTodos(
|
|
|
80
80
|
edits.push({ op: 'done', content: completed.content });
|
|
81
81
|
noteLines.push(`Reconciled: completed "${completed.content}" (${bestTier} match with subagent description).`);
|
|
82
82
|
} else if (best.length > 1) {
|
|
83
|
-
//
|
|
83
|
+
// Ambiguous match: list candidates and leave the decision to a person.
|
|
84
84
|
noteLines.push(
|
|
85
85
|
`Todo match ambiguous between: ${best.map((c) => `"${c.item.content}"`).join(', ')} — update todo_write yourself.`,
|
|
86
86
|
);
|
|
87
87
|
} else if (outcome === 'failed') {
|
|
88
|
-
//
|
|
88
|
+
// High-confidence candidate, but the subagent did not settle successfully: keep it open and prompt.
|
|
89
89
|
for (const c of best) {
|
|
90
90
|
noteLines.push(`Todo kept open: "${c.item.content}" (subagent did not settle successfully).`);
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
} else if (bestTier === 'substring') {
|
|
94
|
-
//
|
|
94
|
+
// Low-confidence match (P2): do not check it off; prompt with the candidate.
|
|
95
95
|
for (const c of best) {
|
|
96
96
|
noteLines.push(`Todo not auto-completed (low-confidence match): "${c.item.content}" — update todo_write if this work is done.`);
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
// 2)
|
|
101
|
-
//
|
|
100
|
+
// 2) Unblock on any blocker match, including substring—real blockers are phrases such as "waiting for X to finish",
|
|
101
|
+
// so the description may be embedded in the middle and prefix would miss it. The low-confidence gate applies only
|
|
102
|
+
// to check-offs; returning an unblocked item to pending is a soft operation. Only settled subagents can unblock (possibly multiple).
|
|
102
103
|
const unblocked: TodoItem[] = [];
|
|
103
104
|
if (outcome === 'settled') {
|
|
104
105
|
for (const t of prev) {
|
package/src/role-loader.ts
CHANGED
|
@@ -1,40 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Layer 2 Week 1: load role manifests (2026-08-18); v1.1 (2026-08-22) adds two user-directory layers.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 1.
|
|
6
|
-
* 2.
|
|
7
|
-
* 3.
|
|
4
|
+
* Lookup order (v1.1, user-approved):
|
|
5
|
+
* 1. Workspace-level `<cwd>/.pi-herdr/roles/<name>.json` (travels with the clone and is team-shared)
|
|
6
|
+
* 2. User-global `~/.pi/agent/herdr-pi/roles/<name>.json` (personal preference across repositories)
|
|
7
|
+
* 3. Bundled `src/roles/` as the fallback shipped with this package
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Built-in names remain reserved (master / worker-default): reject collisions in user/workspace layers—D83 mirror.
|
|
10
|
+
* Tests and the D82 stance are anchored to bundled manifests; allowing overrides would break those guarantees.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* JSON
|
|
16
|
-
*
|
|
12
|
+
* Role names are restricted to [a-z0-9-]—this prevents path traversal and avoids touching disk for invalid names.
|
|
13
|
+
* The manifest role field must match the filename to prevent attaching the wrong manifest.
|
|
14
|
+
* Validation failures are classified as: unreadable = ROLE_NOT_FOUND (reported only after trying every layer);
|
|
15
|
+
* parsed but invalid JSON/validation/name = INVALID_ROLE_CONFIG (report immediately at the hit layer rather than
|
|
16
|
+
* silently falling through, because silence would hide a user editing a manifest incorrectly).
|
|
17
17
|
*/
|
|
18
18
|
import { existsSync, readFileSync } from 'node:fs';
|
|
19
19
|
import { fileURLToPath } from 'node:url';
|
|
20
20
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
21
|
-
import { homedir } from 'node:os';
|
|
22
21
|
import { validateRoleManifest, type RoleManifest } from './role-manifest.ts';
|
|
22
|
+
import { userRolesDir, workspaceRolesDir as layoutWorkspaceRolesDir } from './storage-layout.ts';
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
export { userRolesDir };
|
|
25
|
+
|
|
26
|
+
/** Bundled manifest directory (`src/roles/`). */
|
|
25
27
|
export const ROLES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'roles');
|
|
26
28
|
|
|
27
|
-
/**
|
|
29
|
+
/** Workspace-level directory (the default base is the process cwd, i.e. the master's working directory). */
|
|
28
30
|
export function workspaceRolesDir(baseDir?: string): string {
|
|
29
|
-
return
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** 用户全局目录(~/.pi/agent/herdr-pi/roles/)。 */
|
|
33
|
-
export function userRolesDir(): string {
|
|
34
|
-
return join(homedir(), '.pi', 'agent', 'herdr-pi', 'roles');
|
|
31
|
+
return layoutWorkspaceRolesDir(baseDir ?? process.cwd());
|
|
35
32
|
}
|
|
36
33
|
|
|
37
|
-
/**
|
|
34
|
+
/** Reserved built-in names (no user layer may override them). */
|
|
38
35
|
export const RESERVED_ROLE_NAMES: readonly string[] = ['master', 'worker-default'];
|
|
39
36
|
|
|
40
37
|
const ROLE_NAME_RE = /^[a-z0-9-]+$/;
|
|
@@ -56,7 +53,7 @@ export class RoleLoaderError extends Error {
|
|
|
56
53
|
|
|
57
54
|
export type RoleReader = (fileName: string) => string;
|
|
58
55
|
|
|
59
|
-
/**
|
|
56
|
+
/** Single-layer reader (injectable; production uses existsSync + readFileSync for that directory). */
|
|
60
57
|
export type LayerReader = (dir: string, fileName: string) => string | null;
|
|
61
58
|
|
|
62
59
|
const defaultLayerRead: LayerReader = (dir, fileName) => {
|
|
@@ -66,21 +63,21 @@ const defaultLayerRead: LayerReader = (dir, fileName) => {
|
|
|
66
63
|
};
|
|
67
64
|
|
|
68
65
|
export interface LoadRoleOptions {
|
|
69
|
-
/**
|
|
66
|
+
/** Inject one reader (legacy shape: only the bundled layer is hit, for test compatibility). */
|
|
70
67
|
read?: RoleReader;
|
|
71
|
-
/**
|
|
68
|
+
/** Inject layered readers (v1.1). */
|
|
72
69
|
layerRead?: LayerReader;
|
|
73
|
-
/** workspace
|
|
70
|
+
/** Base directory for the workspace layer (defaults to process.cwd()). */
|
|
74
71
|
baseDir?: string;
|
|
75
72
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* master
|
|
73
|
+
* Direct built-in lookup: used by master's self-application (WS-D7). Reserved names skip user layers and
|
|
74
|
+
* collision checks and always load the bundled manifest. Otherwise, a workspace master.json could cause
|
|
75
|
+
* self-application to fail open and silently lose master's manifest (the boundary caught by d11 live testing).
|
|
79
76
|
*/
|
|
80
77
|
builtinDirect?: boolean;
|
|
81
78
|
}
|
|
82
79
|
|
|
83
|
-
/**
|
|
80
|
+
/** Resolve lookup layers in order: workspace → user-global → bundled (each layer has {label, dir}). */
|
|
84
81
|
export function roleLayers(opts?: { baseDir?: string }): Array<{ label: string; dir: string }> {
|
|
85
82
|
const base = opts?.baseDir && isAbsolute(opts.baseDir) ? opts.baseDir
|
|
86
83
|
: resolve(opts?.baseDir ?? process.cwd());
|
|
@@ -97,7 +94,7 @@ export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManife
|
|
|
97
94
|
}
|
|
98
95
|
const fileName = `${name}.json`;
|
|
99
96
|
|
|
100
|
-
//
|
|
97
|
+
// Legacy single reader: use bundled-layer semantics so existing tests/composer injection paths remain intact.
|
|
101
98
|
if (opts?.read && !opts.layerRead) {
|
|
102
99
|
let text: string;
|
|
103
100
|
try {
|
|
@@ -112,11 +109,11 @@ export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManife
|
|
|
112
109
|
const layerRead = opts?.layerRead ?? defaultLayerRead;
|
|
113
110
|
let layers = roleLayers({ baseDir: opts?.baseDir });
|
|
114
111
|
|
|
115
|
-
//
|
|
112
|
+
// Direct built-in lookup (self-application): inspect only the bundled layer, ignoring user-layer bait/collisions.
|
|
116
113
|
if (opts?.builtinDirect && RESERVED_ROLE_NAMES.includes(name)) {
|
|
117
114
|
layers = [layers[2]];
|
|
118
115
|
} else if (RESERVED_ROLE_NAMES.includes(name)) {
|
|
119
|
-
//
|
|
116
|
+
// Reserved built-in name: reject workspace/user collisions instead of silently overriding built-in semantics.
|
|
120
117
|
for (const layer of layers.slice(0, 2)) {
|
|
121
118
|
if (layerRead(layer.dir, fileName) != null) {
|
|
122
119
|
throw new RoleLoaderError(
|
|
@@ -129,8 +126,8 @@ export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManife
|
|
|
129
126
|
|
|
130
127
|
for (const layer of layers) {
|
|
131
128
|
const text = layerRead(layer.dir, fileName);
|
|
132
|
-
if (text == null) continue; //
|
|
133
|
-
return parseAndValidate(name, fileName, text, layer.label); //
|
|
129
|
+
if (text == null) continue; // No manifest in this layer; try the next layer.
|
|
130
|
+
return parseAndValidate(name, fileName, text, layer.label); // At the hit layer, report errors immediately.
|
|
134
131
|
}
|
|
135
132
|
throw new RoleLoaderError(
|
|
136
133
|
'ROLE_NOT_FOUND',
|