@sun-asterisk/sungen 3.3.0-alpha.7 → 3.3.0-alpha.9

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.
Files changed (148) hide show
  1. package/dist/cli/case-triage-hint.d.ts +14 -0
  2. package/dist/cli/case-triage-hint.d.ts.map +1 -0
  3. package/dist/cli/case-triage-hint.js +23 -0
  4. package/dist/cli/case-triage-hint.js.map +1 -0
  5. package/dist/cli/commands/contract-derive-command.d.ts +2 -1
  6. package/dist/cli/commands/contract-derive-command.d.ts.map +1 -1
  7. package/dist/cli/commands/contract-derive-command.js +15 -3
  8. package/dist/cli/commands/contract-derive-command.js.map +1 -1
  9. package/dist/cli/commands/contract.d.ts.map +1 -1
  10. package/dist/cli/commands/contract.js +5 -2
  11. package/dist/cli/commands/contract.js.map +1 -1
  12. package/dist/cli/commands/delivery.d.ts.map +1 -1
  13. package/dist/cli/commands/delivery.js +29 -13
  14. package/dist/cli/commands/delivery.js.map +1 -1
  15. package/dist/cli/commands/gate.d.ts.map +1 -1
  16. package/dist/cli/commands/gate.js +2 -1
  17. package/dist/cli/commands/gate.js.map +1 -1
  18. package/dist/cli/commands/generate.d.ts.map +1 -1
  19. package/dist/cli/commands/generate.js +12 -2
  20. package/dist/cli/commands/generate.js.map +1 -1
  21. package/dist/cli/commands/journey.d.ts.map +1 -1
  22. package/dist/cli/commands/journey.js +4 -2
  23. package/dist/cli/commands/journey.js.map +1 -1
  24. package/dist/cli/commands/next.d.ts +4 -2
  25. package/dist/cli/commands/next.d.ts.map +1 -1
  26. package/dist/cli/commands/next.js +22 -6
  27. package/dist/cli/commands/next.js.map +1 -1
  28. package/dist/cli/commands/triage.d.ts.map +1 -1
  29. package/dist/cli/commands/triage.js +5 -3
  30. package/dist/cli/commands/triage.js.map +1 -1
  31. package/dist/cli/project-sungen-invocation.d.ts +6 -0
  32. package/dist/cli/project-sungen-invocation.d.ts.map +1 -1
  33. package/dist/cli/project-sungen-invocation.js +19 -0
  34. package/dist/cli/project-sungen-invocation.js.map +1 -1
  35. package/dist/cli/run-command-hints.d.ts +9 -0
  36. package/dist/cli/run-command-hints.d.ts.map +1 -0
  37. package/dist/cli/run-command-hints.js +84 -0
  38. package/dist/cli/run-command-hints.js.map +1 -0
  39. package/dist/cli/waiting-for-you-block.d.ts +9 -6
  40. package/dist/cli/waiting-for-you-block.d.ts.map +1 -1
  41. package/dist/cli/waiting-for-you-block.js +9 -6
  42. package/dist/cli/waiting-for-you-block.js.map +1 -1
  43. package/dist/contract/build-trace-conformance.d.ts.map +1 -1
  44. package/dist/contract/build-trace-conformance.js +4 -11
  45. package/dist/contract/build-trace-conformance.js.map +1 -1
  46. package/dist/contract/derive-free-feature.d.ts +9 -0
  47. package/dist/contract/derive-free-feature.d.ts.map +1 -1
  48. package/dist/contract/derive-free-feature.js +10 -3
  49. package/dist/contract/derive-free-feature.js.map +1 -1
  50. package/dist/contract/resolve-unit-trace-identities.d.ts +12 -0
  51. package/dist/contract/resolve-unit-trace-identities.d.ts.map +1 -1
  52. package/dist/contract/resolve-unit-trace-identities.js +33 -49
  53. package/dist/contract/resolve-unit-trace-identities.js.map +1 -1
  54. package/dist/contract/trace/collect-trace-evidence.d.ts +9 -3
  55. package/dist/contract/trace/collect-trace-evidence.d.ts.map +1 -1
  56. package/dist/contract/trace/collect-trace-evidence.js +45 -18
  57. package/dist/contract/trace/collect-trace-evidence.js.map +1 -1
  58. package/dist/contract/trace/scenario-id-claims.d.ts +11 -0
  59. package/dist/contract/trace/scenario-id-claims.d.ts.map +1 -0
  60. package/dist/contract/trace/scenario-id-claims.js +89 -0
  61. package/dist/contract/trace/scenario-id-claims.js.map +1 -0
  62. package/dist/contract/trace/select-final-attempt.d.ts +2 -0
  63. package/dist/contract/trace/select-final-attempt.d.ts.map +1 -1
  64. package/dist/contract/trace/select-final-attempt.js +4 -1
  65. package/dist/contract/trace/select-final-attempt.js.map +1 -1
  66. package/dist/contract/trace/trace-binding.d.ts +3 -2
  67. package/dist/contract/trace/trace-binding.d.ts.map +1 -1
  68. package/dist/contract/trace/trace-binding.js +6 -3
  69. package/dist/contract/trace/trace-binding.js.map +1 -1
  70. package/dist/contract/trace/trace-freshness.d.ts +27 -0
  71. package/dist/contract/trace/trace-freshness.d.ts.map +1 -0
  72. package/dist/contract/trace/trace-freshness.js +146 -0
  73. package/dist/contract/trace/trace-freshness.js.map +1 -0
  74. package/dist/contract/trace/trace-identity-resolution.d.ts +2 -0
  75. package/dist/contract/trace/trace-identity-resolution.d.ts.map +1 -1
  76. package/dist/contract/trace/trace-identity-resolution.js +6 -3
  77. package/dist/contract/trace/trace-identity-resolution.js.map +1 -1
  78. package/dist/contract/trace/trace-run-record.d.ts +2 -0
  79. package/dist/contract/trace/trace-run-record.d.ts.map +1 -1
  80. package/dist/contract/trace/trace-run-record.js +19 -5
  81. package/dist/contract/trace/trace-run-record.js.map +1 -1
  82. package/dist/exporters/csv-exporter.d.ts +11 -2
  83. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  84. package/dist/exporters/csv-exporter.js +8 -4
  85. package/dist/exporters/csv-exporter.js.map +1 -1
  86. package/dist/exporters/matrix/export.d.ts +6 -1
  87. package/dist/exporters/matrix/export.d.ts.map +1 -1
  88. package/dist/exporters/matrix/export.js +11 -4
  89. package/dist/exporters/matrix/export.js.map +1 -1
  90. package/dist/exporters/xlsx-exporter.d.ts +3 -2
  91. package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
  92. package/dist/exporters/xlsx-exporter.js +4 -4
  93. package/dist/exporters/xlsx-exporter.js.map +1 -1
  94. package/dist/graph/evaluate-graph.d.ts +3 -1
  95. package/dist/graph/evaluate-graph.d.ts.map +1 -1
  96. package/dist/graph/evaluate-graph.js +4 -2
  97. package/dist/graph/evaluate-graph.js.map +1 -1
  98. package/dist/graph/unit-artifact-probes.d.ts.map +1 -1
  99. package/dist/graph/unit-artifact-probes.js +3 -1
  100. package/dist/graph/unit-artifact-probes.js.map +1 -1
  101. package/dist/harness/gates/audit-report-verification.d.ts +2 -0
  102. package/dist/harness/gates/audit-report-verification.d.ts.map +1 -1
  103. package/dist/harness/gates/audit-report-verification.js +8 -3
  104. package/dist/harness/gates/audit-report-verification.js.map +1 -1
  105. package/dist/harness/next-step.d.ts +2 -0
  106. package/dist/harness/next-step.d.ts.map +1 -1
  107. package/dist/harness/next-step.js +2 -0
  108. package/dist/harness/next-step.js.map +1 -1
  109. package/dist/hooks/hand-back-core.d.ts +4 -1
  110. package/dist/hooks/hand-back-core.d.ts.map +1 -1
  111. package/dist/hooks/hand-back-core.js +7 -3
  112. package/dist/hooks/hand-back-core.js.map +1 -1
  113. package/dist/hooks/session-start-unit-state-hook.js +1 -1
  114. package/dist/hooks/session-start-unit-state-hook.js.map +1 -1
  115. package/dist/orchestrator/templates/ai-src/commands/create-test.md +4 -1
  116. package/package.json +3 -3
  117. package/src/cli/case-triage-hint.ts +31 -0
  118. package/src/cli/commands/contract-derive-command.ts +14 -4
  119. package/src/cli/commands/contract.ts +5 -2
  120. package/src/cli/commands/delivery.ts +29 -13
  121. package/src/cli/commands/gate.ts +2 -1
  122. package/src/cli/commands/generate.ts +11 -2
  123. package/src/cli/commands/journey.ts +4 -2
  124. package/src/cli/commands/next.ts +25 -9
  125. package/src/cli/commands/triage.ts +5 -3
  126. package/src/cli/project-sungen-invocation.ts +20 -0
  127. package/src/cli/run-command-hints.ts +47 -0
  128. package/src/cli/waiting-for-you-block.ts +14 -10
  129. package/src/contract/build-trace-conformance.ts +4 -6
  130. package/src/contract/derive-free-feature.ts +15 -3
  131. package/src/contract/resolve-unit-trace-identities.ts +35 -12
  132. package/src/contract/trace/collect-trace-evidence.ts +50 -18
  133. package/src/contract/trace/scenario-id-claims.ts +60 -0
  134. package/src/contract/trace/select-final-attempt.ts +9 -2
  135. package/src/contract/trace/trace-binding.ts +7 -4
  136. package/src/contract/trace/trace-freshness.ts +110 -0
  137. package/src/contract/trace/trace-identity-resolution.ts +7 -4
  138. package/src/contract/trace/trace-run-record.ts +20 -4
  139. package/src/exporters/csv-exporter.ts +12 -4
  140. package/src/exporters/matrix/export.ts +11 -4
  141. package/src/exporters/xlsx-exporter.ts +5 -4
  142. package/src/graph/evaluate-graph.ts +5 -3
  143. package/src/graph/unit-artifact-probes.ts +3 -1
  144. package/src/harness/gates/audit-report-verification.ts +7 -2
  145. package/src/harness/next-step.ts +3 -0
  146. package/src/hooks/hand-back-core.ts +8 -4
  147. package/src/hooks/session-start-unit-state-hook.ts +1 -1
  148. package/src/orchestrator/templates/ai-src/commands/create-test.md +4 -1
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Is a trace newer than the contract it is asked to prove? (DOS-14.6: a trace older than the
3
+ * `.feature` or the lock is refused.) Judged by CONTENT, never by file time alone (#967): a `touch`,
4
+ * a checkout or a fresh clone changes no byte, so it stales nothing; an edit after the run always does.
5
+ *
6
+ * schema 2 record the reporter hashed every `.feature` and `contracts.lock.yaml` under `qa/` when
7
+ * the run started. Each of the unit's contract files must hash NOW to what the run
8
+ * saw: one that changed since, or did not exist yet, is newer than the trace → stale
9
+ * schema 1 record an older reporter recorded no content, so the files are dated against the run's
10
+ * start (the record's `startedAt`): one last written before it is older than the run;
11
+ * one written after it still is when git holds it unchanged and the commit that last
12
+ * changed it is an ancestor of HEAD committed before the run started (its bytes ARE
13
+ * that commit's). Anything else — edited, uncommitted, untracked, committed after the
14
+ * run started, no git, git failed or slow — is stale: fail closed.
15
+ *
16
+ * Limit of the schema-1 path: a commit time is what the committer's clock (or GIT_COMMITTER_DATE) said,
17
+ * so a backdated commit passes the time check — time-based checks cannot see through that. Schema 2
18
+ * compares content and retires this path: re-running the free tests once with the current reporter is
19
+ * the remedy.
20
+ */
21
+ import { execFileSync } from 'child_process';
22
+ import { createHash } from 'crypto';
23
+ import * as fs from 'fs';
24
+ import * as path from 'path';
25
+ import { lockPathFor } from '../contracts-lock-file';
26
+ import { contractFeatureFiles } from '../normalize-feature-steps';
27
+ import type { RunRecord } from './trace-run-record';
28
+
29
+ interface ContractFile { rel: string; sha: string; mtimeMs: number }
30
+ interface LastCommit { sha: string; ms: number }
31
+ /** A unit's contract as it is NOW — its `.feature` files and lock, read once per verdict. */
32
+ export interface UnitContract {
33
+ projectRoot: string;
34
+ files: ContractFile[];
35
+ /** Schema 1 only, asked of git at most once: project path → the last commit that changed each file git holds unchanged. */
36
+ committed?: Map<string, LastCommit> | null;
37
+ /** Schema 1 only: commit → is it an ancestor of HEAD (asked once per commit). */
38
+ ancestors?: Map<string, boolean>;
39
+ }
40
+
41
+ /** Inside the Claude Code guard too: a slow git leaves a schema-1 trace stale rather than the hook waiting. */
42
+ const GIT_TIMEOUT_MS = 2_000;
43
+ const posix = (p: string): string => p.split(path.sep).join('/');
44
+
45
+ export function unitContract(projectRoot: string, unit: { name: string; dir: string }): UnitContract {
46
+ const root = path.resolve(projectRoot);
47
+ const lock = path.resolve(lockPathFor(unit.dir));
48
+ const files = [...(fs.existsSync(lock) ? [lock] : []), ...contractFeatureFiles(unit.dir, unit.name)].map((abs) => {
49
+ let sha = 'unreadable'; // never equal to a recorded sha256: an unreadable contract file is stale
50
+ let mtimeMs = Number.POSITIVE_INFINITY;
51
+ try { sha = createHash('sha256').update(fs.readFileSync(abs)).digest('hex'); mtimeMs = fs.statSync(abs).mtimeMs; } catch { /* stale */ }
52
+ return { rel: posix(path.relative(root, abs)), sha, mtimeMs };
53
+ });
54
+ return { projectRoot: root, files };
55
+ }
56
+
57
+ function git(cwd: string, args: string[]): string | null {
58
+ try { return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: GIT_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }).toString('utf8'); } catch { return null; }
59
+ }
60
+
61
+ /**
62
+ * Each contract file git holds unchanged → the last commit (first-parent history, merges included)
63
+ * that changed it, with its committer time. Two git calls for the whole unit; null when git cannot say.
64
+ * Paths are passed as literal pathspecs: no glob or `:(magic)` reading of a unit's file names.
65
+ */
66
+ function committedTimes(c: UnitContract): Map<string, LastCommit> | null {
67
+ if (c.committed !== undefined) return c.committed;
68
+ const rels = c.files.map((f) => f.rel);
69
+ const changed = git(c.projectRoot, ['--literal-pathspecs', 'diff', '--name-only', '--relative', '-z', 'HEAD', '--', ...rels]);
70
+ const log = git(c.projectRoot, ['-c', 'core.quotePath=false', '--literal-pathspecs', 'log', '--first-parent', '-m', '-n', '500', '--format=%x01%H %ct', '--name-only', '--relative', '--', ...rels]);
71
+ if (changed === null || log === null) return (c.committed = null);
72
+ const dirty = new Set(changed.split('\0').filter(Boolean));
73
+ const times = new Map<string, LastCommit>();
74
+ let at: LastCommit | null = null;
75
+ for (const raw of log.split('\n')) {
76
+ const line = raw.trim();
77
+ if (raw.startsWith('\x01')) {
78
+ const [sha, ct] = raw.slice(1).trim().split(' ');
79
+ at = /^[0-9a-f]{40,64}$/.test(sha ?? '') && Number(ct) > 0 ? { sha, ms: Number(ct) * 1000 } : null;
80
+ } else if (line && at && !times.has(line) && !dirty.has(line)) times.set(line, at);
81
+ }
82
+ return (c.committed = times);
83
+ }
84
+
85
+ /** Is `sha` an ancestor of HEAD? First-parent history says so already; asserted, never assumed. */
86
+ function isAncestorOfHead(c: UnitContract, sha: string): boolean {
87
+ if (!c.ancestors) c.ancestors = new Map();
88
+ if (!c.ancestors.has(sha)) c.ancestors.set(sha, git(c.projectRoot, ['merge-base', '--is-ancestor', sha, 'HEAD']) !== null);
89
+ return c.ancestors.get(sha)!;
90
+ }
91
+
92
+ /** Why a trace bound to `record` is older than the unit's contract — null when it is not. */
93
+ export function staleReason(c: UnitContract, record: RunRecord): string | null {
94
+ if (record.contract) {
95
+ const f = c.files.find((x) => record.contract![x.rel] !== x.sha);
96
+ if (!f) return null;
97
+ const why = record.contract[f.rel] === undefined ? 'it did not exist when the run started' : 'its content changed after the run';
98
+ return `the trace is older than ${f.rel}: ${why} (stale) — re-run the test`;
99
+ }
100
+ const runStart = Date.parse(record.startedAt); // unreadable → nothing is "before the run": stale
101
+ const late = c.files.filter((f) => !(f.mtimeMs <= runStart));
102
+ if (!late.length) return null;
103
+ const committed = committedTimes(c);
104
+ const vouched = (rel: string): boolean => {
105
+ const last = committed?.get(rel);
106
+ return !!last && last.ms <= runStart && isAncestorOfHead(c, last.sha);
107
+ };
108
+ const f = late.find((x) => !vouched(x.rel));
109
+ return f ? `the trace is older than ${f.rel}: it was written after the run, and no commit from before the run holds its bytes (stale) — re-run the test` : null;
110
+ }
@@ -51,13 +51,16 @@ export function identityRequestsFor(trace: ParsedTrace): TraceIdentityRequest[]
51
51
  }
52
52
 
53
53
  /** Ask the driver about every not-yet-cached target of these traces; rewrite the cache. */
54
- export async function resolveWithDriver(projectRoot: string, traces: ParsedTrace[], resolver: TraceIdentityResolver): Promise<{ asked: number; resolver: string; blockedRequests: number }> {
54
+ /** `keys`: every target of the given traces (cached or asked) — what the caller reports on. */
55
+ export async function resolveWithDriver(projectRoot: string, traces: ParsedTrace[], resolver: TraceIdentityResolver): Promise<{ asked: number; resolver: string; blockedRequests: number; keys: string[] }> {
55
56
  const { cache } = readIdentityCache(projectRoot);
56
57
  // The cache is project-wide (every unit's traces): answers are only ever ADDED, never pruned here.
57
58
  const keep = new Map<string, CachedIdentity>(cache.entries);
59
+ const all = traces.flatMap(identityRequestsFor);
60
+ const keys = [...new Set(all.map((req) => req.key))];
58
61
  const requests = new Map<string, TraceIdentityRequest>();
59
- for (const req of traces.flatMap(identityRequestsFor)) if (!keep.has(req.key)) requests.set(req.key, req);
60
- if (!requests.size) return { asked: 0, resolver: cache.resolver, blockedRequests: 0 };
62
+ for (const req of all) if (!keep.has(req.key)) requests.set(req.key, req);
63
+ if (!requests.size) return { asked: 0, resolver: cache.resolver, blockedRequests: 0, keys };
61
64
  const answer = await resolver({ requests: [...requests.values()] });
62
65
  if (!answer || !Array.isArray(answer.results)) throw new Error('the trace identity resolver returned no results');
63
66
  if (!TRUSTED_RESOLVER.test(String(answer.resolver)) || typeof answer.browser !== 'string' || !answer.browser) {
@@ -70,5 +73,5 @@ export async function resolveWithDriver(projectRoot: string, traces: ParsedTrace
70
73
  keep.set(key, { ...rest, browser: answer.browser.slice(0, 100) });
71
74
  }
72
75
  writeIdentityCache(projectRoot, answer.resolver, keep);
73
- return { asked: requests.size, resolver: String(answer.resolver), blockedRequests: Number(answer.blockedRequests) || 0 };
76
+ return { asked: requests.size, resolver: String(answer.resolver), blockedRequests: Number(answer.blockedRequests) || 0, keys };
74
77
  }
@@ -8,6 +8,10 @@
8
8
  * own outputDir (#941) — or `<project>/test-results/sungen-run-record*.json` (one per run or shard).
9
9
  * Playwright wipes its outputDir at the start of every run, so a record and its traces share a
10
10
  * lifetime; the free run has its own so the compiled suite's next run cannot delete its traces.
11
+ *
12
+ * Schema 2 (#967) adds `contract`: the sha256 of every `.feature` and `contracts.lock.yaml` under `qa/`
13
+ * when the run started — what trace freshness compares by content. Schema 1 (an older reporter) has
14
+ * none; its traces are dated instead (trace-freshness.ts).
11
15
  */
12
16
  import * as fs from 'fs';
13
17
  import * as path from 'path';
@@ -24,10 +28,13 @@ export interface RunRecord {
24
28
  shard: string | null;
25
29
  report: string | null;
26
30
  specs: Record<string, string>;
31
+ /** Schema 2: project path → sha256 of each contract file at run start. null: schema 1 (no content recorded). */
32
+ contract: Record<string, string> | null;
27
33
  attempts: RunRecordAttempt[];
28
34
  }
29
35
 
30
- const TOP = new Set(['schema', 'tool', 'runId', 'startedAt', 'playwrightVersion', 'shard', 'report', 'specs', 'attempts']);
36
+ const TOP_V1: ReadonlySet<string> = new Set(['schema', 'tool', 'runId', 'startedAt', 'playwrightVersion', 'shard', 'report', 'specs', 'attempts']);
37
+ const TOP_V2: ReadonlySet<string> = new Set([...TOP_V1, 'contract']);
31
38
  const ATTEMPT = new Set(['testId', 'file', 'title', 'retry', 'status', 'startTime', 'duration', 'trace']);
32
39
  const SHA = /^[0-9a-f]{64}$/;
33
40
  const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
@@ -65,16 +72,25 @@ export function readRunRecord(file: string, text?: string): RunRecord {
65
72
  let raw: unknown;
66
73
  try { raw = JSON.parse(text ?? fs.readFileSync(file, 'utf8')); } catch (e) { throw new Error(`not readable JSON (${(e as Error).message.split('\n')[0]})`); }
67
74
  if (!isObj(raw)) throw new Error('not an object');
68
- const extra = Object.keys(raw).find((k) => !TOP.has(k));
75
+ if (raw.tool === 'sungen-trace-run-record' && typeof raw.schema === 'number' && raw.schema > 2) {
76
+ // A newer driver-ui wrote it (mixed versions): say what to upgrade, not just "not a record".
77
+ throw new Error(`record schema ${raw.schema} is newer than this sungen — upgrade @sun-asterisk/sungen (core before or together with driver-ui)`);
78
+ }
79
+ const keys = raw.schema === 2 ? TOP_V2 : raw.schema === 1 ? TOP_V1 : null;
80
+ if (!keys || raw.tool !== 'sungen-trace-run-record') throw new Error('not a sungen run record (schema 1 or 2)');
81
+ const extra = Object.keys(raw).find((k) => !keys.has(k));
69
82
  if (extra) throw new Error(`unknown key "${extra}"`);
70
- if (raw.schema !== 1 || raw.tool !== 'sungen-trace-run-record') throw new Error('not a sungen run record (schema 1)');
71
83
  if (typeof raw.runId !== 'string' || !raw.runId || typeof raw.startedAt !== 'string' || typeof raw.playwrightVersion !== 'string') throw new Error('runId, startedAt and playwrightVersion must be strings');
72
84
  if (raw.shard !== null && typeof raw.shard !== 'string') throw new Error('shard must be a string or null');
73
85
  if (raw.report !== null && typeof raw.report !== 'string') throw new Error('report must be a string or null');
74
86
  if (!isObj(raw.specs) || !Object.values(raw.specs).every((v) => typeof v === 'string' && (v === '' || SHA.test(v)))) throw new Error('specs must map files to sha256');
87
+ if (raw.schema === 2 && !(isObj(raw.contract) && Object.values(raw.contract).every((v) => typeof v === 'string' && SHA.test(v)))) {
88
+ throw new Error('contract must map contract files to sha256 (schema 2)');
89
+ }
75
90
  if (!Array.isArray(raw.attempts)) throw new Error('attempts must be a list');
76
91
  return {
77
92
  file, runId: raw.runId, startedAt: raw.startedAt, playwrightVersion: raw.playwrightVersion, shard: raw.shard as string | null,
78
- report: raw.report as string | null, specs: raw.specs as Record<string, string>, attempts: raw.attempts.map(attemptOf),
93
+ report: raw.report as string | null, specs: raw.specs as Record<string, string>,
94
+ contract: raw.schema === 2 ? raw.contract as Record<string, string> : null, attempts: raw.attempts.map(attemptOf),
79
95
  };
80
96
  }
@@ -439,12 +439,12 @@ export function renderCsv(summary: ScreenSummary, rows: TestCaseRow[], specLink:
439
439
  * multi-locale delivery aggregator. Pass an empty string to force the
440
440
  * base (no suffix) variant.
441
441
  */
442
- export function writeCsv(cwd: string, screen: string, csvContent: string, locale?: string | null): string {
442
+ export function writeCsv(cwd: string, screen: string, csvContent: string, locale?: string | null, opts: DeliverableFileOptions = {}): string {
443
443
  const outDir = path.join(cwd, 'qa', 'deliverables');
444
444
  if (!fs.existsSync(outDir)) {
445
445
  fs.mkdirSync(outDir, { recursive: true });
446
446
  }
447
- const outPath = path.join(outDir, `${deliverableBasename(screen, locale)}.csv`);
447
+ const outPath = path.join(outDir, `${deliverableBasename(screen, locale, opts)}.csv`);
448
448
  fs.writeFileSync(outPath, csvContent, 'utf-8');
449
449
  return outPath;
450
450
  }
@@ -455,8 +455,16 @@ export function writeCsv(cwd: string, screen: string, csvContent: string, locale
455
455
  * locale is given (or `SUNGEN_ENV` is set and no explicit override). Pass
456
456
  * `null` / empty string to force the base variant.
457
457
  */
458
- export function deliverableBasename(screen: string, locale?: string | null): string {
458
+ export function deliverableBasename(screen: string, locale?: string | null, opts: DeliverableFileOptions = {}): string {
459
459
  const effective = locale !== undefined ? locale : process.env.SUNGEN_ENV;
460
- return effective ? `${screen}-testcases.${effective}` : `${screen}-testcases`;
460
+ return `${effective ? `${screen}-testcases.${effective}` : `${screen}-testcases`}${opts.draft ? DRAFT_MARK : ''}`;
461
461
  }
462
462
 
463
+ /**
464
+ * A DRAFT render (a watermarked `--preview`) is written beside the official deliverable, never onto it
465
+ * (#965): `<screen>-testcases[.env].DRAFT.<ext>`. Readers of `qa/deliverables/` skip it (isDraftDeliverable).
466
+ */
467
+ export interface DeliverableFileOptions { draft?: boolean }
468
+ export const DRAFT_MARK = '.DRAFT';
469
+ export const isDraftDeliverable = (fileName: string): boolean => /\.DRAFT\.[^.]+$/.test(fileName);
470
+
@@ -169,7 +169,12 @@ export function approveMatrix(paths: MatrixTargetPaths, groupIds?: string[]): {
169
169
 
170
170
  export type MatrixFormat = 'xlsx' | 'csv' | 'both';
171
171
 
172
- /** Render + write the requested format(s). Caller has already enforced the gate policy. */
172
+ /**
173
+ * Render + write the requested format(s). Caller has already enforced the gate policy. A draft (the
174
+ * matrix is not approved for official delivery: it carries the DRAFT banner) goes to its own
175
+ * `…-testcases.DRAFT.<ext>` and never overwrites the official file (#965); the tester's Manual results
176
+ * are still carried from the official workbook when it can be read.
177
+ */
173
178
  export async function writeMatrixDeliverables(
174
179
  paths: MatrixTargetPaths,
175
180
  model: MatrixModel,
@@ -179,23 +184,25 @@ export async function writeMatrixDeliverables(
179
184
  ): Promise<{ csvPath?: string; xlsxPath?: string; manualCarry?: ManualCarryReport }> {
180
185
  const out: { csvPath?: string; xlsxPath?: string; manualCarry?: ManualCarryReport } = {};
181
186
  const xlsx = format === 'xlsx' || format === 'both';
187
+ const draft = model.manifest.approvalState === 'draft';
182
188
  // Read the workbook about to be overwritten BEFORE writing anything, so the tester's Manual
183
189
  // results survive (#756) and a refusal leaves no half-written target behind.
184
190
  // A CSV-only export overwrites no workbook, so an unreadable one is no reason to refuse it — but a
185
191
  // readable one still holds the Manual results the CSV should show.
192
+ // A draft overwrites nothing official either, so an unreadable official workbook is no reason to refuse it.
186
193
  const prior = priorRead !== undefined ? priorRead
187
- : xlsx ? await readPriorManualResults(deliverableXlsxPath(paths.cwd, paths.unit))
194
+ : xlsx && !draft ? await readPriorManualResults(deliverableXlsxPath(paths.cwd, paths.unit))
188
195
  : await readPriorManualResults(deliverableXlsxPath(paths.cwd, paths.unit)).catch(() => null);
189
196
  // Into the model first: the CSV, every cached total and roll-up, and the caller's summary
190
197
  // all read it (#891).
191
198
  applyManualCarry(model, prior);
192
199
  if (format === 'csv' || format === 'both') {
193
- out.csvPath = writeCsv(paths.cwd, paths.unit, renderMatrixCsv(model));
200
+ out.csvPath = writeCsv(paths.cwd, paths.unit, renderMatrixCsv(model), undefined, { draft });
194
201
  }
195
202
  if (xlsx) {
196
203
  const wb = renderMatrixXlsx(model, getPackageVersion());
197
204
  out.manualCarry = carryManualResults(wb, model, prior);
198
- out.xlsxPath = await writeXlsx(paths.cwd, paths.unit, wb);
205
+ out.xlsxPath = await writeXlsx(paths.cwd, paths.unit, wb, undefined, { draft });
199
206
  }
200
207
  return out;
201
208
  }
@@ -18,7 +18,7 @@ import ExcelJS from 'exceljs';
18
18
  import JSZip from 'jszip';
19
19
  import { ApiCatalogEntry, ApiDetailRow, ScreenSummary, TestCaseRow } from './types';
20
20
  import { getPackageVersion } from './package-info';
21
- import { deliverableBasename } from './csv-exporter';
21
+ import { deliverableBasename, DeliverableFileOptions } from './csv-exporter';
22
22
  import {
23
23
  extractApiCallOrder,
24
24
  extractCasesDataset,
@@ -114,8 +114,8 @@ export function buildApiDetailRows(
114
114
  * to force the base (no suffix) variant.
115
115
  */
116
116
  /** Where `writeXlsx` puts a unit's workbook — shared so a reader targets the identical file. */
117
- export function deliverableXlsxPath(cwd: string, screen: string, locale?: string | null): string {
118
- return path.join(cwd, 'qa', 'deliverables', `${deliverableBasename(screen, locale)}.xlsx`);
117
+ export function deliverableXlsxPath(cwd: string, screen: string, locale?: string | null, opts: DeliverableFileOptions = {}): string {
118
+ return path.join(cwd, 'qa', 'deliverables', `${deliverableBasename(screen, locale, opts)}.xlsx`);
119
119
  }
120
120
 
121
121
  export async function writeXlsx(
@@ -123,8 +123,9 @@ export async function writeXlsx(
123
123
  screen: string,
124
124
  wb: ExcelJS.Workbook,
125
125
  locale?: string | null,
126
+ opts: DeliverableFileOptions = {},
126
127
  ): Promise<string> {
127
- const outPath = deliverableXlsxPath(cwd, screen, locale);
128
+ const outPath = deliverableXlsxPath(cwd, screen, locale, opts);
128
129
  const outDir = path.dirname(outPath);
129
130
  if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
130
131
  const buffer = await wb.xlsx.writeBuffer();
@@ -84,12 +84,14 @@ export function evaluateGraph(
84
84
  * rules of its own: each offered step's node says what a person runs (its `human` hook), and a decide
85
85
  * step without one is its own command. `cut`: an open blocking node ended the walk, so only what does
86
86
  * not depend on it is offered (a hard verdict that surfaces past the cut), and the board's asks that
87
- * need the unit past it (sign-off, accept-in-run) wait; `heldBy` is that node's title.
87
+ * need the unit past it (sign-off, accept-in-run) wait; `heldBy` is that node's title and `heldByCommand`
88
+ * its offered step's command (#966: "More appear once …" names what to run).
88
89
  */
89
- export function evaluateHumanActions(nodes: readonly NodeDecl[], s: UnitState, tcStates?: TestCaseState[]): { actions: HumanAction[]; cut: boolean; heldBy?: string } {
90
+ export function evaluateHumanActions(nodes: readonly NodeDecl[], s: UnitState, tcStates?: TestCaseState[]): { actions: HumanAction[]; cut: boolean; heldBy?: string; heldByCommand?: string } {
90
91
  const { offered, cut, cutBy } = walkFrontier(nodes, s, tcStates, {});
91
92
  const actions = offered.flatMap(({ node, step, cases }) => (node.human
92
93
  ? node.human(s, step, cases)
93
94
  : step.kind === 'decide' ? [{ command: step.command, reason: step.because, kind: 'decide' as const }] : []));
94
- return { actions, cut, ...(cutBy ? { heldBy: cutBy.title } : {}) };
95
+ const heldByCommand = cutBy ? offered.find((o) => o.node === cutBy)?.step.command : undefined;
96
+ return { actions, cut, ...(cutBy ? { heldBy: cutBy.title } : {}), ...(heldByCommand ? { heldByCommand } : {}) };
95
97
  }
@@ -9,6 +9,7 @@ import { parse as parseYaml } from 'yaml';
9
9
  import { featureFilesFor } from '../harness/unit-paths';
10
10
  import { readTextFile } from '../harness/read-text';
11
11
  import { loadPlaywrightReport } from '../exporters/playwright-report-parser';
12
+ import { isDraftDeliverable } from '../exporters/csv-exporter';
12
13
 
13
14
  /** `<f>-test-result[.<os>][.<env>].json` — a mobile run writes the OS into the name, an env run the env (#868). */
14
15
  export const RESULT_FILE = /-test-result(?:\.[^.]+)*\.json$/;
@@ -72,6 +73,7 @@ export function countFailedResults(resultFiles: string[]): number {
72
73
 
73
74
  export function hasDeliverableFor(projectRoot: string, slug: string): boolean {
74
75
  try {
75
- return fs.readdirSync(path.join(projectRoot, 'qa', 'deliverables')).some((f) => f.startsWith(`${slug}-`));
76
+ // A `--preview` DRAFT is not a deliverable (#965).
77
+ return fs.readdirSync(path.join(projectRoot, 'qa', 'deliverables')).some((f) => f.startsWith(`${slug}-`) && !isDraftDeliverable(f));
76
78
  } catch { return false; } // nothing exported yet
77
79
  }
@@ -58,6 +58,8 @@ function provenanceField(r: Record<string, unknown>, key: 'sungenVersion' | 'cat
58
58
  const writtenBy = (r: Record<string, unknown>): string | null => provenanceField(r, 'sungenVersion');
59
59
 
60
60
  export interface RulesStamp { catalogHash: string | null; rulesHash: string | null }
61
+ /** rulesProblem's answer for a report that records no rules (`provenance.catalogHash` / `rulesHash`): an older sungen wrote it. */
62
+ export const NO_RULES_STAMP = 'audited by an older sungen, which did not record the rules it scored with';
61
63
  /** What getProvenance writes when it could not read a catalog — never a match, on either side. */
62
64
  const UNKNOWN = 'unknown';
63
65
 
@@ -67,7 +69,7 @@ const UNKNOWN = 'unknown';
67
69
  * unknown == unknown proves nothing.
68
70
  */
69
71
  export function rulesProblem(was: RulesStamp, now: RulesStamp): string | null {
70
- if (!was.catalogHash || !was.rulesHash) return 'it does not say which rules scored it (no provenance.catalogHash / rulesHash)';
72
+ if (!was.catalogHash || !was.rulesHash) return NO_RULES_STAMP;
71
73
  if ([was.catalogHash, was.rulesHash].includes(UNKNOWN)) return 'the rules that scored it are unknown (provenance says "unknown")';
72
74
  if (!now.catalogHash || !now.rulesHash || [now.catalogHash, now.rulesHash].includes(UNKNOWN)) return 'this sungen cannot read its own rules (catalog or rules hash unknown), so it trusts no report';
73
75
  if (was.catalogHash === now.catalogHash && was.rulesHash === now.rulesHash) return null;
@@ -162,7 +164,10 @@ export function verifyAuditReport(projectRoot: string, unit: string): AuditVerif
162
164
  }
163
165
  const stale: AuditVerification['problems'] = [];
164
166
  const rules = otherRules(r);
165
- if (rules) stale.push({ code: 'rules-changed', message: `${writtenBy(r) ? `written by sungen ${writtenBy(r)}, ` : ''}${rules}` });
167
+ // A report from before the rules stamp reads in plain words, with the one command that clears it (#966).
168
+ if (rules) stale.push({ code: 'rules-changed', message: rules === NO_RULES_STAMP
169
+ ? `audited by an older sungen${writtenBy(r) ? ` (${writtenBy(r)})` : ''} — run \`sungen audit --screen ${unit}\` once`
170
+ : `${writtenBy(r) ? `written by sungen ${writtenBy(r)}, ` : ''}${rules}` });
166
171
  if (r.decisionsHash !== current) stale.push({ code: 'decisions-changed', message: 'the gate-override decisions changed since this audit' });
167
172
  if (!unitDir) return result('invalid', [{ code: 'unit-unresolved', message: `"${unit}" does not name exactly one unit, so the audit's inputs cannot be checked` }]);
168
173
  if (r.inputsHash !== auditInputsHash(unitDir, unit, r.decisionsHash as string)) {
@@ -106,6 +106,8 @@ export interface UnitState {
106
106
  * are listed (sign-off, accept-in-run and the first contract approval wait). Absent when nothing cuts.
107
107
  */
108
108
  humanActionsHeldBy?: string;
109
+ /** The canonical command of that blocking step (#966) — what clears the hold. */
110
+ humanActionsHeldByCommand?: string;
109
111
  }
110
112
 
111
113
  export interface ReadUnitStateOptions {
@@ -188,6 +190,7 @@ export function readUnitState(
188
190
  state.humanActions = [...graph.actions, ...boardHumanActions(projectRoot, unitDir, state, graph.cut)]
189
191
  .filter((a) => !seen.has(a.command) && Boolean(seen.add(a.command)));
190
192
  if (graph.heldBy) state.humanActionsHeldBy = graph.heldBy;
193
+ if (graph.heldByCommand) state.humanActionsHeldByCommand = graph.heldByCommand;
191
194
  return state;
192
195
  }
193
196
 
@@ -10,7 +10,7 @@
10
10
  * commander's preAction, so a usage error — which never reaches an action — and `main().catch`
11
11
  * ended in silence. The state now starts the moment parsing does; the action only refines it.
12
12
  */
13
- import { sungenInvocation, withProjectInvocation } from '../cli/project-sungen-invocation';
13
+ import { sungenInvocation, withProjectInvocationInText } from '../cli/project-sungen-invocation';
14
14
  import { waitingForYouLines } from '../cli/waiting-for-you-block';
15
15
  import { resolveUnitCandidates } from '../cli/resolve-unit';
16
16
  import { readUnitState, NextStep, UnitState } from '../harness/next-step';
@@ -88,10 +88,14 @@ export function attachCliHandBack(argv: string[], cwd: string): { onAction: (nam
88
88
 
89
89
  const MARK: Record<NextStep['kind'], string> = { do: '→', blocked: '⛔', optional: '○', decide: '⚖' };
90
90
 
91
- /** `sungen next` rendered compactly for an agent's context; commands as `root` runs sungen. */
91
+ /**
92
+ * `sungen next` rendered compactly for an agent's context; commands — the step's own and those quoted in
93
+ * its reason (#966) — as `root` runs sungen, the way `sungen next` prints them.
94
+ */
92
95
  export function formatSteps(steps: NextStep[], max = 4, root?: string): string[] {
96
+ const asRun = (text: string): string => (root ? withProjectInvocationInText(text, root) : text);
93
97
  return steps.slice(0, max).map((st) =>
94
- ` ${MARK[st.kind]} ${root ? withProjectInvocation(st.command, root) : st.command}${st.kind === 'decide' ? ' (a QA decision — ask, do not run)' : ''}\n ${st.because}`);
98
+ ` ${MARK[st.kind]} ${asRun(st.command)}${st.kind === 'decide' ? ' (a QA decision — ask, do not run)' : ''}\n ${asRun(st.because)}`);
95
99
  }
96
100
 
97
101
  /** The unit a session is on (a named one when it exists, else the nearest) and its derived state. */
@@ -109,6 +113,6 @@ export function run(input: HookInput, ctx: HookContext): HookResult {
109
113
  return {
110
114
  status: 'context',
111
115
  output: [`${handBackLine(found.unit.name, sungenInvocation(found.root))} — derived from what the project holds now:`, ...formatSteps(found.state.steps, 4, found.root),
112
- ...waitingForYouLines(found.state.humanActions, found.root, found.state.humanActionsHeldBy)].join('\n'),
116
+ ...waitingForYouLines(found.state.humanActions, found.root, found.state.humanActionsHeldBy, found.state.humanActionsHeldByCommand)].join('\n'),
113
117
  };
114
118
  }
@@ -50,7 +50,7 @@ export const sessionStartUnitStateHook: SungenHook = {
50
50
  state.audit ? `Audit: ${state.audit.overall}/10 [${state.audit.gateStatus}] · ${state.audit.findings.length} finding(s).` : '',
51
51
  `Next (\`${sungen} next ${unit.name}\`, derived from the project state):`,
52
52
  ...formatSteps(state.steps, 4, root),
53
- ...waitingForYouLines(state.humanActions, root, state.humanActionsHeldBy),
53
+ ...waitingForYouLines(state.humanActions, root, state.humanActionsHeldBy, state.humanActionsHeldByCommand),
54
54
  input.source === 'compact' ? 'Context was compacted: re-read the unit\'s files before acting on them.' : '',
55
55
  others > 0 ? `Working on another unit? Ask \`${sungen} next <unit>\` (or \`${sungen} next\` for all).` : '',
56
56
  ];
@@ -111,7 +111,10 @@ terminal (`script`, `expect`, a pty wrapper), and never edit `contracts.lock.yam
111
111
  traces, write their Gherkin with `sungen contract derive --screen <name>` — deterministic, it writes
112
112
  `features/<name>.free.feature` with every scenario tagged `@free @proposed`. Do not author those
113
113
  steps yourself; you may name the scenarios and suggest VP-ids. A `@proposed` scenario is not
114
- counted by the audit until a QA approves it (`sungen contract approve --screen <name>`).
114
+ counted by the audit until a QA approves it (`sungen contract approve --screen <name>`). When
115
+ two recorded tests claim one ID, `sungen contract derive` exits `1`, names both tests and writes
116
+ nothing: stop and ask the QA to give each test its own ID (its title's leading ID, or its `@tag`).
117
+ Never rename a test's ID — or a scenario's — yourself to get past it.
115
118
 
116
119
  ## Platform detection (do this FIRST)
117
120