@lawrence369/loop-cli 0.1.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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/dist/agent/activity.d.ts +64 -0
- package/dist/agent/activity.js +265 -0
- package/dist/agent/launcher.d.ts +42 -0
- package/dist/agent/launcher.js +243 -0
- package/dist/agent/pty-session.d.ts +113 -0
- package/dist/agent/pty-session.js +490 -0
- package/dist/agent/ready-detector.d.ts +46 -0
- package/dist/agent/ready-detector.js +86 -0
- package/dist/agent/wrapper.d.ts +18 -0
- package/dist/agent/wrapper.js +110 -0
- package/dist/bin/lclaude.d.ts +3 -0
- package/dist/bin/lclaude.js +7 -0
- package/dist/bin/lcodex.d.ts +3 -0
- package/dist/bin/lcodex.js +7 -0
- package/dist/bin/lgemini.d.ts +3 -0
- package/dist/bin/lgemini.js +7 -0
- package/dist/bus/daemon.d.ts +56 -0
- package/dist/bus/daemon.js +135 -0
- package/dist/bus/event-bus.d.ts +105 -0
- package/dist/bus/event-bus.js +157 -0
- package/dist/bus/message.d.ts +48 -0
- package/dist/bus/message.js +129 -0
- package/dist/bus/queue.d.ts +50 -0
- package/dist/bus/queue.js +100 -0
- package/dist/bus/store.d.ts +88 -0
- package/dist/bus/store.js +212 -0
- package/dist/bus/subscriber.d.ts +76 -0
- package/dist/bus/subscriber.js +187 -0
- package/dist/config/index.d.ts +8 -0
- package/dist/config/index.js +72 -0
- package/dist/config/schema.d.ts +18 -0
- package/dist/config/schema.js +58 -0
- package/dist/core/conversation.d.ts +34 -0
- package/dist/core/conversation.js +289 -0
- package/dist/core/engine.d.ts +40 -0
- package/dist/core/engine.js +288 -0
- package/dist/core/loop.d.ts +33 -0
- package/dist/core/loop.js +209 -0
- package/dist/core/protocol.d.ts +60 -0
- package/dist/core/protocol.js +162 -0
- package/dist/core/scoring.d.ts +34 -0
- package/dist/core/scoring.js +69 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +408 -0
- package/dist/orchestrator/daemon.d.ts +74 -0
- package/dist/orchestrator/daemon.js +294 -0
- package/dist/orchestrator/group.d.ts +73 -0
- package/dist/orchestrator/group.js +166 -0
- package/dist/orchestrator/ipc-server.d.ts +60 -0
- package/dist/orchestrator/ipc-server.js +166 -0
- package/dist/orchestrator/scheduler.d.ts +32 -0
- package/dist/orchestrator/scheduler.js +95 -0
- package/dist/plan/context.d.ts +8 -0
- package/dist/plan/context.js +42 -0
- package/dist/plan/decisions.d.ts +18 -0
- package/dist/plan/decisions.js +143 -0
- package/dist/plan/shared-plan.d.ts +33 -0
- package/dist/plan/shared-plan.js +211 -0
- package/dist/skills/executor.d.ts +7 -0
- package/dist/skills/executor.js +11 -0
- package/dist/skills/loader.d.ts +16 -0
- package/dist/skills/loader.js +80 -0
- package/dist/skills/registry.d.ts +13 -0
- package/dist/skills/registry.js +54 -0
- package/dist/terminal/adapter.d.ts +61 -0
- package/dist/terminal/adapter.js +42 -0
- package/dist/terminal/detect.d.ts +30 -0
- package/dist/terminal/detect.js +77 -0
- package/dist/terminal/iterm2-adapter.d.ts +19 -0
- package/dist/terminal/iterm2-adapter.js +120 -0
- package/dist/terminal/pty-adapter.d.ts +18 -0
- package/dist/terminal/pty-adapter.js +84 -0
- package/dist/terminal/terminal-adapter.d.ts +17 -0
- package/dist/terminal/terminal-adapter.js +94 -0
- package/dist/terminal/tmux-adapter.d.ts +18 -0
- package/dist/terminal/tmux-adapter.js +127 -0
- package/dist/ui/banner.d.ts +3 -0
- package/dist/ui/banner.js +145 -0
- package/dist/ui/colors.d.ts +41 -0
- package/dist/ui/colors.js +65 -0
- package/dist/ui/dashboard.d.ts +32 -0
- package/dist/ui/dashboard.js +138 -0
- package/dist/ui/input.d.ts +10 -0
- package/dist/ui/input.js +96 -0
- package/dist/ui/interactive.d.ts +13 -0
- package/dist/ui/interactive.js +230 -0
- package/dist/ui/renderer.d.ts +33 -0
- package/dist/ui/renderer.js +106 -0
- package/dist/utils/ansi.d.ts +11 -0
- package/dist/utils/ansi.js +16 -0
- package/dist/utils/fs.d.ts +34 -0
- package/dist/utils/fs.js +115 -0
- package/dist/utils/lock.d.ts +12 -0
- package/dist/utils/lock.js +116 -0
- package/dist/utils/process.d.ts +31 -0
- package/dist/utils/process.js +111 -0
- package/dist/utils/pty-filter.d.ts +31 -0
- package/dist/utils/pty-filter.js +187 -0
- package/package.json +71 -0
- package/skills/loop/SKILL.md +19 -0
- package/skills/plan/SKILL.md +9 -0
- package/skills/review/SKILL.md +14 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
import { unlinkSync } from "node:fs";
|
|
3
|
+
/**
|
|
4
|
+
* Unix domain socket IPC server.
|
|
5
|
+
*
|
|
6
|
+
* Protocol: newline-delimited JSON (each message is a JSON object
|
|
7
|
+
* followed by a newline character).
|
|
8
|
+
*/
|
|
9
|
+
export class IpcServer {
|
|
10
|
+
socketPath;
|
|
11
|
+
handler;
|
|
12
|
+
server = null;
|
|
13
|
+
sockets = new Set();
|
|
14
|
+
constructor(socketPath, handler) {
|
|
15
|
+
this.socketPath = socketPath;
|
|
16
|
+
this.handler = handler;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Start listening on the Unix socket.
|
|
20
|
+
*/
|
|
21
|
+
async start() {
|
|
22
|
+
// Remove stale socket file if it exists
|
|
23
|
+
try {
|
|
24
|
+
unlinkSync(this.socketPath);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Ignore - file may not exist
|
|
28
|
+
}
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
this.server = createServer((socket) => this.handleConnection(socket));
|
|
31
|
+
this.server.on("error", (err) => {
|
|
32
|
+
reject(err);
|
|
33
|
+
});
|
|
34
|
+
this.server.listen(this.socketPath, () => {
|
|
35
|
+
resolve();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Stop the IPC server and close all connections.
|
|
41
|
+
*/
|
|
42
|
+
async stop() {
|
|
43
|
+
// Close all client sockets
|
|
44
|
+
for (const socket of this.sockets) {
|
|
45
|
+
try {
|
|
46
|
+
socket.destroy();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Ignore
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
this.sockets.clear();
|
|
53
|
+
// Close the server
|
|
54
|
+
if (this.server) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
this.server.close(() => {
|
|
57
|
+
// Remove socket file
|
|
58
|
+
try {
|
|
59
|
+
unlinkSync(this.socketPath);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Ignore
|
|
63
|
+
}
|
|
64
|
+
resolve();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// Remove socket file even if server wasn't created
|
|
69
|
+
try {
|
|
70
|
+
unlinkSync(this.socketPath);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Ignore
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Send a message to all connected clients.
|
|
78
|
+
*/
|
|
79
|
+
broadcast(payload) {
|
|
80
|
+
const line = JSON.stringify(payload) + "\n";
|
|
81
|
+
for (const socket of this.sockets) {
|
|
82
|
+
if (socket.destroyed)
|
|
83
|
+
continue;
|
|
84
|
+
try {
|
|
85
|
+
socket.write(line);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Ignore write errors
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Get the number of connected clients.
|
|
94
|
+
*/
|
|
95
|
+
get clientCount() {
|
|
96
|
+
return this.sockets.size;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Handle a new socket connection.
|
|
100
|
+
*/
|
|
101
|
+
handleConnection(socket) {
|
|
102
|
+
this.sockets.add(socket);
|
|
103
|
+
let buffer = "";
|
|
104
|
+
socket.on("data", (data) => {
|
|
105
|
+
buffer += data.toString("utf8");
|
|
106
|
+
// Process complete lines
|
|
107
|
+
const lines = buffer.split("\n");
|
|
108
|
+
buffer = lines.pop() ?? ""; // Keep incomplete last line in buffer
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const trimmed = line.trim();
|
|
111
|
+
if (!trimmed)
|
|
112
|
+
continue;
|
|
113
|
+
this.processLine(trimmed, socket).catch((err) => {
|
|
114
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
115
|
+
const errorResponse = {
|
|
116
|
+
success: false,
|
|
117
|
+
type: "ERROR",
|
|
118
|
+
error: message,
|
|
119
|
+
};
|
|
120
|
+
try {
|
|
121
|
+
socket.write(JSON.stringify(errorResponse) + "\n");
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Ignore write errors
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
socket.on("close", () => {
|
|
130
|
+
this.sockets.delete(socket);
|
|
131
|
+
});
|
|
132
|
+
socket.on("error", () => {
|
|
133
|
+
this.sockets.delete(socket);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Process a single JSON line from a client.
|
|
138
|
+
*/
|
|
139
|
+
async processLine(line, socket) {
|
|
140
|
+
let req;
|
|
141
|
+
try {
|
|
142
|
+
req = JSON.parse(line);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
const errorResponse = {
|
|
146
|
+
success: false,
|
|
147
|
+
type: "ERROR",
|
|
148
|
+
error: "Invalid JSON",
|
|
149
|
+
};
|
|
150
|
+
socket.write(JSON.stringify(errorResponse) + "\n");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (!req || typeof req !== "object" || !req.type) {
|
|
154
|
+
const errorResponse = {
|
|
155
|
+
success: false,
|
|
156
|
+
type: "ERROR",
|
|
157
|
+
error: "Missing request type",
|
|
158
|
+
};
|
|
159
|
+
socket.write(JSON.stringify(errorResponse) + "\n");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const response = await this.handler(req);
|
|
163
|
+
socket.write(JSON.stringify(response) + "\n");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
//# sourceMappingURL=ipc-server.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { SubscriberManager } from "../bus/subscriber.js";
|
|
2
|
+
/**
|
|
3
|
+
* Simple activity-aware task scheduler.
|
|
4
|
+
*
|
|
5
|
+
* Routes tasks to idle agents, preferring agents that have been idle
|
|
6
|
+
* the longest. Falls back to the least-recently-active agent if none
|
|
7
|
+
* are idle.
|
|
8
|
+
*/
|
|
9
|
+
export declare class Scheduler {
|
|
10
|
+
private readonly subscriberManager;
|
|
11
|
+
constructor(subscriberManager: SubscriberManager);
|
|
12
|
+
/**
|
|
13
|
+
* Assign a task to the best available agent.
|
|
14
|
+
*
|
|
15
|
+
* @param task - Description of the task (for logging; not persisted here)
|
|
16
|
+
* @param preferredAgent - Optional subscriber ID or agent type to prefer
|
|
17
|
+
* @returns The subscriber ID of the assigned agent, or null if none available
|
|
18
|
+
*/
|
|
19
|
+
assignTask(_task: string, preferredAgent?: string): Promise<string | null>;
|
|
20
|
+
/**
|
|
21
|
+
* Find the best idle agent to route work to.
|
|
22
|
+
*
|
|
23
|
+
* @returns The subscriber ID of an idle agent, or null if none available
|
|
24
|
+
*/
|
|
25
|
+
routeToIdle(): Promise<string | null>;
|
|
26
|
+
/**
|
|
27
|
+
* From a list of active agents, pick the best one to receive work.
|
|
28
|
+
* Prefers idle agents; among idle agents, picks the one idle longest.
|
|
29
|
+
*/
|
|
30
|
+
private routeToIdleFromList;
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=scheduler.d.ts.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple activity-aware task scheduler.
|
|
3
|
+
*
|
|
4
|
+
* Routes tasks to idle agents, preferring agents that have been idle
|
|
5
|
+
* the longest. Falls back to the least-recently-active agent if none
|
|
6
|
+
* are idle.
|
|
7
|
+
*/
|
|
8
|
+
export class Scheduler {
|
|
9
|
+
subscriberManager;
|
|
10
|
+
constructor(subscriberManager) {
|
|
11
|
+
this.subscriberManager = subscriberManager;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Assign a task to the best available agent.
|
|
15
|
+
*
|
|
16
|
+
* @param task - Description of the task (for logging; not persisted here)
|
|
17
|
+
* @param preferredAgent - Optional subscriber ID or agent type to prefer
|
|
18
|
+
* @returns The subscriber ID of the assigned agent, or null if none available
|
|
19
|
+
*/
|
|
20
|
+
async assignTask(_task, preferredAgent) {
|
|
21
|
+
const agents = await this.subscriberManager.list();
|
|
22
|
+
// Filter to active agents only
|
|
23
|
+
const active = [];
|
|
24
|
+
for (const [id, meta] of agents) {
|
|
25
|
+
if (meta.status === "active") {
|
|
26
|
+
active.push([id, meta]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (active.length === 0)
|
|
30
|
+
return null;
|
|
31
|
+
// If a preferred agent is specified, check it first
|
|
32
|
+
if (preferredAgent) {
|
|
33
|
+
// Try exact match
|
|
34
|
+
const exact = active.find(([id]) => id === preferredAgent);
|
|
35
|
+
if (exact)
|
|
36
|
+
return exact[0];
|
|
37
|
+
// Try nickname match
|
|
38
|
+
const byNickname = active.find(([, meta]) => meta.nickname === preferredAgent);
|
|
39
|
+
if (byNickname)
|
|
40
|
+
return byNickname[0];
|
|
41
|
+
// Try agent type match (pick idle one of that type)
|
|
42
|
+
const byType = active.filter(([, meta]) => meta.agent_type === preferredAgent);
|
|
43
|
+
if (byType.length > 0) {
|
|
44
|
+
const idle = byType.find(([, meta]) => meta.activity_state === "idle");
|
|
45
|
+
if (idle)
|
|
46
|
+
return idle[0];
|
|
47
|
+
return byType[0][0]; // Fallback to first of type
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Prefer idle agents
|
|
51
|
+
return this.routeToIdleFromList(active);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Find the best idle agent to route work to.
|
|
55
|
+
*
|
|
56
|
+
* @returns The subscriber ID of an idle agent, or null if none available
|
|
57
|
+
*/
|
|
58
|
+
async routeToIdle() {
|
|
59
|
+
const agents = await this.subscriberManager.list();
|
|
60
|
+
const active = [];
|
|
61
|
+
for (const [id, meta] of agents) {
|
|
62
|
+
if (meta.status === "active") {
|
|
63
|
+
active.push([id, meta]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return this.routeToIdleFromList(active);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* From a list of active agents, pick the best one to receive work.
|
|
70
|
+
* Prefers idle agents; among idle agents, picks the one idle longest.
|
|
71
|
+
*/
|
|
72
|
+
routeToIdleFromList(active) {
|
|
73
|
+
if (active.length === 0)
|
|
74
|
+
return null;
|
|
75
|
+
// Separate idle from working
|
|
76
|
+
const idle = active.filter(([, meta]) => meta.activity_state === "idle");
|
|
77
|
+
if (idle.length > 0) {
|
|
78
|
+
// Pick the agent that has been idle the longest (oldest last_activity)
|
|
79
|
+
idle.sort((a, b) => {
|
|
80
|
+
const aTime = a[1].last_activity ?? a[1].last_seen;
|
|
81
|
+
const bTime = b[1].last_activity ?? b[1].last_seen;
|
|
82
|
+
return (aTime ?? "").localeCompare(bTime ?? "");
|
|
83
|
+
});
|
|
84
|
+
return idle[0][0];
|
|
85
|
+
}
|
|
86
|
+
// No idle agents - pick least recently active (might finish soonest)
|
|
87
|
+
const sorted = [...active].sort((a, b) => {
|
|
88
|
+
const aTime = a[1].last_activity ?? a[1].last_seen;
|
|
89
|
+
const bTime = b[1].last_activity ?? b[1].last_seen;
|
|
90
|
+
return (aTime ?? "").localeCompare(bTime ?? "");
|
|
91
|
+
});
|
|
92
|
+
return sorted[0][0];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=scheduler.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a unified context string combining:
|
|
3
|
+
* - Last iteration score/feedback from the shared plan
|
|
4
|
+
* - Active decisions
|
|
5
|
+
* - File change log
|
|
6
|
+
*/
|
|
7
|
+
export declare function buildContext(cwd: string): Promise<string>;
|
|
8
|
+
//# sourceMappingURL=context.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getExecutorContext } from "./shared-plan.js";
|
|
2
|
+
import { listDecisions } from "./decisions.js";
|
|
3
|
+
/**
|
|
4
|
+
* Build a unified context string combining:
|
|
5
|
+
* - Last iteration score/feedback from the shared plan
|
|
6
|
+
* - Active decisions
|
|
7
|
+
* - File change log
|
|
8
|
+
*/
|
|
9
|
+
export async function buildContext(cwd) {
|
|
10
|
+
const sections = [];
|
|
11
|
+
// 1. Last iteration context from shared plan
|
|
12
|
+
const iterationContext = await getExecutorContext(cwd);
|
|
13
|
+
if (iterationContext) {
|
|
14
|
+
sections.push(iterationContext);
|
|
15
|
+
}
|
|
16
|
+
// 2. Active decisions
|
|
17
|
+
try {
|
|
18
|
+
const decisions = await listDecisions(cwd);
|
|
19
|
+
const active = decisions.filter((d) => d.status === "proposed" || d.status === "accepted");
|
|
20
|
+
if (active.length > 0) {
|
|
21
|
+
const decisionLines = [
|
|
22
|
+
"## Active Decisions",
|
|
23
|
+
"",
|
|
24
|
+
];
|
|
25
|
+
for (const d of active) {
|
|
26
|
+
decisionLines.push(`- **[${d.status.toUpperCase()}] #${d.id}: ${d.title}**`);
|
|
27
|
+
if (d.decision) {
|
|
28
|
+
decisionLines.push(` ${d.decision}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
sections.push(decisionLines.join("\n"));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// No decisions directory — skip
|
|
36
|
+
}
|
|
37
|
+
if (sections.length === 0) {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
return sections.join("\n\n");
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=context.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface Decision {
|
|
2
|
+
id: number;
|
|
3
|
+
title: string;
|
|
4
|
+
status: "proposed" | "accepted" | "rejected" | "superseded";
|
|
5
|
+
context: string;
|
|
6
|
+
decision: string;
|
|
7
|
+
consequences: string;
|
|
8
|
+
date: string;
|
|
9
|
+
}
|
|
10
|
+
type DecisionStatus = Decision["status"];
|
|
11
|
+
/** Add a new decision to the decisions directory. */
|
|
12
|
+
export declare function addDecision(cwd: string, decision: Omit<Decision, "id" | "date">): Promise<Decision>;
|
|
13
|
+
/** List all decisions from the decisions directory. */
|
|
14
|
+
export declare function listDecisions(cwd: string): Promise<Decision[]>;
|
|
15
|
+
/** Resolve (update status of) a decision by ID. */
|
|
16
|
+
export declare function resolveDecision(cwd: string, id: number, status: DecisionStatus): Promise<void>;
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=decisions.d.ts.map
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
// ── Helpers ─────────────────────────────────────────
|
|
5
|
+
function decisionsDir(cwd) {
|
|
6
|
+
return join(cwd, ".loop", "context", "decisions");
|
|
7
|
+
}
|
|
8
|
+
function slugify(title) {
|
|
9
|
+
const cleaned = title
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
12
|
+
.replace(/^-+|-+$/g, "")
|
|
13
|
+
.replace(/-+/g, "-");
|
|
14
|
+
return cleaned || "decision";
|
|
15
|
+
}
|
|
16
|
+
function nextNumber(dir) {
|
|
17
|
+
if (!existsSync(dir))
|
|
18
|
+
return 1;
|
|
19
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".md"));
|
|
20
|
+
const numbers = files.map((f) => {
|
|
21
|
+
const match = f.match(/^(\d{4})-/);
|
|
22
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
23
|
+
});
|
|
24
|
+
return numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
|
|
25
|
+
}
|
|
26
|
+
function padId(id) {
|
|
27
|
+
return String(id).padStart(4, "0");
|
|
28
|
+
}
|
|
29
|
+
function isValidStatus(s) {
|
|
30
|
+
return s === "proposed" || s === "accepted" || s === "rejected" || s === "superseded";
|
|
31
|
+
}
|
|
32
|
+
function parseDecisionFile(filePath) {
|
|
33
|
+
try {
|
|
34
|
+
const content = readFileSync(filePath, "utf-8");
|
|
35
|
+
const parsed = matter(content);
|
|
36
|
+
const data = parsed.data;
|
|
37
|
+
const body = parsed.content;
|
|
38
|
+
// Extract ID from filename
|
|
39
|
+
const filename = filePath.split("/").pop() ?? "";
|
|
40
|
+
const idMatch = filename.match(/^(\d{4})-/);
|
|
41
|
+
const id = idMatch ? parseInt(idMatch[1], 10) : 0;
|
|
42
|
+
// Extract title from first heading
|
|
43
|
+
let title = "";
|
|
44
|
+
const titleMatch = body.match(/^#\s+(.+)$/m);
|
|
45
|
+
if (titleMatch) {
|
|
46
|
+
title = titleMatch[1].replace(/^DECISION\s+\d+:\s*/i, "").trim();
|
|
47
|
+
}
|
|
48
|
+
// Extract sections from body
|
|
49
|
+
const contextMatch = body.match(/Context:\n([\s\S]*?)(?=\nDecision:|\n#|$)/i);
|
|
50
|
+
const decisionMatch = body.match(/Decision:\n([\s\S]*?)(?=\nConsequences:|\nImplications:|\n#|$)/i);
|
|
51
|
+
const consequencesMatch = body.match(/(?:Consequences|Implications):\n([\s\S]*?)(?=\n#|$)/i);
|
|
52
|
+
const status = isValidStatus(data.status) ? data.status : "proposed";
|
|
53
|
+
const date = typeof data.date === "string"
|
|
54
|
+
? data.date
|
|
55
|
+
: (body.match(/Date:\s*(.+)/)?.[1]?.trim() ?? new Date().toISOString().slice(0, 10));
|
|
56
|
+
return {
|
|
57
|
+
id,
|
|
58
|
+
title: title || (typeof data.title === "string" ? data.title : "(no title)"),
|
|
59
|
+
status,
|
|
60
|
+
context: contextMatch?.[1]?.trim() ?? "",
|
|
61
|
+
decision: decisionMatch?.[1]?.trim() ?? "",
|
|
62
|
+
consequences: consequencesMatch?.[1]?.trim() ?? "",
|
|
63
|
+
date,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// ── Public API ──────────────────────────────────────
|
|
71
|
+
/** Add a new decision to the decisions directory. */
|
|
72
|
+
export async function addDecision(cwd, decision) {
|
|
73
|
+
const dir = decisionsDir(cwd);
|
|
74
|
+
mkdirSync(dir, { recursive: true });
|
|
75
|
+
const id = nextNumber(dir);
|
|
76
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
77
|
+
const slug = slugify(decision.title);
|
|
78
|
+
const filename = `${padId(id)}-${slug}.md`;
|
|
79
|
+
const filePath = join(dir, filename);
|
|
80
|
+
// Path traversal guard: ensure file stays within decisions dir
|
|
81
|
+
if (!resolve(filePath).startsWith(resolve(dir))) {
|
|
82
|
+
throw new Error("Invalid decision title: path traversal detected");
|
|
83
|
+
}
|
|
84
|
+
const content = `---\n` +
|
|
85
|
+
`status: ${decision.status}\n` +
|
|
86
|
+
`date: ${date}\n` +
|
|
87
|
+
`---\n` +
|
|
88
|
+
`# DECISION ${padId(id)}: ${decision.title}\n\n` +
|
|
89
|
+
`Date: ${date}\n\n` +
|
|
90
|
+
`Context:\n${decision.context || "What led to this decision?"}\n\n` +
|
|
91
|
+
`Decision:\n${decision.decision || "What is now considered true?"}\n\n` +
|
|
92
|
+
`Consequences:\n${decision.consequences || "What must follow from this?"}\n`;
|
|
93
|
+
writeFileSync(filePath, content, "utf-8");
|
|
94
|
+
return {
|
|
95
|
+
id,
|
|
96
|
+
title: decision.title,
|
|
97
|
+
status: decision.status,
|
|
98
|
+
context: decision.context,
|
|
99
|
+
decision: decision.decision,
|
|
100
|
+
consequences: decision.consequences,
|
|
101
|
+
date,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** List all decisions from the decisions directory. */
|
|
105
|
+
export async function listDecisions(cwd) {
|
|
106
|
+
const dir = decisionsDir(cwd);
|
|
107
|
+
if (!existsSync(dir))
|
|
108
|
+
return [];
|
|
109
|
+
const files = readdirSync(dir)
|
|
110
|
+
.filter((f) => f.endsWith(".md"))
|
|
111
|
+
.sort();
|
|
112
|
+
const decisions = [];
|
|
113
|
+
for (const file of files) {
|
|
114
|
+
const filePath = join(dir, file);
|
|
115
|
+
const decision = parseDecisionFile(filePath);
|
|
116
|
+
if (decision) {
|
|
117
|
+
decisions.push(decision);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return decisions;
|
|
121
|
+
}
|
|
122
|
+
/** Resolve (update status of) a decision by ID. */
|
|
123
|
+
export async function resolveDecision(cwd, id, status) {
|
|
124
|
+
const dir = decisionsDir(cwd);
|
|
125
|
+
if (!existsSync(dir)) {
|
|
126
|
+
throw new Error(`Decisions directory not found: ${dir}`);
|
|
127
|
+
}
|
|
128
|
+
const prefix = padId(id);
|
|
129
|
+
const files = readdirSync(dir).filter((f) => f.startsWith(prefix) && f.endsWith(".md"));
|
|
130
|
+
if (files.length === 0) {
|
|
131
|
+
throw new Error(`Decision ${id} not found`);
|
|
132
|
+
}
|
|
133
|
+
const filePath = join(dir, files[0]);
|
|
134
|
+
const content = readFileSync(filePath, "utf-8");
|
|
135
|
+
const parsed = matter(content);
|
|
136
|
+
// Update status in frontmatter
|
|
137
|
+
const data = parsed.data;
|
|
138
|
+
data.status = status;
|
|
139
|
+
data.resolved_at = new Date().toISOString();
|
|
140
|
+
const updated = matter.stringify(parsed.content, data);
|
|
141
|
+
writeFileSync(filePath, updated, "utf-8");
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=decisions.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface SharedPlan {
|
|
2
|
+
task: string;
|
|
3
|
+
iterations: IterationRecord[];
|
|
4
|
+
fileChangeLog: FileChange[];
|
|
5
|
+
}
|
|
6
|
+
export interface IterationRecord {
|
|
7
|
+
iteration: number;
|
|
8
|
+
executor: string;
|
|
9
|
+
reviewer: string;
|
|
10
|
+
score: number;
|
|
11
|
+
approved: boolean;
|
|
12
|
+
executorSummary: string;
|
|
13
|
+
reviewerFeedback: string;
|
|
14
|
+
timestamp: string;
|
|
15
|
+
}
|
|
16
|
+
export interface FileChange {
|
|
17
|
+
iteration: number;
|
|
18
|
+
file: string;
|
|
19
|
+
action: "created" | "modified" | "deleted";
|
|
20
|
+
}
|
|
21
|
+
/** Initialize a new shared plan file. */
|
|
22
|
+
export declare function initSharedPlan(cwd: string, task: string): Promise<void>;
|
|
23
|
+
/** Update the shared plan with a new iteration record. */
|
|
24
|
+
export declare function updateSharedPlan(cwd: string, record: IterationRecord, filesChanged: string[]): Promise<void>;
|
|
25
|
+
/** Generate a context snippet from the shared plan for the executor prompt. */
|
|
26
|
+
export declare function getExecutorContext(cwd: string): Promise<string>;
|
|
27
|
+
/** Generate a context snippet for the reviewer prompt. */
|
|
28
|
+
export declare function getReviewerContext(cwd: string): Promise<string>;
|
|
29
|
+
/** Clear the shared plan file. */
|
|
30
|
+
export declare function clearPlan(cwd: string): Promise<void>;
|
|
31
|
+
/** Show the raw plan content. */
|
|
32
|
+
export declare function showPlan(cwd: string): Promise<string>;
|
|
33
|
+
//# sourceMappingURL=shared-plan.d.ts.map
|