@pugi/cli 0.1.0-alpha.3
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/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/run.js +2 -0
- package/dist/core/credentials.js +355 -0
- package/dist/core/engine/adapter-runner.js +8 -0
- package/dist/core/engine/anvil-client.js +156 -0
- package/dist/core/engine/index.js +7 -0
- package/dist/core/engine/native-pugi.js +369 -0
- package/dist/core/engine/noop.js +27 -0
- package/dist/core/engine/prompts.js +76 -0
- package/dist/core/engine/tool-bridge.js +215 -0
- package/dist/core/file-cache.js +29 -0
- package/dist/core/index-store.js +260 -0
- package/dist/core/path-security.js +63 -0
- package/dist/core/permission.js +204 -0
- package/dist/core/session.js +90 -0
- package/dist/core/settings.js +46 -0
- package/dist/index.js +8 -0
- package/dist/runtime/cli.js +2935 -0
- package/dist/tools/file-tools.js +346 -0
- package/dist/tools/registry.js +24 -0
- package/package.json +58 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { bashTool, editTool, globTool, grepTool, readTool, writeTool, } from '../../tools/file-tools.js';
|
|
2
|
+
/**
|
|
3
|
+
* Tool-bridge: turns the abstract tool registry into:
|
|
4
|
+
* 1. An OpenAI-shaped tools schema for `EngineLoopClient.send`.
|
|
5
|
+
* 2. A single executor callback that dispatches each tool_call to the
|
|
6
|
+
* concrete `file-tools.ts` handler under workspace permissions.
|
|
7
|
+
*
|
|
8
|
+
* The bridge enforces two CLI-side invariants that the runtime cannot:
|
|
9
|
+
* - Plan-mode refusal. When `kind === 'plan'`, the executor refuses
|
|
10
|
+
* write/edit/bash by throwing `PLAN_MODE_REFUSED:<tool>` (sentinel
|
|
11
|
+
* recognised by `runEngineLoop` to terminate with status
|
|
12
|
+
* `tool_refused`). The schema also omits the mutating tools so the
|
|
13
|
+
* model is unlikely to attempt them in the first place.
|
|
14
|
+
* - Argument validation. Each call's `arguments` string is JSON-parsed
|
|
15
|
+
* and shape-checked here; bad JSON or missing fields are surfaced
|
|
16
|
+
* to the model as a tool error string so it can correct itself.
|
|
17
|
+
*
|
|
18
|
+
* The bridge does NOT touch session.ts directly — `file-tools.ts`
|
|
19
|
+
* already records every call. The engine adapter wires a hook layer on
|
|
20
|
+
* top that surfaces tool events into the engine's status stream.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Read-only subset surfaced to plan-mode. Mutating tools (write, edit,
|
|
24
|
+
* bash) are intentionally absent so the model rarely tries them.
|
|
25
|
+
*/
|
|
26
|
+
const READ_ONLY_TOOLS = new Set(['read', 'grep', 'glob']);
|
|
27
|
+
/**
|
|
28
|
+
* Tools we actually wire today. The registry has more entries
|
|
29
|
+
* (task_*, skill, question) — those route through the runtime layer, not
|
|
30
|
+
* the local filesystem, so they ship in a follow-up PR. M1 cornerstone is
|
|
31
|
+
* the six core tools.
|
|
32
|
+
*/
|
|
33
|
+
const WIRED_TOOLS = new Set(['read', 'write', 'edit', 'grep', 'glob', 'bash']);
|
|
34
|
+
export function buildToolsSchema(kind) {
|
|
35
|
+
const planMode = kind === 'plan';
|
|
36
|
+
const toolDefs = [
|
|
37
|
+
{
|
|
38
|
+
name: 'read',
|
|
39
|
+
description: 'Read the contents of a workspace file. Required before edit on a file. Returns the full UTF-8 text. Workspace-scoped: paths must be relative to the workspace root.',
|
|
40
|
+
parameters: {
|
|
41
|
+
type: 'object',
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
required: ['path'],
|
|
44
|
+
properties: {
|
|
45
|
+
path: { type: 'string', description: 'Workspace-relative file path.' },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'grep',
|
|
51
|
+
description: 'Substring-match every workspace file. Returns up to 200 matches with {path, line, text}. Use this to locate code by symbol/keyword.',
|
|
52
|
+
parameters: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
additionalProperties: false,
|
|
55
|
+
required: ['query'],
|
|
56
|
+
properties: {
|
|
57
|
+
query: { type: 'string', description: 'Substring to search for.' },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'glob',
|
|
63
|
+
description: 'List files matching a glob pattern (workspace-scoped, node_modules / dist / .git / .pugi excluded). Up to 500 paths.',
|
|
64
|
+
parameters: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
required: ['pattern'],
|
|
68
|
+
properties: {
|
|
69
|
+
pattern: { type: 'string', description: 'Glob pattern, e.g. "src/**/*.ts".' },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
if (!planMode) {
|
|
75
|
+
toolDefs.push({
|
|
76
|
+
name: 'write',
|
|
77
|
+
description: 'Create or overwrite a workspace file. Use for new files only — prefer edit for existing files. Workspace-scoped.',
|
|
78
|
+
parameters: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
additionalProperties: false,
|
|
81
|
+
required: ['path', 'content'],
|
|
82
|
+
properties: {
|
|
83
|
+
path: { type: 'string', description: 'Workspace-relative file path.' },
|
|
84
|
+
content: { type: 'string', description: 'Full new file contents (UTF-8).' },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
}, {
|
|
88
|
+
name: 'edit',
|
|
89
|
+
description: 'Replace exactly one occurrence of oldString with newString inside an already-read file. Fails if the file changed since you read it or if oldString is missing/duplicate.',
|
|
90
|
+
parameters: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
additionalProperties: false,
|
|
93
|
+
required: ['path', 'oldString', 'newString'],
|
|
94
|
+
properties: {
|
|
95
|
+
path: { type: 'string' },
|
|
96
|
+
oldString: { type: 'string' },
|
|
97
|
+
newString: { type: 'string' },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
}, {
|
|
101
|
+
name: 'bash',
|
|
102
|
+
description: 'Run a shell command inside the workspace root via /bin/sh -c. Inherits a sanitized env (PUGI_API_KEY/PUGI_LOGIN_TOKEN stripped). 30s timeout. Output capped at 64KB. Returns {exitCode, stdout, stderr, truncated}.',
|
|
103
|
+
parameters: {
|
|
104
|
+
type: 'object',
|
|
105
|
+
additionalProperties: false,
|
|
106
|
+
required: ['command'],
|
|
107
|
+
properties: {
|
|
108
|
+
command: { type: 'string', description: 'Single shell command to execute.' },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return toolDefs;
|
|
114
|
+
}
|
|
115
|
+
function parseArgs(raw) {
|
|
116
|
+
if (!raw || raw.trim() === '')
|
|
117
|
+
return {};
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(raw);
|
|
120
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
121
|
+
throw new Error('tool arguments must be a JSON object');
|
|
122
|
+
}
|
|
123
|
+
return parsed;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
throw new Error(`invalid JSON in tool arguments: ${error.message}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function requireString(obj, key) {
|
|
130
|
+
const v = obj[key];
|
|
131
|
+
if (typeof v !== 'string') {
|
|
132
|
+
throw new Error(`tool argument "${key}" must be a string`);
|
|
133
|
+
}
|
|
134
|
+
return v;
|
|
135
|
+
}
|
|
136
|
+
export function buildExecutor(input) {
|
|
137
|
+
const { kind, ctx } = input;
|
|
138
|
+
const planMode = kind === 'plan';
|
|
139
|
+
return async ({ name, arguments: argsRaw }) => {
|
|
140
|
+
if (!WIRED_TOOLS.has(name)) {
|
|
141
|
+
throw new Error(`unknown tool: ${name}`);
|
|
142
|
+
}
|
|
143
|
+
if (planMode && !READ_ONLY_TOOLS.has(name)) {
|
|
144
|
+
// Sentinel recognised by `runEngineLoop` — terminates the loop
|
|
145
|
+
// with status `tool_refused`. The CLI surfaces this as a blocked
|
|
146
|
+
// outcome, not a failure, because plan mode is doing its job.
|
|
147
|
+
throw new Error(`PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
|
|
148
|
+
}
|
|
149
|
+
const args = parseArgs(argsRaw);
|
|
150
|
+
switch (name) {
|
|
151
|
+
case 'read': {
|
|
152
|
+
const { path } = { path: requireString(args, 'path') };
|
|
153
|
+
const content = readTool(ctx, path);
|
|
154
|
+
// Cap the content surfaced back to the model so a 10MB file
|
|
155
|
+
// does not blow the context window. The model sees the head
|
|
156
|
+
// and a truncation marker; if it needs more it can grep.
|
|
157
|
+
const CAP = 32 * 1024;
|
|
158
|
+
if (content.length > CAP) {
|
|
159
|
+
return `${content.slice(0, CAP)}\n(...truncated at ${CAP} bytes; use grep or glob to narrow the read)`;
|
|
160
|
+
}
|
|
161
|
+
return content;
|
|
162
|
+
}
|
|
163
|
+
case 'write': {
|
|
164
|
+
const wargs = {
|
|
165
|
+
path: requireString(args, 'path'),
|
|
166
|
+
content: requireString(args, 'content'),
|
|
167
|
+
};
|
|
168
|
+
writeTool(ctx, wargs.path, wargs.content);
|
|
169
|
+
return `wrote ${wargs.path} (${wargs.content.length} bytes)`;
|
|
170
|
+
}
|
|
171
|
+
case 'edit': {
|
|
172
|
+
const eargs = {
|
|
173
|
+
path: requireString(args, 'path'),
|
|
174
|
+
oldString: requireString(args, 'oldString'),
|
|
175
|
+
newString: requireString(args, 'newString'),
|
|
176
|
+
};
|
|
177
|
+
editTool(ctx, eargs.path, eargs.oldString, eargs.newString);
|
|
178
|
+
return `edited ${eargs.path}`;
|
|
179
|
+
}
|
|
180
|
+
case 'grep': {
|
|
181
|
+
const gargs = { query: requireString(args, 'query') };
|
|
182
|
+
const matches = grepTool(ctx, gargs.query);
|
|
183
|
+
if (matches.length === 0)
|
|
184
|
+
return `no matches for ${gargs.query}`;
|
|
185
|
+
const head = matches.slice(0, 50);
|
|
186
|
+
const rendered = head.map((m) => `${m.path}:${m.line}: ${m.text}`).join('\n');
|
|
187
|
+
const more = matches.length > head.length ? `\n(... ${matches.length - head.length} more)` : '';
|
|
188
|
+
return `${matches.length} match(es):\n${rendered}${more}`;
|
|
189
|
+
}
|
|
190
|
+
case 'glob': {
|
|
191
|
+
const gargs = { pattern: requireString(args, 'pattern') };
|
|
192
|
+
const results = globTool(ctx, gargs.pattern);
|
|
193
|
+
if (results.length === 0)
|
|
194
|
+
return `no paths match ${gargs.pattern}`;
|
|
195
|
+
return `${results.length} path(s):\n${results.slice(0, 100).join('\n')}${results.length > 100 ? `\n(... ${results.length - 100} more)` : ''}`;
|
|
196
|
+
}
|
|
197
|
+
case 'bash': {
|
|
198
|
+
const bargs = { command: requireString(args, 'command') };
|
|
199
|
+
const result = bashTool(ctx, bargs.command);
|
|
200
|
+
const body = [
|
|
201
|
+
`exit=${result.exitCode}`,
|
|
202
|
+
result.stdout ? `stdout:\n${result.stdout}` : '',
|
|
203
|
+
result.stderr ? `stderr:\n${result.stderr}` : '',
|
|
204
|
+
]
|
|
205
|
+
.filter(Boolean)
|
|
206
|
+
.join('\n');
|
|
207
|
+
return body || '(no output)';
|
|
208
|
+
}
|
|
209
|
+
default:
|
|
210
|
+
// Exhaustive; unreachable because of the WIRED_TOOLS guard above.
|
|
211
|
+
throw new Error(`unhandled tool: ${name}`);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
//# sourceMappingURL=tool-bridge.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
export class FileReadCache {
|
|
5
|
+
records = new Map();
|
|
6
|
+
set(record) {
|
|
7
|
+
this.records.set(record.resolvedPath, record);
|
|
8
|
+
}
|
|
9
|
+
get(root, path) {
|
|
10
|
+
return this.records.get(resolve(root, path));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function hashContent(content) {
|
|
14
|
+
return createHash('sha256').update(content).digest('hex');
|
|
15
|
+
}
|
|
16
|
+
export function createReadRecord(root, path, content, source) {
|
|
17
|
+
const resolvedPath = resolve(root, path);
|
|
18
|
+
const stat = statSync(resolvedPath);
|
|
19
|
+
return {
|
|
20
|
+
path,
|
|
21
|
+
resolvedPath,
|
|
22
|
+
sha256: hashContent(content),
|
|
23
|
+
sizeBytes: stat.size,
|
|
24
|
+
mtimeMs: stat.mtimeMs,
|
|
25
|
+
readAt: new Date().toISOString(),
|
|
26
|
+
source,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=file-cache.js.map
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { resolve, relative } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { auditEventSchema } from '@pugi/sdk';
|
|
5
|
+
/**
|
|
6
|
+
* `.pugi/index.json` — materialized session + artifact index.
|
|
7
|
+
*
|
|
8
|
+
* The append-only event log at `.pugi/events.jsonl` is the source of truth.
|
|
9
|
+
* `index.json` is a cached projection rebuilt on demand. Reads prefer the
|
|
10
|
+
* cached view; writes (idea/plan/build/review/handoff/resume) update both.
|
|
11
|
+
*
|
|
12
|
+
* Local-first invariant: this file is regenerable from events.jsonl plus
|
|
13
|
+
* filesystem scan of artifacts/handoffs. Deleting it never loses data.
|
|
14
|
+
*/
|
|
15
|
+
export const pugiIndexArtifactSchema = z.object({
|
|
16
|
+
id: z.string().min(1),
|
|
17
|
+
kind: z.enum([
|
|
18
|
+
'idea',
|
|
19
|
+
'plan',
|
|
20
|
+
'build',
|
|
21
|
+
'review',
|
|
22
|
+
'triple-review',
|
|
23
|
+
'resume',
|
|
24
|
+
'handoff',
|
|
25
|
+
'other',
|
|
26
|
+
]),
|
|
27
|
+
path: z.string().min(1),
|
|
28
|
+
sessionId: z.string().min(1).nullable(),
|
|
29
|
+
createdAt: z.string().datetime(),
|
|
30
|
+
files: z.array(z.string()).default([]),
|
|
31
|
+
});
|
|
32
|
+
export const pugiIndexSessionSchema = z.object({
|
|
33
|
+
id: z.string().min(1),
|
|
34
|
+
startedAt: z.string().datetime(),
|
|
35
|
+
endedAt: z.string().datetime().nullable(),
|
|
36
|
+
commandCount: z.number().int().nonnegative(),
|
|
37
|
+
commands: z.array(z.object({
|
|
38
|
+
command: z.string().min(1),
|
|
39
|
+
status: z.enum(['started', 'success', 'error']),
|
|
40
|
+
timestamp: z.string().datetime(),
|
|
41
|
+
})),
|
|
42
|
+
artifactIds: z.array(z.string()).default([]),
|
|
43
|
+
});
|
|
44
|
+
export const pugiIndexSchema = z.object({
|
|
45
|
+
schema: z.literal(1),
|
|
46
|
+
updatedAt: z.string().datetime(),
|
|
47
|
+
artifacts: z.array(pugiIndexArtifactSchema),
|
|
48
|
+
sessions: z.array(pugiIndexSessionSchema),
|
|
49
|
+
});
|
|
50
|
+
export function indexPath(root) {
|
|
51
|
+
return resolve(root, '.pugi', 'index.json');
|
|
52
|
+
}
|
|
53
|
+
export function readIndex(root) {
|
|
54
|
+
const path = indexPath(root);
|
|
55
|
+
if (!existsSync(path))
|
|
56
|
+
return null;
|
|
57
|
+
try {
|
|
58
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
59
|
+
const parsed = pugiIndexSchema.safeParse(raw);
|
|
60
|
+
return parsed.success ? parsed.data : null;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function writeIndex(root, index) {
|
|
67
|
+
const path = indexPath(root);
|
|
68
|
+
writeFileSync(path, `${JSON.stringify(index, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
69
|
+
}
|
|
70
|
+
export function emptyIndex() {
|
|
71
|
+
return {
|
|
72
|
+
schema: 1,
|
|
73
|
+
updatedAt: new Date().toISOString(),
|
|
74
|
+
artifacts: [],
|
|
75
|
+
sessions: [],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Rebuild the index from the event log + filesystem.
|
|
80
|
+
*
|
|
81
|
+
* This is the canonical recovery path: even if `.pugi/index.json` is
|
|
82
|
+
* deleted or corrupt, we can always reconstruct it.
|
|
83
|
+
*/
|
|
84
|
+
export function rebuildIndex(root) {
|
|
85
|
+
const events = readEvents(root);
|
|
86
|
+
const sessions = groupSessionsFromEvents(events);
|
|
87
|
+
const artifacts = scanArtifacts(root, events);
|
|
88
|
+
return {
|
|
89
|
+
schema: 1,
|
|
90
|
+
updatedAt: new Date().toISOString(),
|
|
91
|
+
artifacts,
|
|
92
|
+
sessions,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Append an artifact to the index, dedup by id. Touches `updatedAt`.
|
|
97
|
+
*/
|
|
98
|
+
export function upsertArtifact(index, artifact) {
|
|
99
|
+
const next = { ...index };
|
|
100
|
+
const existing = next.artifacts.findIndex((a) => a.id === artifact.id);
|
|
101
|
+
if (existing >= 0) {
|
|
102
|
+
next.artifacts = next.artifacts.map((a, i) => (i === existing ? artifact : a));
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
next.artifacts = [...next.artifacts, artifact];
|
|
106
|
+
}
|
|
107
|
+
next.updatedAt = new Date().toISOString();
|
|
108
|
+
// Attach to session bucket if we know the sessionId.
|
|
109
|
+
if (artifact.sessionId) {
|
|
110
|
+
next.sessions = next.sessions.map((session) => session.id === artifact.sessionId
|
|
111
|
+
? {
|
|
112
|
+
...session,
|
|
113
|
+
artifactIds: session.artifactIds.includes(artifact.id)
|
|
114
|
+
? session.artifactIds
|
|
115
|
+
: [...session.artifactIds, artifact.id],
|
|
116
|
+
}
|
|
117
|
+
: session);
|
|
118
|
+
}
|
|
119
|
+
return next;
|
|
120
|
+
}
|
|
121
|
+
export function readEvents(root) {
|
|
122
|
+
const eventsPath = resolve(root, '.pugi', 'events.jsonl');
|
|
123
|
+
if (!existsSync(eventsPath))
|
|
124
|
+
return [];
|
|
125
|
+
const raw = readFileSync(eventsPath, 'utf8');
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const line of raw.split('\n')) {
|
|
128
|
+
const trimmed = line.trim();
|
|
129
|
+
if (!trimmed)
|
|
130
|
+
continue;
|
|
131
|
+
try {
|
|
132
|
+
const parsed = auditEventSchema.safeParse(JSON.parse(trimmed));
|
|
133
|
+
if (parsed.success)
|
|
134
|
+
out.push(parsed.data);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Tolerate corrupt single lines so the rest of the log keeps loading.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
function groupSessionsFromEvents(events) {
|
|
143
|
+
const map = new Map();
|
|
144
|
+
for (const event of events) {
|
|
145
|
+
if (event.type !== 'session')
|
|
146
|
+
continue;
|
|
147
|
+
let session = map.get(event.sessionId);
|
|
148
|
+
if (!session) {
|
|
149
|
+
session = {
|
|
150
|
+
id: event.sessionId,
|
|
151
|
+
startedAt: event.timestamp,
|
|
152
|
+
endedAt: null,
|
|
153
|
+
commandCount: 0,
|
|
154
|
+
commands: [],
|
|
155
|
+
artifactIds: [],
|
|
156
|
+
};
|
|
157
|
+
map.set(event.sessionId, session);
|
|
158
|
+
}
|
|
159
|
+
if (event.name === 'command_started' && event.command) {
|
|
160
|
+
session.commandCount += 1;
|
|
161
|
+
session.commands.push({
|
|
162
|
+
command: event.command,
|
|
163
|
+
status: 'started',
|
|
164
|
+
timestamp: event.timestamp,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
else if (event.name === 'command_completed' && event.command) {
|
|
168
|
+
session.commands.push({
|
|
169
|
+
command: event.command,
|
|
170
|
+
status: event.status === 'error' ? 'error' : 'success',
|
|
171
|
+
timestamp: event.timestamp,
|
|
172
|
+
});
|
|
173
|
+
session.endedAt = event.timestamp;
|
|
174
|
+
}
|
|
175
|
+
else if (event.name === 'created') {
|
|
176
|
+
session.startedAt = event.timestamp;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return [...map.values()].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
180
|
+
}
|
|
181
|
+
function scanArtifacts(root, events) {
|
|
182
|
+
const artifactsDir = resolve(root, '.pugi', 'artifacts');
|
|
183
|
+
const handoffsDir = resolve(root, '.pugi', 'handoffs');
|
|
184
|
+
const out = [];
|
|
185
|
+
// Map artifact dir names to sessionIds via tool_call events (best-effort).
|
|
186
|
+
// Artifact ids are timestamped slugs (e.g. `2026-05-22T03-51-37-260Z-triple-review`);
|
|
187
|
+
// tool_call records the `inputSummary` which often contains the prompt.
|
|
188
|
+
// We attach sessionId by mtime proximity to the closest tool_call event.
|
|
189
|
+
const toolCallTimes = events
|
|
190
|
+
.filter((e) => e.type === 'tool_call')
|
|
191
|
+
.map((e) => ({ sessionId: e.sessionId, timestamp: Date.parse(e.timestamp) }))
|
|
192
|
+
.filter((entry) => Number.isFinite(entry.timestamp));
|
|
193
|
+
if (existsSync(artifactsDir)) {
|
|
194
|
+
for (const entry of readdirSync(artifactsDir, { withFileTypes: true })) {
|
|
195
|
+
if (!entry.isDirectory())
|
|
196
|
+
continue;
|
|
197
|
+
const dir = resolve(artifactsDir, entry.name);
|
|
198
|
+
const files = readdirSync(dir, { withFileTypes: true })
|
|
199
|
+
.filter((file) => file.isFile())
|
|
200
|
+
.map((file) => file.name)
|
|
201
|
+
.sort();
|
|
202
|
+
const stat = statSync(dir);
|
|
203
|
+
out.push({
|
|
204
|
+
id: entry.name,
|
|
205
|
+
kind: inferKindFromFiles(files),
|
|
206
|
+
path: relative(root, dir),
|
|
207
|
+
sessionId: nearestSessionId(stat.mtimeMs, toolCallTimes),
|
|
208
|
+
createdAt: stat.mtime.toISOString(),
|
|
209
|
+
files,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (existsSync(handoffsDir)) {
|
|
214
|
+
for (const entry of readdirSync(handoffsDir, { withFileTypes: true })) {
|
|
215
|
+
if (!entry.isFile() || !entry.name.endsWith('.json'))
|
|
216
|
+
continue;
|
|
217
|
+
const path = resolve(handoffsDir, entry.name);
|
|
218
|
+
const stat = statSync(path);
|
|
219
|
+
out.push({
|
|
220
|
+
id: entry.name.replace(/\.json$/, ''),
|
|
221
|
+
kind: 'handoff',
|
|
222
|
+
path: relative(root, path),
|
|
223
|
+
sessionId: nearestSessionId(stat.mtimeMs, toolCallTimes),
|
|
224
|
+
createdAt: stat.mtime.toISOString(),
|
|
225
|
+
files: [entry.name],
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
230
|
+
}
|
|
231
|
+
function inferKindFromFiles(files) {
|
|
232
|
+
if (files.some((f) => f === 'triple-review-request.json' || f === 'triple-review.md')) {
|
|
233
|
+
return 'triple-review';
|
|
234
|
+
}
|
|
235
|
+
if (files.includes('brief.md') && files.includes('execution-graph.json'))
|
|
236
|
+
return 'idea';
|
|
237
|
+
if (files.includes('plan.md'))
|
|
238
|
+
return 'plan';
|
|
239
|
+
if (files.includes('build.md'))
|
|
240
|
+
return 'build';
|
|
241
|
+
if (files.includes('review.md'))
|
|
242
|
+
return 'review';
|
|
243
|
+
if (files.includes('resume.md'))
|
|
244
|
+
return 'resume';
|
|
245
|
+
return 'other';
|
|
246
|
+
}
|
|
247
|
+
function nearestSessionId(mtimeMs, toolCallTimes) {
|
|
248
|
+
if (toolCallTimes.length === 0)
|
|
249
|
+
return null;
|
|
250
|
+
let best = null;
|
|
251
|
+
for (const entry of toolCallTimes) {
|
|
252
|
+
const delta = Math.abs(entry.timestamp - mtimeMs);
|
|
253
|
+
if (!best || delta < best.delta)
|
|
254
|
+
best = { sessionId: entry.sessionId, delta };
|
|
255
|
+
}
|
|
256
|
+
// Cap attribution to a 5-minute window so unrelated sessions do not collect
|
|
257
|
+
// each other's artifacts on long-lived repos.
|
|
258
|
+
return best && best.delta <= 5 * 60 * 1000 ? best.sessionId : null;
|
|
259
|
+
}
|
|
260
|
+
//# sourceMappingURL=index-store.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { basename, relative, resolve } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve and validate that an inputPath stays within the workspace.
|
|
5
|
+
*
|
|
6
|
+
* Defends against:
|
|
7
|
+
* 1. relative-path traversal (`../etc/passwd`)
|
|
8
|
+
* 2. URL-encoded traversal (`..%2Fetc%2Fpasswd`)
|
|
9
|
+
* 3. symlink escapes at the target itself (`alias-link -> /etc/passwd`)
|
|
10
|
+
*
|
|
11
|
+
* The previous implementation also resolved the parent's realpath and
|
|
12
|
+
* compared to the workspace root. That broke `pugi explain .` on macOS
|
|
13
|
+
* where the workspace is under a symlinked prefix (`/tmp` → `/private/tmp`)
|
|
14
|
+
* because the parent's realpath legitimately sits one level above the
|
|
15
|
+
* workspace. The fix: compute the target's realpath (or the literal
|
|
16
|
+
* resolved path when the target does not yet exist) and require that to
|
|
17
|
+
* stay inside the workspace's realpath. Parent-chain symlinks that
|
|
18
|
+
* actually escape will fail this check; benign system symlinks above
|
|
19
|
+
* the workspace will not.
|
|
20
|
+
*
|
|
21
|
+
* Throws when the resolved path is outside the workspace. Returns the
|
|
22
|
+
* literal absolute path on disk (suitable for `readFileSync` /
|
|
23
|
+
* `writeFileSync`).
|
|
24
|
+
*/
|
|
25
|
+
export function resolveWorkspacePath(root, inputPath) {
|
|
26
|
+
const decoded = decodeURIComponent(inputPath);
|
|
27
|
+
const target = resolve(root, decoded);
|
|
28
|
+
const realRoot = realpathSync.native(root);
|
|
29
|
+
let realTarget;
|
|
30
|
+
try {
|
|
31
|
+
realTarget = realpathSync.native(target);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const code = error.code;
|
|
35
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR')
|
|
36
|
+
throw error;
|
|
37
|
+
// Target does not exist yet (write/create). Anchor against the
|
|
38
|
+
// parent's realpath so the new file we are about to create stays
|
|
39
|
+
// inside the workspace.
|
|
40
|
+
let realParent;
|
|
41
|
+
try {
|
|
42
|
+
realParent = realpathSync.native(resolve(target, '..'));
|
|
43
|
+
}
|
|
44
|
+
catch (parentError) {
|
|
45
|
+
const parentCode = parentError.code;
|
|
46
|
+
if (parentCode !== 'ENOENT' && parentCode !== 'ENOTDIR')
|
|
47
|
+
throw parentError;
|
|
48
|
+
throw new Error(`Path escapes workspace (missing parent): ${inputPath}`);
|
|
49
|
+
}
|
|
50
|
+
realTarget = resolve(realParent, basename(target));
|
|
51
|
+
}
|
|
52
|
+
if (!isInsideWorkspace(realTarget, realRoot)) {
|
|
53
|
+
throw new Error(`Path escapes workspace: ${inputPath}`);
|
|
54
|
+
}
|
|
55
|
+
return target;
|
|
56
|
+
}
|
|
57
|
+
function isInsideWorkspace(child, workspaceRoot) {
|
|
58
|
+
if (child === workspaceRoot)
|
|
59
|
+
return true;
|
|
60
|
+
const rel = relative(workspaceRoot, child);
|
|
61
|
+
return Boolean(rel) && !rel.startsWith('..') && rel !== '..';
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=path-security.js.map
|