@prjct.app/pi-signals 0.0.0-stage → 0.1.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/CHANGELOG.md +13 -0
- package/LICENSE +20 -0
- package/README.md +73 -2
- package/bin/cli.mjs +23 -0
- package/dist/cli.mjs +1146 -0
- package/index.ts +1 -0
- package/package.json +60 -4
- package/src/agent.ts +50 -0
- package/src/cli.ts +31 -0
- package/src/commands.ts +16 -0
- package/src/config.ts +48 -0
- package/src/evidence.ts +25 -0
- package/src/host.ts +15 -0
- package/src/index.ts +70 -0
- package/src/repair.ts +86 -0
- package/src/report.ts +50 -0
- package/src/scheduler.ts +35 -0
- package/src/schema.ts +31 -0
- package/src/store.ts +95 -0
- package/src/triage.ts +34 -0
- package/src/worker.ts +60 -0
- package/src/worktree.ts +13 -0
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default, installSignals, type SignalsDependencies } from './src/index.ts';
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prjct.app/pi-signals",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Local failure signals, daily agent-owned triage and verified repair candidates for Pi.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.19.0"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"pi-package",
|
|
12
|
+
"pi-extension",
|
|
13
|
+
"signals",
|
|
14
|
+
"triage",
|
|
15
|
+
"quality"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"pi": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
"./index.ts"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"index.ts",
|
|
27
|
+
"src",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"CHANGELOG.md",
|
|
31
|
+
"dist",
|
|
32
|
+
"bin"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@prjct.app/pi-secrets": "^0.2.6"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@earendil-works/pi-ai": ">=1.0.4",
|
|
39
|
+
"@earendil-works/pi-coding-agent": ">=1.0.4",
|
|
40
|
+
"typebox": "*"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@earendil-works/pi-ai": "1.0.4",
|
|
44
|
+
"@earendil-works/pi-coding-agent": "1.0.4",
|
|
45
|
+
"@types/node": "^22.19.0",
|
|
46
|
+
"esbuild": "^0.28.2",
|
|
47
|
+
"tsx": "^4.20.0",
|
|
48
|
+
"typebox": "1.3.7",
|
|
49
|
+
"typescript": "^5.9.3"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"check": "tsc --noEmit",
|
|
53
|
+
"test": "node --import ./tests/isolate.mjs --import tsx --test --test-concurrency=2 tests/*.test.ts",
|
|
54
|
+
"check:package": "npm pack --dry-run --ignore-scripts",
|
|
55
|
+
"build": "node scripts/build.mjs",
|
|
56
|
+
"prepack": "npm run build",
|
|
57
|
+
"pretest": "npm run build"
|
|
58
|
+
},
|
|
59
|
+
"bin": {
|
|
60
|
+
"pi-signals": "bin/cli.mjs"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/agent.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
|
4
|
+
import { protectOutboundData } from '@prjct.app/pi-secrets/privacy';
|
|
5
|
+
import { config, stateDir } from './config.ts';
|
|
6
|
+
import type { SignalModel } from './schema.ts';
|
|
7
|
+
|
|
8
|
+
export type AgentTask = { cwd: string; model?: SignalModel; prompt: string; tools: ToolDefinition[]; writable: boolean; signal?: AbortSignal };
|
|
9
|
+
export type AgentRunner = (task: AgentTask) => Promise<void>;
|
|
10
|
+
export const runAgent: AgentRunner = async task => {
|
|
11
|
+
const home = getAgentDir();
|
|
12
|
+
const configured = SettingsManager.create(task.cwd, home, { projectTrusted: false });
|
|
13
|
+
if (configured.drainErrors().length) throw new Error('Cannot read Pi settings for the signals worker.');
|
|
14
|
+
const effective = configured.getSettings();
|
|
15
|
+
const selected = task.model ?? (effective.defaultProvider && effective.defaultModel ? { provider: effective.defaultProvider, id: effective.defaultModel, thinkingLevel: effective.defaultThinkingLevel } : undefined);
|
|
16
|
+
if (!selected) throw new Error('No originating or configured Pi model is available. Select a model in Pi.');
|
|
17
|
+
const runtime = await ModelRuntime.create({ authPath: join(home, 'auth.json'), modelsPath: join(home, 'models.json'), allowModelNetwork: false });
|
|
18
|
+
const model = runtime.getModel(selected.provider, selected.id);
|
|
19
|
+
const manager = new DefaultPackageManager({ cwd: task.cwd, agentDir: home, settingsManager: configured });
|
|
20
|
+
const admitted = config().workerPackages;
|
|
21
|
+
const paths = manager.listConfiguredPackages().flatMap(pkg => {
|
|
22
|
+
if (!pkg.installedPath) return [];
|
|
23
|
+
const manifest = JSON.parse(readFileSync(join(pkg.installedPath, 'package.json'), 'utf8'));
|
|
24
|
+
return admitted.includes(manifest.name) ? (manifest.pi?.extensions ?? []).map((entry: string) => join(pkg.installedPath!, entry)) : [];
|
|
25
|
+
});
|
|
26
|
+
const settings = SettingsManager.inMemory({ ...effective, packages: [], extensions: [], cacheWarming: 'off' });
|
|
27
|
+
const loader = new DefaultResourceLoader({ cwd: task.cwd, agentDir: home, settingsManager: settings,
|
|
28
|
+
noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, additionalExtensionPaths: paths });
|
|
29
|
+
await loader.reload();
|
|
30
|
+
if (loader.getExtensions().errors.length) throw new Error('The worker could not load the admitted Pi extensions.');
|
|
31
|
+
const { session } = await createAgentSession({ cwd: task.cwd, agentDir: home, settingsManager: settings, resourceLoader: loader,
|
|
32
|
+
modelRuntime: runtime, model, thinkingLevel: selected.thinkingLevel, sessionManager: SessionManager.create(task.cwd, join(stateDir(), 'sessions')),
|
|
33
|
+
tools: ['read', 'grep', 'find', 'ls', 'bash', ...(task.writable ? ['edit', 'write'] : []), 'context_status', 'context_history', 'context_compact', ...task.tools.map(tool => tool.name)], customTools: task.tools });
|
|
34
|
+
const abort = () => { void session.abort(); };
|
|
35
|
+
task.signal?.addEventListener('abort', abort, { once: true });
|
|
36
|
+
try {
|
|
37
|
+
task.signal?.throwIfAborted();
|
|
38
|
+
await session.bindExtensions({ mode: 'rpc' });
|
|
39
|
+
const resolved = runtime.getModel(selected.provider, selected.id);
|
|
40
|
+
if (!resolved) throw new Error(`The originating model ${selected.provider}/${selected.id} is unavailable; no substitute was selected.`);
|
|
41
|
+
await session.setModel(resolved);
|
|
42
|
+
if (selected.thinkingLevel) session.setThinkingLevel(selected.thinkingLevel);
|
|
43
|
+
await session.prompt(await protectOutboundData(task.prompt));
|
|
44
|
+
task.signal?.throwIfAborted();
|
|
45
|
+
} finally {
|
|
46
|
+
task.signal?.removeEventListener('abort', abort);
|
|
47
|
+
await session.extensionRunner?.emit({ type: 'session_shutdown', reason: 'quit' });
|
|
48
|
+
session.dispose();
|
|
49
|
+
}
|
|
50
|
+
};
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { databasePath, config } from './config.ts';
|
|
4
|
+
import { SignalStore } from './store.ts';
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
const [action = 'status', argument] = process.argv.slice(2);
|
|
8
|
+
if (action === 'schedule' && argument === 'install') {
|
|
9
|
+
return (await import('./scheduler.ts')).installSchedule(fileURLToPath(new URL('../bin/cli.mjs', import.meta.url)));
|
|
10
|
+
}
|
|
11
|
+
if (action === 'once') {
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
const abort = () => controller.abort(new Error('Signals worker cancelled'));
|
|
14
|
+
process.once('SIGTERM', abort); process.once('SIGINT', abort);
|
|
15
|
+
try { return await (await import('./worker.ts')).runDaily(argument === '--scheduled', controller.signal); }
|
|
16
|
+
finally { process.removeListener('SIGTERM', abort); process.removeListener('SIGINT', abort); }
|
|
17
|
+
}
|
|
18
|
+
const store = new SignalStore(databasePath());
|
|
19
|
+
try {
|
|
20
|
+
if (action === 'status') { const issues = store.list(); return { config: config(), issues: issues.slice(0, 20), nextOffset: issues.length > 20 ? 20 : null }; }
|
|
21
|
+
if (action === 'retry' && argument && /^[a-f0-9]{64}$/.test(argument)) return { queued: store.retry(argument) };
|
|
22
|
+
if (action === 'show' && argument) return { issue: store.get(argument), observations: store.observations(argument), runs: store.runs(argument) };
|
|
23
|
+
throw new Error('Usage: pi-signals status | show <id> | retry <id> | once [--scheduled] | schedule install');
|
|
24
|
+
} finally { store.close(); }
|
|
25
|
+
}
|
|
26
|
+
try { console.log(JSON.stringify(await main(), null, 2)); }
|
|
27
|
+
catch (error) {
|
|
28
|
+
const { protectOutboundData } = await import('@prjct.app/pi-secrets/privacy');
|
|
29
|
+
console.error(await protectOutboundData(String(error)).catch(() => 'Signals failed; outbound privacy protection was unavailable.'));
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createLocalBashOperations } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
|
|
3
|
+
export type Check = { command: string; code: number; output: string; durationMs: number };
|
|
4
|
+
export const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
5
|
+
/** Use Pi's public execution/cancellation backend, including process-tree cleanup. */
|
|
6
|
+
export async function command(cwd: string, value: string | string[], signal?: AbortSignal): Promise<Check> {
|
|
7
|
+
const text = Array.isArray(value) ? value.map(quote).join(' ') : value;
|
|
8
|
+
const chunks: Buffer[] = [], start = performance.now();
|
|
9
|
+
const result = await createLocalBashOperations().exec(text, cwd, { onData: data => chunks.push(data), signal, timeout: 600 });
|
|
10
|
+
return { command: text, code: result.exitCode ?? 128, output: Buffer.concat(chunks).toString('utf8'), durationMs: performance.now() - start };
|
|
11
|
+
}
|
|
12
|
+
export async function must(cwd: string, value: string | string[], signal?: AbortSignal) {
|
|
13
|
+
const result = await command(cwd, value, signal);
|
|
14
|
+
if (result.code !== 0) throw new Error(`Command failed (${result.code}): ${result.command}\n${result.output}`);
|
|
15
|
+
return result.output.trim();
|
|
16
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
import { DefaultPackageManager, getAgentDir, getPackageDir, SettingsManager, type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import type { Target } from './schema.ts';
|
|
6
|
+
|
|
7
|
+
export const stateDir = () => join(process.env.PRJCT_HOME ?? join(homedir(), '.prjct'), 'shared', 'signals');
|
|
8
|
+
export const databasePath = () => join(stateDir(), 'signals.sqlite');
|
|
9
|
+
export type Repository = { path: string; base: string; setup: string[]; tests: string[] };
|
|
10
|
+
export type Config = { enabled: boolean; packages: string[]; workerPackages: string[]; repositories: Record<string, Repository>; timezone: string; hour: number; minute: number; repair: boolean; delivery: 'local' };
|
|
11
|
+
export function config(): Config {
|
|
12
|
+
const defaults: Config = { enabled: true, packages: ['@prjct.app/'], workerPackages: ['@prjct.app/pi-secrets', '@prjct.app/pi-context-control', '@prjct.app/pi-self-compact'], repositories: {}, timezone: 'America/Cancun', hour: 9, minute: 0, repair: false, delivery: 'local' };
|
|
13
|
+
try {
|
|
14
|
+
const raw = JSON.parse(readFileSync(join(stateDir(), 'config.json'), 'utf8'));
|
|
15
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Expected an object');
|
|
16
|
+
const value: Config = { ...defaults, ...raw };
|
|
17
|
+
if (typeof value.enabled !== 'boolean' || typeof value.repair !== 'boolean' || value.delivery !== 'local'
|
|
18
|
+
|| !Array.isArray(value.packages) || !value.packages.every(prefix => typeof prefix === 'string' && prefix.length > 0)
|
|
19
|
+
|| !Array.isArray(value.workerPackages) || !value.workerPackages.every(name => typeof name === 'string' && name.length > 0)
|
|
20
|
+
|| !Number.isInteger(value.hour) || value.hour < 0 || value.hour > 23 || !Number.isInteger(value.minute) || value.minute < 0 || value.minute > 59
|
|
21
|
+
|| !value.repositories || typeof value.repositories !== 'object' || Array.isArray(value.repositories)) throw new Error('Invalid configuration');
|
|
22
|
+
new Intl.DateTimeFormat('en', { timeZone: value.timezone });
|
|
23
|
+
for (const repo of Object.values(value.repositories)) {
|
|
24
|
+
if (!repo || !isAbsolute(repo.path) || !/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(repo.base) || repo.base.includes('..')
|
|
25
|
+
|| !Array.isArray(repo.setup) || !Array.isArray(repo.tests) || ![...repo.setup, ...repo.tests].every(command => typeof command === 'string' && command.trim())) throw new Error('Invalid repository binding');
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return defaults;
|
|
30
|
+
throw new Error('Invalid global signals configuration; processing is disabled until repaired.');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function manifestTarget(root: string): Target | undefined {
|
|
34
|
+
try {
|
|
35
|
+
const value = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
36
|
+
return typeof value.name === 'string' && typeof value.version === 'string' ? { id: value.name, version: value.version } : undefined;
|
|
37
|
+
} catch { return undefined; }
|
|
38
|
+
}
|
|
39
|
+
export function targets(pi: ExtensionAPI, ctx: ExtensionContext, policy: Config): Target[] {
|
|
40
|
+
const effective = pi.getSettings();
|
|
41
|
+
const settings = effective.packages?.length ? SettingsManager.inMemory(effective) : SettingsManager.create(ctx.cwd, getAgentDir(), { projectTrusted: false });
|
|
42
|
+
if (settings.drainErrors().some(error => error.scope === 'global')) throw new Error('Cannot read the global Pi package catalog.');
|
|
43
|
+
const manager = new DefaultPackageManager({ cwd: ctx.cwd, agentDir: getAgentDir(), settingsManager: settings });
|
|
44
|
+
const installed = manager.listConfiguredPackages().flatMap(pkg => pkg.installedPath ? [manifestTarget(pkg.installedPath)].filter((value): value is Target => !!value) : [])
|
|
45
|
+
.filter(target => policy.packages.some(prefix => target.id === prefix || (prefix.endsWith('/') && target.id.startsWith(prefix))));
|
|
46
|
+
const core = manifestTarget(getPackageDir());
|
|
47
|
+
return [...new Map([...installed, ...(core ? [{ ...core, id: 'pi' }] : [])].map(target => [target.id, target])).values()];
|
|
48
|
+
}
|
package/src/evidence.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
import type { ToolDefinition } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import { protectOutboundData } from '@prjct.app/pi-secrets/privacy';
|
|
4
|
+
|
|
5
|
+
export type EvidenceSource = readonly unknown[] | ((offset: number, limit: number) => unknown[]);
|
|
6
|
+
/** Keep full observations in storage; the deciding agent chooses which pages to read. */
|
|
7
|
+
export function evidenceTool(source: EvidenceSource) {
|
|
8
|
+
const seen: string[] = [];
|
|
9
|
+
const tool: ToolDefinition = {
|
|
10
|
+
name: 'signal_evidence', label: 'Read original signal evidence',
|
|
11
|
+
description: 'Read original issue observations by page. Nothing is discarded; follow nextOffset to inspect all sightings. Select a smaller limit for large reports.',
|
|
12
|
+
parameters: Type.Object({ offset: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })) }),
|
|
13
|
+
async execute(_id, raw) {
|
|
14
|
+
const input = raw as { offset?: number; limit?: number }, offset = input.offset ?? 0, limit = input.limit ?? 1;
|
|
15
|
+
const rows = typeof source === 'function' ? source(offset, limit + 1) : source.slice(offset, offset + limit + 1);
|
|
16
|
+
const items = await protectOutboundData(rows.slice(0, limit));
|
|
17
|
+
for (const item of items) {
|
|
18
|
+
const payload = (item as { payload?: { body?: string } }).payload;
|
|
19
|
+
seen.push(payload?.body ?? JSON.stringify(item));
|
|
20
|
+
}
|
|
21
|
+
return { content: [{ type: 'text', text: JSON.stringify({ items, nextOffset: rows.length > limit ? offset + limit : null }) }], details: {} };
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
return { tool, contains: (quote: string) => seen.some(text => text.includes(quote)) };
|
|
25
|
+
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { getPackageDir, VERSION } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { stateDir } from './config.ts';
|
|
6
|
+
|
|
7
|
+
/** Record only the host's public SDK location, never credentials or model data. */
|
|
8
|
+
export function rememberHost() {
|
|
9
|
+
const root = stateDir(), file = join(root, 'host.json');
|
|
10
|
+
const value = JSON.stringify({ packageDir: getPackageDir(), version: VERSION }) + '\n';
|
|
11
|
+
try { if (readFileSync(file, 'utf8') === value) return; } catch { /* First load. */ }
|
|
12
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
13
|
+
const temporary = join(root, `host-${randomUUID()}.tmp`);
|
|
14
|
+
writeFileSync(temporary, value, { mode: 0o600 }); renameSync(temporary, file);
|
|
15
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { protectOutboundData } from '@prjct.app/pi-secrets/privacy';
|
|
3
|
+
import { config, targets, databasePath } from './config.ts';
|
|
4
|
+
import { hideLocalPaths, prepareReport } from './report.ts';
|
|
5
|
+
import { InspectSchema, OBSERVATION, ReportSchema } from './schema.ts';
|
|
6
|
+
import { SignalStore } from './store.ts';
|
|
7
|
+
import { rememberHost } from './host.ts';
|
|
8
|
+
|
|
9
|
+
const defaults = { config, targets, protect: protectOutboundData, databasePath };
|
|
10
|
+
export type SignalsDependencies = Partial<typeof defaults>;
|
|
11
|
+
const result = (value: unknown) => ({ content: [{ type: 'text' as const, text: JSON.stringify(value) }], details: {} });
|
|
12
|
+
const page = <T>(rows: T[], offset: number) => ({ items: rows.slice(0, 20), nextOffset: rows.length > 20 ? offset + 20 : null });
|
|
13
|
+
export function installSignals(pi: ExtensionAPI, dependencies: SignalsDependencies = {}) {
|
|
14
|
+
const deps = { ...defaults, ...dependencies };
|
|
15
|
+
pi.on('session_start', () => { try { rememberHost(); } catch { /* A host path hint must not interrupt an active session. */ } });
|
|
16
|
+
const catalog = (ctx: ExtensionContext) => {
|
|
17
|
+
const settings = deps.config();
|
|
18
|
+
if (!settings.enabled) throw new Error('Signals is disabled by global configuration.');
|
|
19
|
+
return deps.targets(pi, ctx, settings);
|
|
20
|
+
};
|
|
21
|
+
pi.registerTool({
|
|
22
|
+
name: 'signal_report', label: 'Record failure signal',
|
|
23
|
+
description: 'Record a local issue in the global .prjct database for an installed extension or Pi: latency regression, crash, or concrete response-quality regression. Supply a stable defect signature, exact session evidence, reproduction steps, expected and actual behavior. No GitHub or model request occurs during capture.',
|
|
24
|
+
promptGuidelines: ['When you detect a latency regression, crash, or concrete response-quality regression in Pi or an installed extension, record minimal evidence with signal_report, then continue the original task. Use signal_inspect for targets. Detection is a signal, not proof of causality; the active agent and later triage decide.'],
|
|
25
|
+
parameters: ReportSchema,
|
|
26
|
+
async execute(_id, input, _signal, _update, ctx) {
|
|
27
|
+
const target = catalog(ctx).find(target => target.id === input.target);
|
|
28
|
+
if (!target) throw new Error('Unknown component. Inspect targets before recording a signal.');
|
|
29
|
+
const capture = prepareReport(input, target, ctx);
|
|
30
|
+
const payload = await deps.protect(hideLocalPaths(capture.payload, ctx.cwd));
|
|
31
|
+
const store = new SignalStore(deps.databasePath());
|
|
32
|
+
try { return result(store.capture({ ...capture, payload })); } finally { store.close(); }
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
pi.registerTool({
|
|
36
|
+
name: 'signal_inspect', label: 'Inspect failure signals',
|
|
37
|
+
description: 'Read targets, local issues, or observations. For stored issue observations supply issueId; otherwise observations are native failures on this session branch. Paginated; no external calls.',
|
|
38
|
+
parameters: InspectSchema,
|
|
39
|
+
async execute(_id, input, _signal, _update, ctx) {
|
|
40
|
+
const allowed = catalog(ctx), offset = input.offset ?? 0;
|
|
41
|
+
if (input.action === 'targets') return result(page(allowed.slice(offset, offset + 21), offset));
|
|
42
|
+
if (input.action === 'observations' && !input.issueId) {
|
|
43
|
+
const entries = ctx.sessionManager.getBranch().filter(entry => entry.type === 'custom' && entry.customType === OBSERVATION).reverse();
|
|
44
|
+
return result(page(entries.slice(offset, offset + 21).map(entry => ({ entryId: entry.id, data: entry.type === 'custom' ? entry.data : undefined })), offset));
|
|
45
|
+
}
|
|
46
|
+
const store = new SignalStore(deps.databasePath());
|
|
47
|
+
try { return result(page<unknown>(input.action === 'issues' ? store.list(offset) : store.observations(input.issueId!, offset), offset)); }
|
|
48
|
+
finally { store.close(); }
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
pi.registerCommand('signals', {
|
|
52
|
+
description: 'Show local issues and daily triage configuration',
|
|
53
|
+
async handler(_args, ctx) {
|
|
54
|
+
try {
|
|
55
|
+
const settings = deps.config(), store = new SignalStore(deps.databasePath());
|
|
56
|
+
try { ctx.ui.notify(JSON.stringify({ enabled: settings.enabled, daily: `${settings.hour}:${String(settings.minute).padStart(2, '0')} ${settings.timezone}`, issues: page(store.list(), 0) }), 'info'); }
|
|
57
|
+
finally { store.close(); }
|
|
58
|
+
} catch { ctx.ui.notify('Cannot read signals configuration or database.', 'error'); }
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
pi.on('session_compact_failed', async (event, ctx) => {
|
|
62
|
+
if (event.aborted) return;
|
|
63
|
+
try {
|
|
64
|
+
if (!deps.config().enabled) return;
|
|
65
|
+
pi.appendEntry(OBSERVATION, await deps.protect(hideLocalPaths({ reason: event.reason, error: event.errorMessage ?? 'Compaction failed',
|
|
66
|
+
fromExtension: event.fromExtension, willRetry: event.willRetry, time: Date.now() }, ctx.cwd)));
|
|
67
|
+
} catch { /* A failed observer never interrupts recovery or stores unprotected evidence. */ }
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export default installSignals;
|
package/src/repair.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Type, type Static } from 'typebox';
|
|
2
|
+
import { copyFile, lstat, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
4
|
+
import { protectOutboundData } from '@prjct.app/pi-secrets/privacy';
|
|
5
|
+
import { runAgent, type AgentRunner } from './agent.ts';
|
|
6
|
+
import { command, must, type Check } from './commands.ts';
|
|
7
|
+
import { stateDir, type Repository } from './config.ts';
|
|
8
|
+
import type { LocalIssue } from './store.ts';
|
|
9
|
+
import { evidenceTool, type EvidenceSource } from './evidence.ts';
|
|
10
|
+
|
|
11
|
+
const FixSchema = Type.Object({
|
|
12
|
+
explanation: Type.String({ minLength: 1 }),
|
|
13
|
+
testFiles: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
|
|
14
|
+
regressionCommand: Type.String({ minLength: 1 }),
|
|
15
|
+
failurePattern: Type.String({ minLength: 8 }),
|
|
16
|
+
}, { additionalProperties: false });
|
|
17
|
+
type Fix = Static<typeof FixSchema>;
|
|
18
|
+
const ReviewSchema = Type.Object({ fixesSignal: Type.Boolean(), testsMeaningful: Type.Boolean(), explanation: Type.String({ minLength: 1 }) });
|
|
19
|
+
export type RepairResult = { status: 'verified' | 'blocked'; explanation: string; base: string; head?: string; branch?: string; path: string; checks: Check[]; review?: unknown };
|
|
20
|
+
|
|
21
|
+
/** A regression test is replayed unchanged on the base and candidate, then the trusted suite runs. */
|
|
22
|
+
export async function repair(issue: LocalIssue, repo: Repository, path: string, base: string, observations: EvidenceSource, signal?: AbortSignal, agent: AgentRunner = runAgent): Promise<RepairResult> {
|
|
23
|
+
const state: { fix?: Fix; review?: Static<typeof ReviewSchema> } = {};
|
|
24
|
+
const checks: Check[] = [];
|
|
25
|
+
const blocked = (explanation: string): RepairResult => ({ status: 'blocked', explanation, base, path, checks });
|
|
26
|
+
await agent({ cwd: path, model: issue.model, writable: true, signal,
|
|
27
|
+
prompt: `Repair signal ${issue.id} in this isolated worktree. Preserve the task, public Pi SDK architecture and model autonomy. No JEV, classifier substitution, hidden context pruning, or cost-based model changes. Keep the fix small and reuse existing code. Do not publish, push, merge, or commit; the coordinator owns release gates.\n`
|
|
28
|
+
+ `Add a meaningful regression test reproducing the exact observed failure. The SAME new test files will be copied to unchanged base ${base} and must fail there with the specific failurePattern; on your fix they must pass repeatedly. Do not edit existing tests or weaken assertions merely to make the suite pass. For latency use comparable repeated measurements; for response quality use the original task constraints and a controlled expected response, not general intelligence claims.\n`
|
|
29
|
+
+ `Read the original observations with signal_evidence. Use signal_fix to describe the fix and name only new test files (under tests/test/__tests__ or *.test.*/*.spec.*). Full suites: ${JSON.stringify(repo.tests)}.\n${JSON.stringify({ issue })}`,
|
|
30
|
+
tools: [evidenceTool(observations).tool, { name: 'signal_fix', label: 'Submit repair candidate', description: 'Submit the candidate for independent baseline, regression, suite and semantic checks. This does not mark it fixed.', parameters: FixSchema,
|
|
31
|
+
async execute(_id, input) { state.fix = input as Fix; return { content: [{ type: 'text', text: 'Candidate recorded for verification.' }], details: {} }; } }],
|
|
32
|
+
});
|
|
33
|
+
if (!state.fix) return blocked('No structured repair candidate was provided.');
|
|
34
|
+
if (!repo.tests.length) return blocked('No trusted package validation commands are configured.');
|
|
35
|
+
if (await must(path, ['git', 'rev-parse', 'HEAD'], signal) !== base) return blocked('The agent changed the candidate base before verification; no precommitted changes are accepted.');
|
|
36
|
+
const fix = state.fix;
|
|
37
|
+
const baselinePath = join(stateDir(), 'worktrees', `baseline-${issue.id.slice(0, 12)}-${Date.now()}`);
|
|
38
|
+
await must(repo.path, ['git', 'worktree', 'add', '--detach', baselinePath, base], signal);
|
|
39
|
+
for (const setup of repo.setup) await must(baselinePath, setup, signal);
|
|
40
|
+
for (const file of fix.testFiles) {
|
|
41
|
+
if (file.startsWith('/') || file.split(/[\\/]/).some(part => part === '..' || part === '.git') || !/(^|\/)(__tests__|tests?)\/|\.(test|spec)\./.test(file)) return blocked('Regression files must be new tests within the worktree.');
|
|
42
|
+
const source = resolve(path, file), destination = resolve(baselinePath, file);
|
|
43
|
+
if (!source.startsWith(path + sep) || !destination.startsWith(baselinePath + sep)) return blocked('Regression file escaped the worktree.');
|
|
44
|
+
// Refuse symlinks throughout either path; do not copy arbitrary external files.
|
|
45
|
+
for (const root of [path, baselinePath]) {
|
|
46
|
+
let current = root;
|
|
47
|
+
for (const part of file.split('/')) {
|
|
48
|
+
current = join(current, part);
|
|
49
|
+
const stat = await lstat(current).catch(() => undefined);
|
|
50
|
+
if (stat?.isSymbolicLink()) return blocked('Regression paths must not contain symlinks.');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if ((await command(path, ['git', 'cat-file', '-e', `${base}:${file}`], signal)).code === 0) return blocked('Use a new regression test file so baseline assertions cannot be silently rewritten.');
|
|
54
|
+
await mkdir(dirname(destination), { recursive: true }); await copyFile(source, destination);
|
|
55
|
+
}
|
|
56
|
+
const files = [...new Set((await must(path, ['git', 'diff', '--name-only', '-z'], signal) + '\0' + await must(path, ['git', 'ls-files', '--others', '--exclude-standard', '-z'], signal)).split('\0').filter(Boolean))];
|
|
57
|
+
if (!files.length) return blocked('No changed files in the repair candidate.');
|
|
58
|
+
const branch = `signals/${issue.id.slice(0, 12)}-${Date.now()}`;
|
|
59
|
+
await must(path, ['git', 'switch', '-c', branch], signal);
|
|
60
|
+
await must(path, ['git', 'add', '--', ...files], signal);
|
|
61
|
+
const diff = await must(path, ['git', 'diff', '--cached', '--binary'], signal);
|
|
62
|
+
if (await protectOutboundData(diff) !== diff) return blocked('The candidate diff contains data caught by outbound protection; publication is blocked.');
|
|
63
|
+
await must(path, ['git', 'commit', '-m', `fix: ${issue.title}`], signal);
|
|
64
|
+
const head = await must(path, ['git', 'rev-parse', 'HEAD'], signal);
|
|
65
|
+
for (let sample = 0; sample < 3; sample++) {
|
|
66
|
+
const before = await command(baselinePath, fix.regressionCommand, signal); checks.push(before);
|
|
67
|
+
if (before.code === 0 || !before.output.includes(fix.failurePattern)) return blocked('The unchanged baseline did not consistently reproduce the claimed failure.');
|
|
68
|
+
const after = await command(path, fix.regressionCommand, signal); checks.push(after);
|
|
69
|
+
if (after.code !== 0) return blocked('The regression test still fails or is intermittent on the candidate.');
|
|
70
|
+
}
|
|
71
|
+
for (const test of repo.tests) {
|
|
72
|
+
const check = await command(path, test, signal); checks.push(check);
|
|
73
|
+
if (check.code !== 0) return blocked('A required package validation failed.');
|
|
74
|
+
}
|
|
75
|
+
await agent({ cwd: path, model: issue.model, writable: false, signal,
|
|
76
|
+
prompt: `Review repair ${head} against base ${base} for signal ${issue.id}. You use the same model and reasoning as the originating agent. Inspect git diff ${base}..${head}, the new regression tests and these measured checks. Use signal_evidence for original observations and signal_validation for full check output by index. Passing tests alone is insufficient: decide whether the change addresses the actual reported failure, whether the assertion is meaningful, and whether latency/quality comparisons have controlled baselines. Reject weakened tests, output suppression, unrelated changes and context/model degradation. Do not modify files or publish. Record signal_review, or leave the candidate blocked.\n${JSON.stringify(await protectOutboundData({ issue, fix, checks: checks.map(({ command, code, durationMs }, index) => ({ index, command, code, durationMs })) }))}`,
|
|
77
|
+
tools: [evidenceTool(observations).tool, { name: 'signal_validation', label: 'Read validation output', description: 'Read the complete recorded output of one check by its index.', parameters: Type.Object({ index: Type.Integer({ minimum: 0 }) }),
|
|
78
|
+
async execute(_id, raw) { const check = checks[(raw as { index: number }).index]; if (!check) throw new Error('Unknown validation index.'); return { content: [{ type: 'text', text: JSON.stringify(await protectOutboundData(check)) }], details: {} }; } },
|
|
79
|
+
{ name: 'signal_review', label: 'Review regression repair', description: 'Record the semantic review of the exact tested candidate.', parameters: ReviewSchema,
|
|
80
|
+
async execute(_id, input) { state.review = input as Static<typeof ReviewSchema>; return { content: [{ type: 'text', text: 'Review recorded.' }], details: {} }; } }],
|
|
81
|
+
});
|
|
82
|
+
if (!state.review?.fixesSignal || !state.review.testsMeaningful) return blocked(state.review?.explanation ?? 'No evidence-backed review approval.');
|
|
83
|
+
if (await must(path, ['git', 'rev-parse', 'HEAD'], signal) !== head || await must(path, ['git', 'status', '--porcelain'], signal)) return blocked('The candidate changed after verification.');
|
|
84
|
+
if (await must(repo.path, ['git', 'rev-parse', `${repo.base}^{commit}`], signal) !== base) return blocked('The base advanced; revalidation is required before merge.');
|
|
85
|
+
return { status: 'verified', explanation: state.review.explanation, base, head, branch, path, checks: await protectOutboundData(checks), review: await protectOutboundData(state.review) };
|
|
86
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { contentText } from '@earendil-works/pi-ai';
|
|
4
|
+
import { VERSION, type ExtensionContext, type SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { OBSERVATION, type Capture, type ReportInput, type Target } from './schema.ts';
|
|
6
|
+
|
|
7
|
+
export const entryText = (entry: SessionEntry): string => {
|
|
8
|
+
if (entry.type === 'message' && 'content' in entry.message) return contentText(entry.message.content);
|
|
9
|
+
if (entry.type === 'custom_message') return contentText(entry.content);
|
|
10
|
+
if (entry.type === 'custom' && entry.customType === OBSERVATION) return JSON.stringify(entry.data);
|
|
11
|
+
return '';
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function prepareReport(input: ReportInput, target: Target, ctx: ExtensionContext): Capture {
|
|
15
|
+
if (!input.title.trim() || !input.signature.trim() || !input.expected.trim() || !input.actual.trim()
|
|
16
|
+
|| !input.steps.length || input.steps.some(step => !step.trim()) || !input.evidence.length) throw new Error('Include a title, stable defect signature, reproduction steps, expected and actual results, and evidence.');
|
|
17
|
+
const branch = ctx.sessionManager.getBranch();
|
|
18
|
+
const evidence = input.evidence.map(item => {
|
|
19
|
+
const entry = branch.findLast(entry => (!item.entryId || entry.id === item.entryId) && entryText(entry).includes(item.quote));
|
|
20
|
+
if (!entry) throw new Error('Evidence must quote an original entry on this session branch. Inspect observations or use context_history to recover the exact text.');
|
|
21
|
+
const source = entry.type === 'message' ? entry.message.role : entry.type;
|
|
22
|
+
return { source, quote: item.quote, entryId: entry.id };
|
|
23
|
+
});
|
|
24
|
+
if (input.reproduced && !evidence.some(item => item.source === 'toolResult' || item.source === 'custom')) throw new Error('A reproduced defect needs tool or native SDK evidence; a claim in prose alone is not execution evidence.');
|
|
25
|
+
const signature = input.signature.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
26
|
+
const hash = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
27
|
+
const id = hash([target.id, signature]);
|
|
28
|
+
const observationId = hash([id, ctx.sessionManager.getSessionId(), evidence.map(item => item.entryId), evidence.map(item => item.quote)]);
|
|
29
|
+
const quote = (value: string) => value.split('\n').map(line => `> ${line}`).join('\n');
|
|
30
|
+
const body = [
|
|
31
|
+
`## Observed problem\n\n${input.actual}`,
|
|
32
|
+
`## Expected behavior\n\n${input.expected}`,
|
|
33
|
+
`## Reproduction\n\nStatus: ${input.reproduced ? 'reproduced by the reporting agent' : 'observed; reproduction not independently confirmed'}.\n\n${input.steps.map((step, i) => `${i + 1}. ${step}`).join('\n')}`,
|
|
34
|
+
`## Evidence\n\n${evidence.map(item => `Source: ${item.source}\n\n${quote(item.quote)}`).join('\n\n')}`,
|
|
35
|
+
`## Environment\n\n- Component: ${target.id}@${target.version}\n- Pi: ${VERSION}\n- Model: ${ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : 'unavailable'}\n- Reasoning: ${ctx.thinkingLevel ?? 'unspecified'}\n- Node: ${process.version}\n- Platform: ${process.platform}/${process.arch}\n- Category: ${input.category}`,
|
|
36
|
+
input.measurements ? `## Measurements\n\n${JSON.stringify(input.measurements)}` : '',
|
|
37
|
+
'Recorded by the active Pi agent from selected evidence. Triage must evaluate causality and reproducibility; this signal does not establish a general change in model intelligence.',
|
|
38
|
+
].filter(Boolean).join('\n\n');
|
|
39
|
+
return { id, observationId, component: target.id, version: target.version, category: input.category, payload: { title: input.title.trim(), body },
|
|
40
|
+
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id, thinkingLevel: ctx.thinkingLevel } : undefined };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Publication uses a minimal report, never a copied transcript or automatic file attachments. */
|
|
44
|
+
export function hideLocalPaths<T>(value: T, cwd: string): T {
|
|
45
|
+
const serialized = JSON.stringify(value);
|
|
46
|
+
const paths = new Map([[homedir(), '<home>'], [cwd, '<workspace>']]);
|
|
47
|
+
const protectedText = [...paths].filter(([path]) => path.length > 1).sort(([a], [b]) => b.length - a.length)
|
|
48
|
+
.reduce((text, [path, label]) => text.replaceAll(JSON.stringify(path).slice(1, -1), label), serialized);
|
|
49
|
+
return JSON.parse(protectedText) as T;
|
|
50
|
+
}
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, isAbsolute } from 'node:path';
|
|
4
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { command, must } from './commands.ts';
|
|
6
|
+
import { config, stateDir } from './config.ts';
|
|
7
|
+
|
|
8
|
+
const label = 'app.prjct.pi-signals';
|
|
9
|
+
const xml = (value: string) => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
10
|
+
export function launchAgent(entry: string) {
|
|
11
|
+
if (!isAbsolute(entry)) throw new Error('Scheduler requires the absolute installed CLI path.');
|
|
12
|
+
const settings = config();
|
|
13
|
+
const variables = { PATH: process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin', PI_CODING_AGENT_DIR: getAgentDir(),
|
|
14
|
+
...(process.env.PRJCT_HOME ? { PRJCT_HOME: process.env.PRJCT_HOME } : {}) };
|
|
15
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>
|
|
16
|
+
<key>Label</key><string>${label}</string>
|
|
17
|
+
<key>ProgramArguments</key><array>${[process.execPath, entry, 'once', '--scheduled'].map(value => `<string>${xml(value)}</string>`).join('')}</array>
|
|
18
|
+
<key>EnvironmentVariables</key><dict>${Object.entries(variables).map(([key, value]) => `<key>${key}</key><string>${xml(value)}</string>`).join('')}</dict>
|
|
19
|
+
<key>RunAtLoad</key><true/>
|
|
20
|
+
<key>StartCalendarInterval</key><dict><key>Minute</key><integer>${settings.minute}</integer></dict>
|
|
21
|
+
<key>StandardOutPath</key><string>${xml(join(stateDir(), 'worker.log'))}</string>
|
|
22
|
+
<key>StandardErrorPath</key><string>${xml(join(stateDir(), 'worker-error.log'))}</string>
|
|
23
|
+
<key>ProcessType</key><string>Background</string>
|
|
24
|
+
</dict></plist>\n`;
|
|
25
|
+
}
|
|
26
|
+
export async function installSchedule(entry: string) {
|
|
27
|
+
if (process.platform !== 'darwin') throw new Error('Use an OS scheduler to run pi-signals once --scheduled hourly on this platform.');
|
|
28
|
+
const directory = join(homedir(), 'Library', 'LaunchAgents'), file = join(directory, `${label}.plist`);
|
|
29
|
+
await mkdir(directory, { recursive: true }); await mkdir(stateDir(), { recursive: true, mode: 0o700 });
|
|
30
|
+
await writeFile(file, launchAgent(entry), { mode: 0o600 });
|
|
31
|
+
const domain = `gui/${process.getuid!()}`;
|
|
32
|
+
const loaded = await command(stateDir(), ['launchctl', 'print', `${domain}/${label}`]);
|
|
33
|
+
if (loaded.code !== 0) await must(stateDir(), ['launchctl', 'bootstrap', domain, file]);
|
|
34
|
+
return { installed: true, file, schedule: config().timezone, existingProcessesStopped: false };
|
|
35
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Type, type Static } from 'typebox';
|
|
2
|
+
|
|
3
|
+
const text = (maxLength: number) => Type.String({ minLength: 1, maxLength });
|
|
4
|
+
export const ReportSchema = Type.Object({
|
|
5
|
+
target: text(150),
|
|
6
|
+
title: text(160),
|
|
7
|
+
signature: text(240),
|
|
8
|
+
category: Type.Union([Type.Literal('latency'), Type.Literal('crash'), Type.Literal('response-quality')]),
|
|
9
|
+
steps: Type.Array(text(2000), { minItems: 1, maxItems: 20 }),
|
|
10
|
+
reproduced: Type.Boolean(),
|
|
11
|
+
expected: text(4000),
|
|
12
|
+
actual: text(4000),
|
|
13
|
+
evidence: Type.Array(Type.Object({ quote: Type.String({ minLength: 4, maxLength: 2000 }), entryId: Type.Optional(text(150)) }), { minItems: 1, maxItems: 8 }),
|
|
14
|
+
measurements: Type.Optional(Type.Object({ metric: text(150), unit: text(40), baseline: Type.Array(Type.Number({ minimum: 0 }), { minItems: 1, maxItems: 100 }), observed: Type.Array(Type.Number({ minimum: 0 }), { minItems: 1, maxItems: 100 }) })),
|
|
15
|
+
}, { additionalProperties: false });
|
|
16
|
+
export type ReportInput = Static<typeof ReportSchema>;
|
|
17
|
+
export const InspectSchema = Type.Object({
|
|
18
|
+
action: Type.Union([Type.Literal('targets'), Type.Literal('issues'), Type.Literal('observations')]),
|
|
19
|
+
issueId: Type.Optional(text(64)),
|
|
20
|
+
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
21
|
+
}, { additionalProperties: false });
|
|
22
|
+
export type Target = { id: string; version: string };
|
|
23
|
+
export type Payload = { title: string; body: string };
|
|
24
|
+
export const OBSERVATION = 'signal-observation';
|
|
25
|
+
export type SignalModel = { provider: string; id: string; thinkingLevel?: import('@earendil-works/pi-coding-agent').ExtensionContext['thinkingLevel'] };
|
|
26
|
+
export type Capture = { id: string; observationId: string; component: string; version: string; category: ReportInput['category']; payload: Payload; model?: SignalModel };
|
|
27
|
+
export const TriageSchema = Type.Object({
|
|
28
|
+
verdict: Type.Union([Type.Literal('attention'), Type.Literal('flaky'), Type.Literal('needs_evidence')]),
|
|
29
|
+
reason: text(6000), evidence: Type.Array(text(4000), { minItems: 1, maxItems: 20 }),
|
|
30
|
+
}, { additionalProperties: false });
|
|
31
|
+
export type Triage = Static<typeof TriageSchema>;
|