@prjct.app/pi-signals 0.1.2 → 0.2.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 CHANGED
@@ -1,3 +1,11 @@
1
+ ## 0.2.1 (2026-10-13)
2
+
3
+ - The `seen` list in `evidenceTool` is now a cell holding an immutable array. Each `execute` builds a new array with the additions appended instead of pushing in place; `contains` reads from the cell.
4
+
5
+ ## 0.2.0 (2026-10-09)
6
+
7
+ - `/signals` opens the shared docked panel, like the other prjct extensions. It lists what needs a fix first, then queued, running, flaky and resolved issues, each with component, status and sightings. An issue's detail shows the triage decision, recent observations and runs. The Daily triage row shows schedule, recent days, repositories and the repair setting. `r` queues an issue for triage again. Outside the TUI the same list prints as text instead of JSON.
8
+
1
9
  ## 0.1.2 (2026-10-09)
2
10
 
3
11
  - No system-prompt rule. signal_report no longer asks every session to watch Pi and extensions; its description says what it records and when to inspect targets first.
package/README.md CHANGED
@@ -24,7 +24,7 @@ Pi's private npm bin directory may not be on your shell PATH. In that case use `
24
24
 
25
25
  - `signal_report`: select minimal evidence, a stable signature identifying the component and failure mechanism, and one of the three signal categories. The same signature groups repeated sightings; repeated submission of the same session evidence is deduplicated.
26
26
  - `signal_inspect`: list `targets`, `issues`, or `observations`. Supply `issueId` for persisted issue observations. Pages have explicit continuation offsets; history is never trimmed.
27
- - `/signals`: show recent local issues and the configured daily time.
27
+ - `/signals`: the docked panel (shared with the other prjct extensions). What needs a fix comes first, then queued, running, flaky and resolved issues, each with its component, status and sightings. Enter shows the triage decision, recent observations and runs; the **Daily triage** row shows the schedule, recent days, repositories and repair setting. `r` queues an issue for triage again. Outside the TUI the same list prints as text.
28
28
  - `pi-signals status`, `show <id>` and `retry <id>`: inspect or explicitly requeue a signal.
29
29
  - `pi-signals once`: run queued triage/repair now. `once --scheduled` respects the daily clock and completed-day record.
30
30
  - `pi-signals schedule install`: install a macOS LaunchAgent using the absolute installed npm CLI path. An hourly OS wake checks the configured timezone; the worker runs once after the configured hour and catches up after sleep/login. There is no resident polling process inside Pi. Other platforms can schedule `once --scheduled` hourly with their OS scheduler.
package/dist/cli.mjs CHANGED
@@ -92,7 +92,13 @@ var init_store = __esm({
92
92
  return this.db.prepare("SELECT id FROM issues ORDER BY updated DESC,id LIMIT 21 OFFSET ?").all(offset).map((row) => this.get(String(row.id)));
93
93
  }
94
94
  observations(id, offset = 0, limit = 21) {
95
- return this.db.prepare("SELECT * FROM observations WHERE issueId=? ORDER BY created,id LIMIT ? OFFSET ?").all(id, limit, offset).map((row) => ({ ...row, payload: JSON.parse(String(row.payload)) }));
95
+ return this.db.prepare("SELECT * FROM observations WHERE issueId=? ORDER BY created,id LIMIT ? OFFSET ?").all(id, limit, offset).map((row) => ({
96
+ id: String(row.id),
97
+ issueId: String(row.issueId),
98
+ version: String(row.version),
99
+ created: Number(row.created),
100
+ payload: JSON.parse(String(row.payload))
101
+ }));
96
102
  }
97
103
  capture(value, now = Date.now()) {
98
104
  return this.transaction(() => {
@@ -154,8 +160,22 @@ var init_store = __esm({
154
160
  this.assertOwner(owner);
155
161
  this.db.exec("UPDATE issues SET status='new',lastError='Previous worker interrupted; evidence retained' WHERE status IN ('triaging','repairing'); UPDATE runs SET state='interrupted' WHERE state='running'");
156
162
  }
163
+ /** The latest daily triage days, newest first. */
164
+ days(limit = 7) {
165
+ return this.db.prepare("SELECT day,started,finished FROM schedule ORDER BY day DESC LIMIT ?").all(limit).map((row) => ({ day: String(row.day), started: Number(row.started), ...row.finished == null ? {} : { finished: Number(row.finished) } }));
166
+ }
157
167
  runs(id) {
158
- return this.db.prepare("SELECT * FROM runs WHERE issueId=? ORDER BY started").all(id).map((row) => ({ ...row, id: String(row.id), state: String(row.state), result: row.result ? JSON.parse(String(row.result)) : void 0 }));
168
+ return this.db.prepare("SELECT * FROM runs WHERE issueId=? ORDER BY started").all(id).map((row) => ({
169
+ id: String(row.id),
170
+ issueId: String(row.issueId),
171
+ revision: Number(row.revision),
172
+ phase: String(row.phase),
173
+ owner: String(row.owner),
174
+ state: String(row.state),
175
+ started: Number(row.started),
176
+ ...row.finished == null ? {} : { finished: Number(row.finished) },
177
+ result: row.result ? JSON.parse(String(row.result)) : void 0
178
+ }));
159
179
  }
160
180
  };
161
181
  }
@@ -814,7 +834,7 @@ var init_schema = __esm({
814
834
  // src/evidence.ts
815
835
  import { Type as Type2 } from "typebox";
816
836
  function evidenceTool(source) {
817
- const seen = [];
837
+ const seen = { items: [] };
818
838
  const tool = {
819
839
  name: "signal_evidence",
820
840
  label: "Read original signal evidence",
@@ -824,14 +844,16 @@ function evidenceTool(source) {
824
844
  const input = raw, offset = input.offset ?? 0, limit = input.limit ?? 1;
825
845
  const rows = typeof source === "function" ? source(offset, limit + 1) : source.slice(offset, offset + limit + 1);
826
846
  const items = await protectOutboundData(rows.slice(0, limit));
847
+ const additions = [];
827
848
  for (const item of items) {
828
849
  const payload = item.payload;
829
- seen.push(payload?.body ?? JSON.stringify(item));
850
+ additions.push(payload?.body ?? JSON.stringify(item));
830
851
  }
852
+ if (additions.length) seen.items = [...seen.items, ...additions];
831
853
  return { content: [{ type: "text", text: JSON.stringify({ items, nextOffset: rows.length > limit ? offset + limit : null }) }], details: {} };
832
854
  }
833
855
  };
834
- return { tool, contains: (quote2) => seen.some((text2) => text2.includes(quote2)) };
856
+ return { tool, contains: (quote2) => seen.items.some((text2) => text2.includes(quote2)) };
835
857
  }
836
858
  var init_evidence = __esm({
837
859
  "src/evidence.ts"() {
@@ -933,10 +955,10 @@ ${JSON.stringify({ issue })}`,
933
955
  const source = resolve(path, file), destination = resolve(baselinePath, file);
934
956
  if (!source.startsWith(path + sep2) || !destination.startsWith(baselinePath + sep2)) return blocked("Regression file escaped the worktree.");
935
957
  for (const root of [path, baselinePath]) {
936
- let current = root;
958
+ const cursor = { current: root };
937
959
  for (const part of file.split("/")) {
938
- current = join6(current, part);
939
- const stat = await lstat(current).catch(() => void 0);
960
+ cursor.current = join6(cursor.current, part);
961
+ const stat = await lstat(cursor.current).catch(() => void 0);
940
962
  if (stat?.isSymbolicLink()) return blocked("Regression paths must not contain symlinks.");
941
963
  }
942
964
  }
@@ -953,7 +975,7 @@ ${JSON.stringify({ issue })}`,
953
975
  if (await protectOutboundData(diff) !== diff) return blocked("The candidate diff contains data caught by outbound protection; publication is blocked.");
954
976
  await must(path, ["git", "commit", "-m", `fix: ${issue.title}`], signal);
955
977
  const head = await must(path, ["git", "rev-parse", "HEAD"], signal);
956
- for (let sample = 0; sample < 3; sample++) {
978
+ for (const _sample of [0, 1, 2]) {
957
979
  const before = await command(baselinePath, fix.regressionCommand, signal);
958
980
  checks.push(before);
959
981
  if (before.code === 0 || !before.output.includes(fix.failurePattern)) return blocked("The unchanged baseline did not consistently reproduce the claimed failure.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prjct.app/pi-signals",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Local failure signals, daily agent-owned triage and verified repair candidates for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,7 +32,8 @@
32
32
  "bin"
33
33
  ],
34
34
  "dependencies": {
35
- "@prjct.app/pi-secrets": "^0.2.6"
35
+ "@prjct.app/pi-secrets": "^0.2.6",
36
+ "@prjct.app/pi-tui-kit": "^0.3.5"
36
37
  },
37
38
  "peerDependencies": {
38
39
  "@earendil-works/pi-ai": ">=1.0.4",
@@ -50,7 +51,7 @@
50
51
  },
51
52
  "scripts": {
52
53
  "check": "tsc --noEmit",
53
- "test": "node --import ./tests/isolate.mjs --import tsx --test --test-concurrency=2 tests/*.test.ts",
54
+ "test": "export PI_CODING_AGENT_DIR=\"$(mktemp -d)\" PRJCT_HOME=\"$(mktemp -d)\" && node --import ./tests/isolate.mjs --import tsx --test --test-concurrency=2 tests/*.test.ts",
54
55
  "check:package": "npm pack --dry-run --ignore-scripts",
55
56
  "build": "node scripts/build.mjs",
56
57
  "prepack": "npm run build",
package/src/evidence.ts CHANGED
@@ -5,7 +5,8 @@ import { protectOutboundData } from '@prjct.app/pi-secrets/privacy';
5
5
  export type EvidenceSource = readonly unknown[] | ((offset: number, limit: number) => unknown[]);
6
6
  /** Keep full observations in storage; the deciding agent chooses which pages to read. */
7
7
  export function evidenceTool(source: EvidenceSource) {
8
- const seen: string[] = [];
8
+ // The one cell that changes: it holds the current list, which is always replaced, never edited.
9
+ const seen: { items: readonly string[] } = { items: [] };
9
10
  const tool: ToolDefinition = {
10
11
  name: 'signal_evidence', label: 'Read original signal evidence',
11
12
  description: 'Read original issue observations by page. Nothing is discarded; follow nextOffset to inspect all sightings. Select a smaller limit for large reports.',
@@ -14,12 +15,14 @@ export function evidenceTool(source: EvidenceSource) {
14
15
  const input = raw as { offset?: number; limit?: number }, offset = input.offset ?? 0, limit = input.limit ?? 1;
15
16
  const rows = typeof source === 'function' ? source(offset, limit + 1) : source.slice(offset, offset + limit + 1);
16
17
  const items = await protectOutboundData(rows.slice(0, limit));
18
+ const additions: string[] = [];
17
19
  for (const item of items) {
18
20
  const payload = (item as { payload?: { body?: string } }).payload;
19
- seen.push(payload?.body ?? JSON.stringify(item));
21
+ additions.push(payload?.body ?? JSON.stringify(item));
20
22
  }
23
+ if (additions.length) seen.items = [...seen.items, ...additions];
21
24
  return { content: [{ type: 'text', text: JSON.stringify({ items, nextOffset: rows.length > limit ? offset + limit : null }) }], details: {} };
22
25
  },
23
26
  };
24
- return { tool, contains: (quote: string) => seen.some(text => text.includes(quote)) };
27
+ return { tool, contains: (quote: string) => seen.items.some(text => text.includes(quote)) };
25
28
  }
package/src/index.ts CHANGED
@@ -5,6 +5,8 @@ import { hideLocalPaths, prepareReport } from './report.ts';
5
5
  import { InspectSchema, OBSERVATION, ReportSchema } from './schema.ts';
6
6
  import { SignalStore } from './store.ts';
7
7
  import { rememberHost } from './host.ts';
8
+ import { brand, openPanel, panelText } from '@prjct.app/pi-tui-kit';
9
+ import { signalPanelSpec, type SignalPanelOps } from './panel.ts';
8
10
 
9
11
  const defaults = { config, targets, protect: protectOutboundData, databasePath };
10
12
  export type SignalsDependencies = Partial<typeof defaults>;
@@ -48,13 +50,36 @@ export function installSignals(pi: ExtensionAPI, dependencies: SignalsDependenci
48
50
  finally { store.close(); }
49
51
  },
50
52
  });
53
+ const withStore = <T>(read: (store: SignalStore) => T): T => {
54
+ const store = new SignalStore(deps.databasePath());
55
+ try { return read(store); } finally { store.close(); }
56
+ };
57
+ /** One snapshot per panel draw; a change clears it. Every issue, not one page. */
58
+ const panelOps = (): SignalPanelOps => {
59
+ const cache: { issues?: ReturnType<SignalStore['list']> } = {};
60
+ const all = (store: SignalStore) => {
61
+ const pages = (offset: number): ReturnType<SignalStore['list']> => {
62
+ const rows = store.list(offset);
63
+ return rows.length > 20 ? [...rows.slice(0, 20), ...pages(offset + 20)] : rows;
64
+ };
65
+ return pages(0);
66
+ };
67
+ return {
68
+ config: () => deps.config(),
69
+ issues: () => (cache.issues ??= withStore(all)),
70
+ observations: id => withStore(store => store.observations(id, 0, 50)),
71
+ runs: id => withStore(store => store.runs(id)),
72
+ days: () => withStore(store => store.days()),
73
+ retry: id => { cache.issues = undefined; return withStore(store => store.retry(id)); },
74
+ };
75
+ };
51
76
  pi.registerCommand('signals', {
52
- description: 'Show local issues and daily triage configuration',
77
+ description: brand('signals: local issues in Pi and extensions, what to fix, and the daily triage'),
53
78
  async handler(_args, ctx) {
54
79
  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(); }
80
+ const spec = signalPanelSpec(panelOps());
81
+ if (ctx.mode === 'tui' && ctx.hasUI && typeof ctx.ui.custom === 'function') await openPanel(ctx, spec);
82
+ else ctx.ui.notify(panelText(spec), 'info');
58
83
  } catch { ctx.ui.notify('Cannot read signals configuration or database.', 'error'); }
59
84
  },
60
85
  });
package/src/panel.ts ADDED
@@ -0,0 +1,129 @@
1
+ import { SYMBOL, ago, type PanelAction, type PanelDetail, type PanelItem, type PanelSpec, type Tone } from '@prjct.app/pi-tui-kit';
2
+ import type { Config } from './config.ts';
3
+ import type { LocalIssue, Status } from './store.ts';
4
+
5
+ /** What the panel reads and the one change it can make. Each call opens and closes the store. */
6
+ export type SignalPanelOps = {
7
+ readonly config: () => Config;
8
+ readonly issues: () => readonly LocalIssue[];
9
+ readonly observations: (id: string) => readonly { created: number; version: string; payload: { title: string; body: string } }[];
10
+ readonly runs: (id: string) => readonly { phase: string; state: string; started: number; finished?: number; result?: unknown }[];
11
+ readonly days: () => readonly { day: string; started: number; finished?: number }[];
12
+ /** Queue an issue for triage again; false when a worker holds it. */
13
+ readonly retry: (id: string) => boolean;
14
+ readonly now?: () => number;
15
+ };
16
+
17
+ const TRIAGE = '__triage__';
18
+ const STATUS: Readonly<Record<Status, { word: string; short: string; symbol: string; tone: Tone; rank: number }>> = {
19
+ attention: { word: 'needs a fix', short: 'fix', symbol: SYMBOL.attention, tone: 'warning', rank: 0 },
20
+ blocked: { word: 'repair blocked', short: 'blocked', symbol: SYMBOL.error, tone: 'error', rank: 1 },
21
+ needs_evidence: { word: 'needs evidence', short: 'evidence', symbol: SYMBOL.attention, tone: 'warning', rank: 2 },
22
+ new: { word: 'waiting for triage', short: 'queued', symbol: SYMBOL.idle, tone: 'accent', rank: 3 },
23
+ triaging: { word: 'triaging', short: 'triaging', symbol: SYMBOL.active, tone: 'accent', rank: 4 },
24
+ repairing: { word: 'repairing', short: 'repairing', symbol: SYMBOL.active, tone: 'accent', rank: 5 },
25
+ flaky: { word: 'flaky', short: 'flaky', symbol: SYMBOL.idle, tone: 'muted', rank: 6 },
26
+ verified: { word: 'fix verified', short: 'verified', symbol: SYMBOL.ok, tone: 'success', rank: 7 },
27
+ resolved: { word: 'resolved', short: 'resolved', symbol: SYMBOL.ok, tone: 'success', rank: 8 },
28
+ };
29
+ /** What still needs someone: a fix, a repair that failed, or more evidence. */
30
+ const TO_FIX: readonly Status[] = ['attention', 'blocked', 'needs_evidence'];
31
+ const RETRYABLE: readonly Status[] = ['attention', 'blocked', 'needs_evidence', 'flaky', 'verified', 'resolved'];
32
+
33
+ const at = (config: Config): string => `${String(config.hour).padStart(2, '0')}:${String(config.minute).padStart(2, '0')} ${config.timezone}`;
34
+ const firstLine = (text: string): string => text.split('\n').map(line => line.trim()).find(Boolean) ?? '';
35
+ const short = (value: unknown): string => {
36
+ if (value === undefined || value === null) return '';
37
+ const record = value as { status?: unknown; explanation?: unknown; verdict?: unknown };
38
+ return [record.status ?? record.verdict, record.explanation].filter(part => typeof part === 'string' && part).join(' · ').slice(0, 200);
39
+ };
40
+
41
+ export function signalPanelSpec(ops: SignalPanelOps, initial?: string): PanelSpec {
42
+ const now = (): number => ops.now?.() ?? Date.now();
43
+ const sorted = (): readonly LocalIssue[] => [...ops.issues()]
44
+ .sort((a, b) => STATUS[a.status].rank - STATUS[b.status].rank || b.updated - a.updated);
45
+ const issueOf = (item: PanelItem | undefined): LocalIssue | undefined =>
46
+ item && item.id !== TRIAGE ? ops.issues().find(issue => issue.id === item.id) : undefined;
47
+
48
+ const triageDetail = (): PanelDetail => {
49
+ const config = ops.config();
50
+ const repositories = Object.entries(config.repositories);
51
+ return {
52
+ title: 'Daily triage',
53
+ subtitle: config.enabled ? `runs every day at ${at(config)}` : 'off: signals are disabled in the global configuration',
54
+ subtitleTone: config.enabled ? 'muted' : 'warning',
55
+ fields: [
56
+ { label: 'Signals', value: config.enabled ? 'on' : 'off', tone: config.enabled ? 'success' : 'warning' },
57
+ { label: 'Repair', value: config.repair ? 'on: verified fixes are prepared in a worktree' : 'off: triage only' },
58
+ { label: 'Watching', value: config.packages.length ? config.packages.join(', ') : 'Pi and installed extensions' },
59
+ { label: 'Repositories', value: repositories.length ? repositories.map(([name, repo]) => `${name} (${repo.base})`).join(', ') : 'none configured' },
60
+ ],
61
+ sections: [
62
+ { title: 'Recent days', lines: ops.days().map(day => `${day.day} ${day.finished ? `finished ${ago(day.finished, now())}` : `started ${ago(day.started, now())}, not finished`}`) },
63
+ ...(ops.issues().length ? [] : [{ title: 'No issues yet', lines: [
64
+ 'When Pi or an installed extension shows a latency regression, a crash or a concrete response-quality regression, the agent can record it with signal_report.',
65
+ 'Recorded issues appear here and are triaged daily.',
66
+ ] }]),
67
+ ],
68
+ };
69
+ };
70
+
71
+ const issueDetail = (issue: LocalIssue): PanelDetail => {
72
+ const status = STATUS[issue.status];
73
+ const observations = [...ops.observations(issue.id)].sort((a, b) => b.created - a.created).slice(0, 5);
74
+ const runs = [...ops.runs(issue.id)].sort((a, b) => b.started - a.started).slice(0, 5);
75
+ return {
76
+ title: issue.title,
77
+ subtitle: `${issue.component} · ${issue.category}`,
78
+ fields: [
79
+ { label: 'Status', value: status.word, tone: status.tone },
80
+ { label: 'Seen', value: `${issue.sightings} ${issue.sightings === 1 ? 'time' : 'times'}` },
81
+ { label: 'First seen', value: ago(issue.created, now()) },
82
+ { label: 'Updated', value: ago(issue.updated, now()) },
83
+ ...(issue.model ? [{ label: 'Model', value: `${issue.model.provider}/${issue.model.id}` }] : []),
84
+ ...(issue.lastError ? [{ label: 'Last error', value: issue.lastError, tone: 'error' as const }] : []),
85
+ ],
86
+ sections: [
87
+ ...(issue.decision ? [{ title: `Triage: ${issue.decision.verdict}`, lines: [issue.decision.reason, ...issue.decision.evidence.map(line => `· ${line}`)] }] : []),
88
+ { title: 'Observations', lines: observations.map(item => `${ago(item.created, now())} · ${item.version} · ${firstLine(item.payload.title) || firstLine(item.payload.body)}`) },
89
+ { title: 'Runs', lines: runs.map(run => `${run.phase} ${run.state} · ${ago(run.started, now())}${short(run.result) ? ` · ${short(run.result)}` : ''}`) },
90
+ ],
91
+ };
92
+ };
93
+
94
+ const actions: PanelAction[] = [{
95
+ key: 'r', label: 'Queue for triage again',
96
+ when: item => { const issue = issueOf(item); return !!issue && RETRYABLE.includes(issue.status); },
97
+ run: (item, panel) => {
98
+ const issue = issueOf(item)!;
99
+ const queued = ops.retry(issue.id);
100
+ panel.notice(queued ? `${issue.title}: queued for the next triage.` : `${issue.title}: a worker is on it; try again later.`, queued ? 'success' : 'warning');
101
+ panel.refresh();
102
+ },
103
+ }];
104
+
105
+ return {
106
+ title: 'Signals',
107
+ summary: () => {
108
+ const issues = ops.issues(), config = ops.config();
109
+ const toFix = issues.filter(issue => TO_FIX.includes(issue.status)).length;
110
+ return [`${issues.length} ${issues.length === 1 ? 'issue' : 'issues'}`, `${toFix} to fix`,
111
+ config.enabled ? `triage ${at(config)}` : 'triage off'].join(' · ');
112
+ },
113
+ items: () => {
114
+ const config = ops.config();
115
+ // The list column is narrow: the label carries what it is, the meta one short fact.
116
+ const triage: PanelItem = { id: TRIAGE, label: 'Daily triage', symbol: config.enabled ? SYMBOL.active : SYMBOL.idle,
117
+ tone: config.enabled ? 'success' : 'warning', meta: config.enabled ? at(config).split(' ')[0] : 'off', search: config.timezone };
118
+ return [triage, ...sorted().map(issue => {
119
+ const status = STATUS[issue.status];
120
+ return { id: issue.id, label: `${issue.component} · ${issue.title}`, symbol: status.symbol, tone: status.tone,
121
+ meta: `${status.short} · ${issue.sightings}×`, search: `${issue.category} ${issue.status} ${status.word}` };
122
+ })];
123
+ },
124
+ detail: item => { const issue = issueOf(item); return issue ? issueDetail(issue) : triageDetail(); },
125
+ actions,
126
+ empty: 'No issues yet.',
127
+ initial: initial ?? sorted()[0]?.id ?? TRIAGE,
128
+ };
129
+ }
package/src/repair.ts CHANGED
@@ -43,10 +43,10 @@ export async function repair(issue: LocalIssue, repo: Repository, path: string,
43
43
  if (!source.startsWith(path + sep) || !destination.startsWith(baselinePath + sep)) return blocked('Regression file escaped the worktree.');
44
44
  // Refuse symlinks throughout either path; do not copy arbitrary external files.
45
45
  for (const root of [path, baselinePath]) {
46
- let current = root;
46
+ const cursor = { current: root };
47
47
  for (const part of file.split('/')) {
48
- current = join(current, part);
49
- const stat = await lstat(current).catch(() => undefined);
48
+ cursor.current = join(cursor.current, part);
49
+ const stat = await lstat(cursor.current).catch(() => undefined);
50
50
  if (stat?.isSymbolicLink()) return blocked('Regression paths must not contain symlinks.');
51
51
  }
52
52
  }
@@ -62,7 +62,7 @@ export async function repair(issue: LocalIssue, repo: Repository, path: string,
62
62
  if (await protectOutboundData(diff) !== diff) return blocked('The candidate diff contains data caught by outbound protection; publication is blocked.');
63
63
  await must(path, ['git', 'commit', '-m', `fix: ${issue.title}`], signal);
64
64
  const head = await must(path, ['git', 'rev-parse', 'HEAD'], signal);
65
- for (let sample = 0; sample < 3; sample++) {
65
+ for (const _sample of [0, 1, 2]) {
66
66
  const before = await command(baselinePath, fix.regressionCommand, signal); checks.push(before);
67
67
  if (before.code === 0 || !before.output.includes(fix.failurePattern)) return blocked('The unchanged baseline did not consistently reproduce the claimed failure.');
68
68
  const after = await command(path, fix.regressionCommand, signal); checks.push(after);
package/src/store.ts CHANGED
@@ -34,7 +34,8 @@ export class SignalStore {
34
34
  list(offset = 0) { return this.db.prepare('SELECT id FROM issues ORDER BY updated DESC,id LIMIT 21 OFFSET ?').all(offset).map(row => this.get(String(row.id))!); }
35
35
  observations(id: string, offset = 0, limit = 21) {
36
36
  return this.db.prepare('SELECT * FROM observations WHERE issueId=? ORDER BY created,id LIMIT ? OFFSET ?').all(id, limit, offset)
37
- .map(row => ({ ...row, payload: JSON.parse(String(row.payload)) }));
37
+ .map(row => ({ id: String(row.id), issueId: String(row.issueId), version: String(row.version), created: Number(row.created),
38
+ payload: JSON.parse(String(row.payload)) as { title: string; body: string } }));
38
39
  }
39
40
  capture(value: Capture, now = Date.now()) {
40
41
  return this.transaction(() => {
@@ -91,5 +92,16 @@ export class SignalStore {
91
92
  // An interrupted run never counts as fixed.
92
93
  this.db.exec("UPDATE issues SET status='new',lastError='Previous worker interrupted; evidence retained' WHERE status IN ('triaging','repairing'); UPDATE runs SET state='interrupted' WHERE state='running'");
93
94
  }
94
- runs(id: string) { return this.db.prepare('SELECT * FROM runs WHERE issueId=? ORDER BY started').all(id).map(row => ({ ...row, id: String(row.id), state: String(row.state), result: row.result ? JSON.parse(String(row.result)) : undefined })); }
95
+ /** The latest daily triage days, newest first. */
96
+ days(limit = 7) {
97
+ return this.db.prepare('SELECT day,started,finished FROM schedule ORDER BY day DESC LIMIT ?').all(limit)
98
+ .map(row => ({ day: String(row.day), started: Number(row.started), ...(row.finished == null ? {} : { finished: Number(row.finished) }) }));
99
+ }
100
+ runs(id: string) {
101
+ return this.db.prepare('SELECT * FROM runs WHERE issueId=? ORDER BY started').all(id).map(row => ({
102
+ id: String(row.id), issueId: String(row.issueId), revision: Number(row.revision), phase: String(row.phase), owner: String(row.owner),
103
+ state: String(row.state), started: Number(row.started), ...(row.finished == null ? {} : { finished: Number(row.finished) }),
104
+ result: row.result ? JSON.parse(String(row.result)) as unknown : undefined,
105
+ }));
106
+ }
95
107
  }