@haystackeditor/cli 0.15.30 → 0.15.31

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.
@@ -27,5 +27,7 @@ export interface DesignVerifyOptions {
27
27
  cwd?: string;
28
28
  commandTimeout?: string;
29
29
  action?: string;
30
+ history?: boolean;
31
+ limit?: string;
30
32
  }
31
33
  export declare function designVerifyCommand(identifier: string, options: DesignVerifyOptions): Promise<void>;
@@ -16,8 +16,9 @@
16
16
  import chalk from 'chalk';
17
17
  import { loadToken } from '../utils/auth.js';
18
18
  import { parsePrRef } from '../utils/pr-ref.js';
19
- import { getDesignVerifierUniverseAction, startDesignVerifierUniverseAction, triggerDesignVerifier, waitForDesignVerifier, waitForDesignVerifierUniverseAction, } from '../utils/design-verifier-api.js';
19
+ import { getDesignVerifierHistory, getDesignVerifierUniverseAction, startDesignVerifierUniverseAction, triggerDesignVerifier, waitForDesignVerifier, waitForDesignVerifierUniverseAction, } from '../utils/design-verifier-api.js';
20
20
  import { buildDesignVerifierAgentReport, designVerifierStageLabel, formatDesignVerifierAgentReport, } from '../utils/design-verifier-result.js';
21
+ import { formatDesignVerifierHistory } from '../utils/design-verifier-history.js';
21
22
  function emit(json, payload, human) {
22
23
  if (json) {
23
24
  console.log(JSON.stringify(payload));
@@ -189,6 +190,31 @@ export async function designVerifyCommand(identifier, options) {
189
190
  process.exitCode = 1;
190
191
  return;
191
192
  }
193
+ if (options.history || options.limit !== undefined) {
194
+ try {
195
+ if (!options.history)
196
+ throw new Error('--limit requires --history');
197
+ if (options.run || options.case || options.universe || options.replay || options.execJson ||
198
+ options.cwd || options.commandTimeout || options.action || options.wait === false) {
199
+ throw new Error('--history cannot be combined with run or universe operations');
200
+ }
201
+ const limit = Number(options.limit ?? '20');
202
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
203
+ throw new Error('--limit must be between 1 and 100');
204
+ }
205
+ const loaded = await getDesignVerifierHistory(pr.owner, pr.repo, token, limit);
206
+ if (loaded.status !== 'ok')
207
+ throw new Error(loaded.message);
208
+ emit(options.json, { ...loaded.history }, () => console.log(formatDesignVerifierHistory(loaded.history)));
209
+ }
210
+ catch (error) {
211
+ emit(options.json, { ok: false, error: error.message }, () => {
212
+ console.error(chalk.red(error.message));
213
+ });
214
+ process.exitCode = 2;
215
+ }
216
+ return;
217
+ }
192
218
  const pollIntervalSeconds = Number(options.pollInterval || '3');
193
219
  const timeoutSeconds = Number(options.timeout || '7200');
194
220
  if (!(Number.isFinite(pollIntervalSeconds) && pollIntervalSeconds >= 1 && pollIntervalSeconds <= 60)) {
@@ -0,0 +1,14 @@
1
+ import type { VerifyHistoryReport } from '../types/verify-history.js';
2
+ export interface VerifyHistoryOptions {
3
+ limit?: string;
4
+ account?: string;
5
+ json?: boolean;
6
+ }
7
+ /** Reject contradictory or mismatched data before printing health or findings. */
8
+ export declare function parseVerifyHistoryReport(value: unknown, expected: {
9
+ owner: string;
10
+ name: string;
11
+ limit: number;
12
+ }): VerifyHistoryReport;
13
+ export declare function formatVerifyHistory(report: VerifyHistoryReport): string;
14
+ export declare function verifyHistoryCommand(repository: string, options: VerifyHistoryOptions): Promise<void>;
@@ -0,0 +1,152 @@
1
+ import { resolveAuthContext } from '../utils/auth.js';
2
+ import { haystackJson, HaystackApiError } from '../utils/haystack-api.js';
3
+ const HEALTH = ['healthy', 'degraded', 'infrastructure-error', 'not-executed', 'unknown'];
4
+ const LIFECYCLES = ['complete', 'failed', 'cancelled', 'budget-stopped', 'account-action'];
5
+ const RUN_ID = /^cv_[0-9a-f]{48}$/;
6
+ const COMMIT = /^[0-9a-f]{40}$/;
7
+ const REPO_COMPONENT = /^(?!\.{1,2}$)[A-Za-z0-9_.-]{1,100}$/;
8
+ function record(value) {
9
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
10
+ }
11
+ function count(value) {
12
+ return Number.isSafeInteger(value) && value >= 0;
13
+ }
14
+ function timestamp(value) {
15
+ return typeof value === 'string' && Number.isFinite(Date.parse(value));
16
+ }
17
+ /** Reject contradictory or mismatched data before printing health or findings. */
18
+ export function parseVerifyHistoryReport(value, expected) {
19
+ const invalid = () => {
20
+ throw new Error('The hosted verifier returned invalid or mismatched history. No reliability summary was accepted.');
21
+ };
22
+ if (!record(value) || value.version !== 'dv-verify-history-v1'
23
+ || value.provider !== 'haystack' || value.scope !== 'user'
24
+ || !record(value.repository)
25
+ || typeof value.repository.owner !== 'string' || typeof value.repository.name !== 'string'
26
+ || value.repository.owner !== expected.owner
27
+ || value.repository.name !== expected.name
28
+ || typeof value.repository.repositoryId !== 'string'
29
+ || !/^github\.com:[1-9]\d*$/.test(value.repository.repositoryId)
30
+ || value.limit !== expected.limit || typeof value.hasMore !== 'boolean'
31
+ || !Array.isArray(value.runs) || value.runs.length > expected.limit
32
+ || (value.hasMore && value.runs.length !== expected.limit)
33
+ || !record(value.summary) || !record(value.summary.counts))
34
+ return invalid();
35
+ const counts = {
36
+ healthy: 0, degraded: 0, 'infrastructure-error': 0, 'not-executed': 0, unknown: 0,
37
+ };
38
+ const seen = new Set();
39
+ let usable = 0;
40
+ let previousCompletedAt = Infinity;
41
+ for (const run of value.runs) {
42
+ if (!record(run) || typeof run.runId !== 'string' || !RUN_ID.test(run.runId)
43
+ || seen.has(run.runId)
44
+ || typeof run.baseCommit !== 'string' || !COMMIT.test(run.baseCommit)
45
+ || typeof run.headCommit !== 'string' || !COMMIT.test(run.headCommit)
46
+ || !timestamp(run.createdAt) || !timestamp(run.completedAt)
47
+ || Date.parse(run.completedAt) < Date.parse(run.createdAt)
48
+ || Date.parse(run.completedAt) > previousCompletedAt
49
+ || (run.status !== 'complete' && run.status !== 'errored')
50
+ || (run.lifecycle !== null && !LIFECYCLES.includes(run.lifecycle))
51
+ || !HEALTH.includes(run.health)
52
+ || (run.regressionCount !== null && !count(run.regressionCount))
53
+ || (run.executedCaseCount !== null && !count(run.executedCaseCount))
54
+ || !Array.isArray(run.degradationCodes)
55
+ || run.degradationCodes.some(code => typeof code !== 'string' || code.length === 0)
56
+ || new Set(run.degradationCodes).size !== run.degradationCodes.length
57
+ || (run.supersededBy !== null && (typeof run.supersededBy !== 'string'
58
+ || !RUN_ID.test(run.supersededBy) || run.supersededBy === run.runId)))
59
+ return invalid();
60
+ if (run.health === 'unknown') {
61
+ if (run.status !== 'errored' || run.lifecycle !== null || run.regressionCount !== null
62
+ || run.executedCaseCount !== null || run.degradationCodes.length !== 0)
63
+ return invalid();
64
+ }
65
+ else if (run.lifecycle === null || run.regressionCount === null || run.executedCaseCount === null) {
66
+ return invalid();
67
+ }
68
+ else if ((run.lifecycle === 'complete') !== (run.status === 'complete')) {
69
+ return invalid();
70
+ }
71
+ if (run.health === 'healthy' && (run.lifecycle !== 'complete'
72
+ || run.executedCaseCount === 0 || run.degradationCodes.length !== 0))
73
+ return invalid();
74
+ if (run.health === 'not-executed' && (run.executedCaseCount !== 0 || run.regressionCount !== 0))
75
+ return invalid();
76
+ if (run.executedCaseCount === 0 && run.regressionCount !== null && run.regressionCount !== 0)
77
+ return invalid();
78
+ seen.add(run.runId);
79
+ previousCompletedAt = Date.parse(run.completedAt);
80
+ counts[run.health]++;
81
+ if ((run.health === 'healthy' || run.health === 'degraded')
82
+ && typeof run.executedCaseCount === 'number' && run.executedCaseCount > 0)
83
+ usable++;
84
+ }
85
+ const usableRate = value.runs.length === 0 ? null : usable / value.runs.length;
86
+ const summaryCounts = value.summary.counts;
87
+ if (value.summary.total !== value.runs.length || value.summary.usableRate !== usableRate
88
+ || HEALTH.some(health => summaryCounts[health] !== counts[health]))
89
+ return invalid();
90
+ return value;
91
+ }
92
+ function terminalSafe(value) {
93
+ return Array.from(value).filter(character => {
94
+ const point = character.codePointAt(0);
95
+ return point >= 32 && !(point >= 127 && point <= 159);
96
+ }).join('');
97
+ }
98
+ export function formatVerifyHistory(report) {
99
+ const { summary } = report;
100
+ const lines = [
101
+ `Your verification history for ${report.repository.owner}/${report.repository.name}`,
102
+ `Latest ${summary.total} finished runs for the selected account (limit ${report.limit}).`,
103
+ ];
104
+ if (summary.total === 0) {
105
+ lines.push('No finished hosted fleet runs found for this account and repository.');
106
+ return lines.join('\n');
107
+ }
108
+ const usable = report.runs.filter(run => (run.health === 'healthy' || run.health === 'degraded')
109
+ && run.executedCaseCount !== null && run.executedCaseCount > 0).length;
110
+ lines.push(`Usable evidence: ${Math.round(summary.usableRate * 100)}% of these runs (${usable}/${summary.total}).`, `Healthy: ${summary.counts.healthy}; partially degraded: ${summary.counts.degraded}; infrastructure error: ${summary.counts['infrastructure-error']}; not executed: ${summary.counts['not-executed']}; unknown: ${summary.counts.unknown}.`, 'Usable means healthy or partially degraded with at least one executed check.', 'This measures execution reliability. Bugs found are listed separately; they do not lower this rate.', '');
111
+ for (const run of report.runs) {
112
+ const health = run.health === 'degraded' ? 'partially degraded' : run.health;
113
+ const checks = run.executedCaseCount === null ? 'unknown checks executed' : `${run.executedCaseCount} checks executed`;
114
+ const regressions = run.regressionCount === null ? 'bugs found unknown'
115
+ : `${run.regressionCount} ${run.regressionCount === 1 ? 'bug' : 'bugs'} found`;
116
+ lines.push(`${terminalSafe(run.completedAt)} ${health} ${checks} ${regressions}`, ` ${run.runId} ${run.baseCommit.slice(0, 12)} -> ${run.headCommit.slice(0, 12)}`);
117
+ if (run.lifecycle && run.lifecycle !== 'complete')
118
+ lines.push(` Run outcome: ${run.lifecycle}`);
119
+ if (run.degradationCodes.length)
120
+ lines.push(` Limitations: ${run.degradationCodes.map(terminalSafe).join(', ')}`);
121
+ if (run.supersededBy)
122
+ lines.push(` Replaced by: ${run.supersededBy}`);
123
+ }
124
+ if (report.hasMore)
125
+ lines.push('', 'Older finished runs exist. Increase --limit to show up to 100.');
126
+ return lines.join('\n');
127
+ }
128
+ export async function verifyHistoryCommand(repository, options) {
129
+ const parts = repository.split('/');
130
+ if (parts.length !== 2 || !parts.every(part => REPO_COMPONENT.test(part))) {
131
+ throw new Error('Use an exact GitHub owner/repository name for verification history.');
132
+ }
133
+ const limitText = options.limit ?? '20';
134
+ if (!/^[1-9]\d*$/.test(limitText) || Number(limitText) > 100) {
135
+ throw new Error('--limit must be a whole number from 1 to 100.');
136
+ }
137
+ const [owner, name] = parts;
138
+ const limit = Number(limitText);
139
+ const auth = await resolveAuthContext({ preferredLogin: options.account, owner, repo: name });
140
+ let response;
141
+ try {
142
+ response = await haystackJson(`/api/agent/cloud-verifier/history/${encodeURIComponent(owner)}/${encodeURIComponent(name)}?limit=${limit}`, auth.token, { method: 'GET' });
143
+ }
144
+ catch (error) {
145
+ if (error instanceof HaystackApiError && error.status === 404) {
146
+ throw new Error('Verification history was not found for this repository and account. Check repository access and use --account <login> if needed.');
147
+ }
148
+ throw error;
149
+ }
150
+ const report = parseVerifyHistoryReport(response, { owner, name, limit });
151
+ process.stdout.write((options.json ? JSON.stringify(report, null, 2) : formatVerifyHistory(report)) + '\n');
152
+ }
@@ -132,6 +132,9 @@ export function parseHostedRunState(value, expectedRunId) {
132
132
  requireString(finding, 'caseId');
133
133
  requireString(finding, 'judgment');
134
134
  }
135
+ if (fleetResult.savedChecks !== undefined) {
136
+ parseHostedSavedChecks(fleetResult.savedChecks, runId, headCommit);
137
+ }
135
138
  requireString(provenance, 'intent', /^(user|inferred)$/);
136
139
  if (status === 'complete' && fleetResult.lifecycle !== 'complete') {
137
140
  throw new Error('Hosted fleet lifecycle disagrees with its completed job.');
@@ -216,6 +219,92 @@ export function parseHostedRepository(value) {
216
219
  fullName: value,
217
220
  };
218
221
  }
222
+ /** The service validates the shared full contract. Check every additional
223
+ * field used by this published CLI before displaying or accepting a clean run. */
224
+ function parseHostedSavedChecks(value, runId, headSha) {
225
+ const saved = requireRecord(value, 'Hosted saved checks');
226
+ if (saved.version !== 'dv-fleet-saved-check-result-v1'
227
+ || !Array.isArray(saved.descriptors) || !Array.isArray(saved.outcomes)
228
+ || !Array.isArray(saved.unresolvedFindings) || !Array.isArray(saved.conflicts)) {
229
+ throw new Error('Hosted saved checks have an invalid version or records.');
230
+ }
231
+ const completeness = requireRecord(saved.completeness, 'Hosted saved check completeness');
232
+ requireString(completeness, 'status', /^(complete|incomplete)$/);
233
+ if (completeness.status === 'incomplete' && (!Array.isArray(completeness.reasons) || completeness.reasons.length === 0)) {
234
+ throw new Error('Hosted saved check history has no explanation for missing evidence.');
235
+ }
236
+ const ref = (record) => {
237
+ requireString(record, 'checkId', /^[0-9a-f]{64}$/);
238
+ requireString(record, 'planRevisionId', /^[0-9a-f]{64}$/);
239
+ return `${record.checkId}:${record.planRevisionId}`;
240
+ };
241
+ const descriptors = new Map();
242
+ for (const value of saved.descriptors) {
243
+ const descriptor = requireRecord(value, 'Hosted saved check descriptor');
244
+ const key = ref(descriptor);
245
+ if (descriptor.version !== 'dv-fleet-saved-check-v1' || descriptors.has(key)) {
246
+ throw new Error('Hosted saved check descriptor identity is invalid or duplicated.');
247
+ }
248
+ requireString(descriptor, 'captureStatus', /^(captured|unavailable)$/);
249
+ descriptors.set(key, descriptor);
250
+ }
251
+ const outcomes = new Map();
252
+ for (const value of saved.outcomes) {
253
+ const outcome = requireRecord(value, 'Hosted saved check outcome');
254
+ const key = ref(outcome);
255
+ requireString(outcome, 'status', /^(passed|reproduced|blocked|inconclusive)$/);
256
+ if (outcome.version !== 'dv-fleet-saved-check-outcome-v1'
257
+ || (!descriptors.has(key) && completeness.status !== 'incomplete') || outcomes.has(key)
258
+ || outcome.publicRunId !== runId || outcome.headSha !== headSha
259
+ || (outcome.currentCaseId !== null && typeof outcome.currentCaseId !== 'string')
260
+ || (outcome.blockedCode !== null && typeof outcome.blockedCode !== 'string')) {
261
+ throw new Error('Hosted saved check outcome does not match its current run or original check.');
262
+ }
263
+ if ((outcome.status === 'blocked') !== (outcome.blockedCode !== null)
264
+ || (outcome.status === 'passed' && (descriptors.get(key)?.captureStatus !== 'captured'
265
+ || outcome.currentCaseId === null || !outcome.receipt || completeness.status !== 'complete'))) {
266
+ throw new Error('Hosted saved check outcome claims a pass without original execution evidence.');
267
+ }
268
+ outcomes.set(key, outcome);
269
+ }
270
+ const findings = new Set();
271
+ for (const value of saved.unresolvedFindings) {
272
+ const finding = requireRecord(value, 'Hosted unresolved finding');
273
+ const key = ref(finding);
274
+ requireString(finding, 'findingId');
275
+ const source = requireRecord(finding.firstObserved, 'Hosted original finding evidence');
276
+ requireString(source, 'publicRunId', RUN_ID);
277
+ requireString(source, 'caseId');
278
+ requireString(source, 'headSha', COMMIT_SHA);
279
+ if (finding.version !== 'dv-fleet-unresolved-finding-v1'
280
+ || (!descriptors.has(key) && completeness.status !== 'incomplete')
281
+ || findings.has(finding.findingId) || !Array.isArray(source.evidence) || source.evidence.length === 0
282
+ || outcomes.get(key)?.status === 'passed') {
283
+ throw new Error('Hosted unresolved finding has invalid original evidence or check identity.');
284
+ }
285
+ findings.add(finding.findingId);
286
+ if (finding.lastOutcome !== null) {
287
+ const last = requireRecord(finding.lastOutcome, 'Hosted unresolved finding outcome');
288
+ if (ref(last) !== key || last.status === 'passed')
289
+ throw new Error('Hosted unresolved finding lost its exact original check.');
290
+ requireString(last, 'status', /^(reproduced|blocked|inconclusive)$/);
291
+ if (last.blockedCode !== null)
292
+ requireString(last, 'blockedCode');
293
+ }
294
+ }
295
+ for (const value of saved.conflicts) {
296
+ const conflict = requireRecord(value, 'Hosted saved check conflict');
297
+ requireString(conflict, 'checkId', /^[0-9a-f]{64}$/);
298
+ if (!Array.isArray(conflict.planRevisionIds) || conflict.planRevisionIds.length < 2
299
+ || conflict.planRevisionIds.some(id => typeof id !== 'string' || !/^[0-9a-f]{64}$/.test(id))) {
300
+ throw new Error('Hosted saved check conflict is invalid.');
301
+ }
302
+ }
303
+ return saved;
304
+ }
305
+ function savedCheckDisplay(value) {
306
+ return String(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
307
+ }
219
308
  function requireCommit(value, flag) {
220
309
  if (typeof value !== 'string' || !COMMIT_SHA.test(value)) {
221
310
  throw new Error(`${flag} must be an exact 40-character lowercase commit SHA.`);
@@ -499,6 +588,26 @@ function printHostedState(operation, state, options) {
499
588
  const finding = requireRecord(value, 'Hosted fleet finding');
500
589
  console.log(` ${chalk.dim('Finding:')} ${finding.findingId} / ${finding.caseId}: ${finding.judgment}`);
501
590
  }
591
+ if (result.savedChecks !== undefined) {
592
+ const saved = requireRecord(result.savedChecks, 'Hosted saved checks');
593
+ const completeness = requireRecord(saved.completeness, 'Hosted saved check completeness');
594
+ const unresolved = saved.unresolvedFindings;
595
+ console.log(` ${chalk.dim('Saved checks:')} ${completeness.status}; ${saved.conflicts.length} conflicts; ${unresolved.length} unresolved bugs`);
596
+ for (const value of unresolved) {
597
+ const finding = requireRecord(value, 'Hosted unresolved finding');
598
+ const source = requireRecord(finding.firstObserved, 'Hosted original finding evidence');
599
+ const last = finding.lastOutcome === null ? null : requireRecord(finding.lastOutcome, 'Hosted unresolved finding outcome');
600
+ console.log(` ${chalk.yellow('Unresolved:')} ${savedCheckDisplay(finding.findingId)} / ${savedCheckDisplay(source.caseId)}`);
601
+ console.log(` First reproduced at ${String(source.headSha).slice(0, 12)}; latest check: ${savedCheckDisplay(last?.status ?? 'not executed')}${last?.blockedCode ? ` (${savedCheckDisplay(last.blockedCode)})` : ''}`);
602
+ }
603
+ if (unresolved.length)
604
+ console.log(' Earlier bug evidence remains open until the exact saved check passes.');
605
+ if (completeness.status === 'incomplete')
606
+ console.log(' Saved check history is incomplete; this run cannot confirm earlier bugs are fixed.');
607
+ }
608
+ else {
609
+ console.log(` ${chalk.dim('Saved checks:')} unavailable for this run`);
610
+ }
502
611
  }
503
612
  if (state.error)
504
613
  console.log(` ${chalk.dim('Error:')} ${state.error}`);
@@ -516,6 +625,16 @@ function markNonSuccess(state, timedOut = false) {
516
625
  process.exitCode = 2;
517
626
  return;
518
627
  }
628
+ if (result.savedChecks !== undefined) {
629
+ const saved = requireRecord(result.savedChecks, 'Hosted saved checks');
630
+ const completeness = requireRecord(saved.completeness, 'Hosted saved check completeness');
631
+ if (completeness.status !== 'complete' || saved.conflicts.length > 0) {
632
+ process.exitCode = 2;
633
+ return;
634
+ }
635
+ if (saved.unresolvedFindings.length > 0)
636
+ process.exitCode = 1;
637
+ }
519
638
  if (result.findings.some(value => requireRecord(value, 'Hosted fleet finding').judgment === 'regression')) {
520
639
  process.exitCode = 1;
521
640
  }
package/dist/index.js CHANGED
@@ -202,6 +202,7 @@ For launch specimens, run the local control plane from the Haystack repository:
202
202
 
203
203
  Examples:
204
204
  haystack verify --repo owner/repo --base main --head feature --json
205
+ haystack verify history owner/repo --limit 20
205
206
  haystack verify hosted start owner/repo --base <sha> --head <sha> --intent-file <path>
206
207
  haystack verify posthog-js-478 # start a run, then:
207
208
  haystack verify reproducibility posthog-js-478 --runs 3
@@ -220,6 +221,26 @@ Examples:
220
221
  `)
221
222
  .action((specimen, options) => runPublicCommand(() => verifyCommand(specimen, options), options.json));
222
223
  const verifyRunArg = 'Run id, chapter id (newest run of that chapter), or "latest" (default)';
224
+ verify
225
+ .command('history')
226
+ .description('Read your recent finished hosted verification runs without starting a run')
227
+ .argument('<repository>', 'Exact GitHub owner/repository name')
228
+ .option('--limit <runs>', 'Number of recent finished runs to show, from 1 to 100 (default 20)')
229
+ .option('--account <login>', 'Use a specific saved Haystack account')
230
+ .option('--json', 'Versioned history with execution health and separate bug counts')
231
+ .addHelpText('after', `
232
+ History covers your hosted fleet runs for this repository. Reliability counts
233
+ only these returned runs; it does not measure whether the code is bug-free.
234
+
235
+ Examples:
236
+ haystack verify history owner/repo
237
+ haystack verify history owner/repo --limit 50 --account my-login --json
238
+ `)
239
+ .action(async (repository, _options, cmd) => {
240
+ const options = { ...cmd.optsWithGlobals(), ...cmd.opts() };
241
+ const { verifyHistoryCommand } = await import('./commands/verify-history.js');
242
+ return runPublicCommand(() => verifyHistoryCommand(repository, options), options.json);
243
+ });
223
244
  verify
224
245
  .command('precompute')
225
246
  .description('Precompute reusable verification inputs for the current working tree')
@@ -901,6 +922,8 @@ program
901
922
  // keeps retired auto-fix/verification wording out of the CLI surface).
902
923
  .description('Run a blind design-by-execution review of a PR')
903
924
  .option('--json', 'Machine-readable output')
925
+ .option('--history', 'Show recent execution reliability for this repository without starting a run')
926
+ .option('--limit <attempts>', 'Maximum recorded terminal attempts to show with --history (1–100, default 20)')
904
927
  .option('--no-wait', 'Return after the run is admitted instead of waiting for its verdict')
905
928
  .option('--run <run-id>', 'Resume and wait for an existing exact run id')
906
929
  .option('--poll-interval <seconds>', 'Status polling interval', '3')
@@ -921,6 +944,11 @@ outside the registered intent.
921
944
  The command waits for a behavioral verdict by default. Use --no-wait to
922
945
  return the run id immediately, or --run <run-id> to resume waiting.
923
946
 
947
+ Read recent execution reliability across this repository's PRs:
948
+ haystack design-verify owner/repo#123 --history --limit 20
949
+ History covers recorded terminal attempts, including partial or failed runs;
950
+ it does not measure bug-detection accuracy or whether changes are bug-free.
951
+
924
952
  After a completed run, inspect or replay one retained case universe without
925
953
  receiving provider credentials:
926
954
  haystack design-verify owner/repo#123 --run RUN --case case-00001 --universe before --replay
@@ -0,0 +1,38 @@
1
+ /** Read-only history from the active hosted verifier, scoped to the caller's
2
+ * stable GitHub user and repository IDs. Counts describe the returned window,
3
+ * never bug correctness. Missing results remain unknown. */
4
+ export type VerifyHistoryHealth = 'healthy' | 'degraded' | 'infrastructure-error' | 'not-executed' | 'unknown';
5
+ export interface VerifyHistoryRun {
6
+ runId: string;
7
+ baseCommit: string;
8
+ headCommit: string;
9
+ createdAt: string;
10
+ completedAt: string;
11
+ status: 'complete' | 'errored';
12
+ lifecycle: 'complete' | 'failed' | 'cancelled' | 'budget-stopped' | 'account-action' | null;
13
+ health: VerifyHistoryHealth;
14
+ regressionCount: number | null;
15
+ executedCaseCount: number | null;
16
+ degradationCodes: string[];
17
+ supersededBy: string | null;
18
+ }
19
+ export interface VerifyHistoryReport {
20
+ version: 'dv-verify-history-v1';
21
+ provider: 'haystack';
22
+ scope: 'user';
23
+ repository: {
24
+ owner: string;
25
+ name: string;
26
+ repositoryId: string;
27
+ };
28
+ limit: number;
29
+ hasMore: boolean;
30
+ runs: VerifyHistoryRun[];
31
+ summary: {
32
+ total: number;
33
+ counts: Record<VerifyHistoryHealth, number>;
34
+ /** Healthy/degraded runs with executedCaseCount > 0, divided by total;
35
+ * null for an empty window. */
36
+ usableRate: number | null;
37
+ };
38
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -7,6 +7,7 @@
7
7
  * the server-side shared secret. This client just POSTs and interprets the
8
8
  * response.
9
9
  */
10
+ import { type DesignVerifierHistoryReport } from './design-verifier-history.js';
10
11
  export type DesignVerifierTriggerResult = {
11
12
  status: 'started' | 'queued' | 'duplicate';
12
13
  runId: string;
@@ -184,6 +185,13 @@ export declare function waitForDesignVerifierUniverseAction(owner: string, repo:
184
185
  }>;
185
186
  export declare function triggerDesignVerifier(owner: string, repo: string, prNumber: number, token: string): Promise<DesignVerifierTriggerResult>;
186
187
  export declare function getDesignVerifierStatus(owner: string, repo: string, prNumber: number, runId: string, token: string): Promise<DesignVerifierStatusFetchResult>;
188
+ export declare function getDesignVerifierHistory(owner: string, repo: string, token: string, limit?: number): Promise<{
189
+ status: 'ok';
190
+ history: DesignVerifierHistoryReport;
191
+ } | {
192
+ status: 'denied' | 'unavailable' | 'error';
193
+ message: string;
194
+ }>;
187
195
  export declare function waitForDesignVerifier(owner: string, repo: string, prNumber: number, runId: string, token: string, options?: {
188
196
  intervalMs?: number;
189
197
  timeoutMs?: number;
@@ -8,6 +8,7 @@
8
8
  * response.
9
9
  */
10
10
  import { haystackApiUrl } from './haystack-api.js';
11
+ import { isDesignVerifierHistoryReport } from './design-verifier-history.js';
11
12
  function isDuration(value) {
12
13
  return value === null || (typeof value === 'number' && Number.isFinite(value) && value >= 0);
13
14
  }
@@ -239,6 +240,28 @@ export async function getDesignVerifierStatus(owner, repo, prNumber, runId, toke
239
240
  return { status: 'unavailable', message };
240
241
  return { status: 'error', message };
241
242
  }
243
+ export async function getDesignVerifierHistory(owner, repo, token, limit = 20) {
244
+ const url = haystackApiUrl(`/api/design-verifier/history/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}?limit=${limit}`);
245
+ try {
246
+ const response = await fetch(url, { method: 'GET', headers: apiHeaders(token) });
247
+ const data = await response.json();
248
+ if (response.ok) {
249
+ if (!isDesignVerifierHistoryReport(data, { owner, repo, limit })) {
250
+ return { status: 'error', message: 'History endpoint returned an invalid or mismatched result.' };
251
+ }
252
+ return { status: 'ok', history: data };
253
+ }
254
+ const message = data?.message || data?.error || `HTTP ${response.status}`;
255
+ if (response.status === 401 || response.status === 403)
256
+ return { status: 'denied', message };
257
+ if ([404, 502, 503].includes(response.status))
258
+ return { status: 'unavailable', message };
259
+ return { status: 'error', message };
260
+ }
261
+ catch (error) {
262
+ return { status: 'error', message: error.message };
263
+ }
264
+ }
242
265
  export async function waitForDesignVerifier(owner, repo, prNumber, runId, token, options = {}) {
243
266
  const intervalMs = options.intervalMs ?? 3000;
244
267
  const timeoutMs = options.timeoutMs ?? 2 * 60 * 60 * 1000;
@@ -0,0 +1,46 @@
1
+ /** FIXED CONTRACT: historical execution health, never a score for bug-detection accuracy. */
2
+ export type DesignVerifierHistoryOutcome = 'healthy' | 'degraded' | 'invalid' | 'failed' | 'unknown';
3
+ export interface DesignVerifierHistoryRun {
4
+ runId: string;
5
+ pr: number;
6
+ baseSha: string | null;
7
+ headSha: string | null;
8
+ createdAt: string;
9
+ finishedAt: string | null;
10
+ status: string;
11
+ outcome: DesignVerifierHistoryOutcome;
12
+ admitted: number | null;
13
+ losses: Array<{
14
+ code: string;
15
+ owner: string;
16
+ impact: 'degraded' | 'invalid';
17
+ count: number;
18
+ }>;
19
+ }
20
+ export interface DesignVerifierHistoryReport {
21
+ version: 'dv-cli-history-v1';
22
+ owner: string;
23
+ repo: string;
24
+ retentionDays: number;
25
+ limit: number;
26
+ runs: DesignVerifierHistoryRun[];
27
+ summary: {
28
+ total: number;
29
+ healthy: number;
30
+ degraded: number;
31
+ invalid: number;
32
+ failed: number;
33
+ unknown: number;
34
+ usableRate: number | null;
35
+ topBlockers: Array<{
36
+ code: string;
37
+ affectedRuns: number;
38
+ }>;
39
+ };
40
+ }
41
+ export declare function isDesignVerifierHistoryReport(value: unknown, expected: {
42
+ owner: string;
43
+ repo: string;
44
+ limit: number;
45
+ }): value is DesignVerifierHistoryReport;
46
+ export declare function formatDesignVerifierHistory(report: DesignVerifierHistoryReport): string;
@@ -0,0 +1,79 @@
1
+ const outcomes = ['healthy', 'degraded', 'invalid', 'failed', 'unknown'];
2
+ export function isDesignVerifierHistoryReport(value, expected) {
3
+ if (!value || typeof value !== 'object' || Array.isArray(value))
4
+ return false;
5
+ const report = value;
6
+ if (report.version !== 'dv-cli-history-v1' || report.owner !== expected.owner ||
7
+ report.repo !== expected.repo || report.limit !== expected.limit ||
8
+ !Number.isSafeInteger(report.retentionDays) || report.retentionDays <= 0 ||
9
+ !Array.isArray(report.runs) || report.runs.length > report.limit || !report.summary)
10
+ return false;
11
+ const seen = new Set();
12
+ const counts = { healthy: 0, degraded: 0, invalid: 0, failed: 0, unknown: 0 };
13
+ const blockers = new Map();
14
+ for (const run of report.runs) {
15
+ if (!run || !Number.isSafeInteger(run.pr) || run.pr < 1 || typeof run.runId !== 'string')
16
+ return false;
17
+ const prefix = `${expected.owner}--${expected.repo}--${run.pr}--`;
18
+ if (!run.runId.startsWith(prefix) ||
19
+ !/^[0-9a-f]{12}(?:--attempt-[1-9]\d*)?$/.test(run.runId.slice(prefix.length)) ||
20
+ seen.has(run.runId) || !outcomes.includes(run.outcome) ||
21
+ !['succeeded', 'failed', 'failed-stale'].includes(run.status) ||
22
+ (run.outcome === 'failed') !== (run.status !== 'succeeded') ||
23
+ typeof run.createdAt !== 'string' || !Number.isFinite(Date.parse(run.createdAt)) ||
24
+ (run.finishedAt !== null && (typeof run.finishedAt !== 'string' || !Number.isFinite(Date.parse(run.finishedAt)))) ||
25
+ (run.baseSha !== null && typeof run.baseSha !== 'string') ||
26
+ (run.headSha !== null && typeof run.headSha !== 'string') ||
27
+ (run.admitted !== null && (!Number.isSafeInteger(run.admitted) || run.admitted < 0)) ||
28
+ !Array.isArray(run.losses) || run.losses.some(loss => !loss || typeof loss.code !== 'string' || !loss.code || typeof loss.owner !== 'string' ||
29
+ !loss.owner || !['degraded', 'invalid'].includes(loss.impact) ||
30
+ !Number.isSafeInteger(loss.count) || loss.count < 1))
31
+ return false;
32
+ if (['healthy', 'degraded'].includes(run.outcome) && (run.admitted === null || run.admitted === 0))
33
+ return false;
34
+ seen.add(run.runId);
35
+ counts[run.outcome]++;
36
+ for (const code of new Set(run.losses.map(loss => loss.code))) {
37
+ blockers.set(code, (blockers.get(code) || 0) + 1);
38
+ }
39
+ }
40
+ const summary = report.summary;
41
+ const usableRate = report.runs.length ? (counts.healthy + counts.degraded) / report.runs.length : null;
42
+ if (summary.total !== report.runs.length || summary.usableRate !== usableRate ||
43
+ outcomes.some(outcome => summary[outcome] !== counts[outcome]) ||
44
+ !Array.isArray(summary.topBlockers) || summary.topBlockers.length !== blockers.size)
45
+ return false;
46
+ const seenBlockers = new Set();
47
+ return summary.topBlockers.every(blocker => {
48
+ if (!blocker || seenBlockers.has(blocker.code) || blockers.get(blocker.code) !== blocker.affectedRuns)
49
+ return false;
50
+ seenBlockers.add(blocker.code);
51
+ return true;
52
+ });
53
+ }
54
+ function terminalSafe(value) {
55
+ return Array.from(value).filter(character => {
56
+ const point = character.codePointAt(0);
57
+ return point >= 32 && !(point >= 127 && point <= 159);
58
+ }).join('');
59
+ }
60
+ export function formatDesignVerifierHistory(report) {
61
+ const { summary } = report;
62
+ const lines = [`Execution history for ${terminalSafe(report.owner)}/${terminalSafe(report.repo)}`,
63
+ `Latest ${summary.total} recorded terminal attempts within ${report.retentionDays} days (limit ${report.limit}).`];
64
+ if (summary.total === 0) {
65
+ lines.push('No recorded attempts in this window. History starts when recording is deployed.');
66
+ return lines.join('\n');
67
+ }
68
+ lines.push(`Usable evidence: ${Math.round(summary.usableRate * 100)}% of attempts (${summary.healthy + summary.degraded}/${summary.total}).`, `Fully healthy: ${summary.healthy}; partially degraded: ${summary.degraded}; invalid: ${summary.invalid}; failed: ${summary.failed}; unknown: ${summary.unknown}.`, 'Usable includes partial evidence from degraded attempts. This measures execution reliability, not bug-detection accuracy.', '');
69
+ for (const run of report.runs) {
70
+ lines.push(`${run.finishedAt || run.createdAt} PR #${run.pr} ${run.outcome} ${run.admitted ?? '?'} admitted cases`, ` ${run.runId}`);
71
+ }
72
+ if (summary.topBlockers.length) {
73
+ lines.push('', 'Most frequent blockers (attempts affected):');
74
+ for (const blocker of summary.topBlockers.slice(0, 5)) {
75
+ lines.push(` ${terminalSafe(blocker.code)}: ${blocker.affectedRuns}`);
76
+ }
77
+ }
78
+ return lines.join('\n');
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.15.30",
3
+ "version": "0.15.31",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {