@sun-asterisk/sungen 3.3.0-alpha.1 → 3.3.0-alpha.2

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 (165) hide show
  1. package/dist/capabilities/discover.d.ts +4 -0
  2. package/dist/capabilities/discover.d.ts.map +1 -1
  3. package/dist/capabilities/discover.js +16 -2
  4. package/dist/capabilities/discover.js.map +1 -1
  5. package/dist/cli/commands/audit.js +1 -1
  6. package/dist/cli/commands/audit.js.map +1 -1
  7. package/dist/cli/commands/contract-derive-command.d.ts +3 -2
  8. package/dist/cli/commands/contract-derive-command.d.ts.map +1 -1
  9. package/dist/cli/commands/contract-derive-command.js +3 -3
  10. package/dist/cli/commands/contract-derive-command.js.map +1 -1
  11. package/dist/cli/commands/contract.js +1 -1
  12. package/dist/cli/commands/contract.js.map +1 -1
  13. package/dist/cli/commands/delivery.d.ts.map +1 -1
  14. package/dist/cli/commands/delivery.js +9 -2
  15. package/dist/cli/commands/delivery.js.map +1 -1
  16. package/dist/cli/commands/journey.js +2 -2
  17. package/dist/cli/commands/journey.js.map +1 -1
  18. package/dist/cli/commands/next.d.ts.map +1 -1
  19. package/dist/cli/commands/next.js +8 -6
  20. package/dist/cli/commands/next.js.map +1 -1
  21. package/dist/cli/index.js +1 -1
  22. package/dist/cli/index.js.map +1 -1
  23. package/dist/cli/project-sungen-invocation.d.ts +7 -0
  24. package/dist/cli/project-sungen-invocation.d.ts.map +1 -0
  25. package/dist/cli/project-sungen-invocation.js +76 -0
  26. package/dist/cli/project-sungen-invocation.js.map +1 -0
  27. package/dist/contract/derive-free-feature.d.ts +8 -0
  28. package/dist/contract/derive-free-feature.d.ts.map +1 -1
  29. package/dist/contract/derive-free-feature.js +10 -1
  30. package/dist/contract/derive-free-feature.js.map +1 -1
  31. package/dist/contract/trace/collect-trace-evidence.js +1 -1
  32. package/dist/contract/trace/collect-trace-evidence.js.map +1 -1
  33. package/dist/contract/trace/test-step-join.d.ts +3 -3
  34. package/dist/contract/trace/test-step-join.d.ts.map +1 -1
  35. package/dist/contract/trace/test-step-join.js +2 -2
  36. package/dist/contract/trace/test-step-join.js.map +1 -1
  37. package/dist/contract/trace/trace-file-reader.d.ts +7 -1
  38. package/dist/contract/trace/trace-file-reader.d.ts.map +1 -1
  39. package/dist/contract/trace/trace-file-reader.js +10 -4
  40. package/dist/contract/trace/trace-file-reader.js.map +1 -1
  41. package/dist/contract/trace/trace-run-record.d.ts +4 -1
  42. package/dist/contract/trace/trace-run-record.d.ts.map +1 -1
  43. package/dist/contract/trace/trace-run-record.js +20 -14
  44. package/dist/contract/trace/trace-run-record.js.map +1 -1
  45. package/dist/contract/trace/trace-steps.js +1 -1
  46. package/dist/contract/trace/trace-steps.js.map +1 -1
  47. package/dist/contract/trace/trace-zip-limits.d.ts +5 -1
  48. package/dist/contract/trace/trace-zip-limits.d.ts.map +1 -1
  49. package/dist/contract/trace/trace-zip-limits.js +6 -2
  50. package/dist/contract/trace/trace-zip-limits.js.map +1 -1
  51. package/dist/decisions/confirmed-decision-commands.d.ts +11 -0
  52. package/dist/decisions/confirmed-decision-commands.d.ts.map +1 -1
  53. package/dist/decisions/confirmed-decision-commands.js +21 -3
  54. package/dist/decisions/confirmed-decision-commands.js.map +1 -1
  55. package/dist/decisions/human-confirmation.d.ts +2 -0
  56. package/dist/decisions/human-confirmation.d.ts.map +1 -1
  57. package/dist/decisions/human-confirmation.js +6 -0
  58. package/dist/decisions/human-confirmation.js.map +1 -1
  59. package/dist/exporters/matrix/xlsx-cells.js +1 -1
  60. package/dist/exporters/matrix/xlsx-cells.js.map +1 -1
  61. package/dist/graph/evaluate-graph.d.ts +3 -1
  62. package/dist/graph/evaluate-graph.d.ts.map +1 -1
  63. package/dist/graph/evaluate-graph.js +8 -5
  64. package/dist/graph/evaluate-graph.js.map +1 -1
  65. package/dist/graph/node-decl.d.ts +7 -0
  66. package/dist/graph/node-decl.d.ts.map +1 -1
  67. package/dist/graph/node-decl.js.map +1 -1
  68. package/dist/graph/nodes/test-case-contract-nodes.d.ts +3 -1
  69. package/dist/graph/nodes/test-case-contract-nodes.d.ts.map +1 -1
  70. package/dist/graph/nodes/test-case-contract-nodes.js +7 -4
  71. package/dist/graph/nodes/test-case-contract-nodes.js.map +1 -1
  72. package/dist/graph/nodes/unit-contract-node.d.ts +2 -1
  73. package/dist/graph/nodes/unit-contract-node.d.ts.map +1 -1
  74. package/dist/graph/nodes/unit-contract-node.js +8 -1
  75. package/dist/graph/nodes/unit-contract-node.js.map +1 -1
  76. package/dist/graph/nodes/unit-design-nodes.d.ts.map +1 -1
  77. package/dist/graph/nodes/unit-design-nodes.js +2 -1
  78. package/dist/graph/nodes/unit-design-nodes.js.map +1 -1
  79. package/dist/harness/catalog/gate-rules.yaml +2 -2
  80. package/dist/harness/gates/audit-report-verification.d.ts +15 -6
  81. package/dist/harness/gates/audit-report-verification.d.ts.map +1 -1
  82. package/dist/harness/gates/audit-report-verification.js +49 -20
  83. package/dist/harness/gates/audit-report-verification.js.map +1 -1
  84. package/dist/harness/gates/delivery-checklist.js +2 -2
  85. package/dist/harness/gates/delivery-checklist.js.map +1 -1
  86. package/dist/harness/gates/delivery-decision-gate.d.ts +4 -0
  87. package/dist/harness/gates/delivery-decision-gate.d.ts.map +1 -1
  88. package/dist/harness/gates/delivery-decision-gate.js +26 -1
  89. package/dist/harness/gates/delivery-decision-gate.js.map +1 -1
  90. package/dist/harness/gates/delivery-decision-step.d.ts +4 -1
  91. package/dist/harness/gates/delivery-decision-step.d.ts.map +1 -1
  92. package/dist/harness/gates/delivery-decision-step.js +17 -2
  93. package/dist/harness/gates/delivery-decision-step.js.map +1 -1
  94. package/dist/harness/journey.d.ts +19 -0
  95. package/dist/harness/journey.d.ts.map +1 -1
  96. package/dist/harness/journey.js +94 -11
  97. package/dist/harness/journey.js.map +1 -1
  98. package/dist/harness/provenance.d.ts +29 -0
  99. package/dist/harness/provenance.d.ts.map +1 -1
  100. package/dist/harness/provenance.js +109 -9
  101. package/dist/harness/provenance.js.map +1 -1
  102. package/dist/hooks/guard-protected-paths.d.ts.map +1 -1
  103. package/dist/hooks/guard-protected-paths.js +3 -2
  104. package/dist/hooks/guard-protected-paths.js.map +1 -1
  105. package/dist/hooks/guard-rules.d.ts +1 -1
  106. package/dist/hooks/guard-tty-spoof-rule.d.ts +1 -1
  107. package/dist/hooks/guard-tty-spoof-rule.js +2 -2
  108. package/dist/hooks/guard-tty-spoof-rule.js.map +1 -1
  109. package/dist/hooks/hand-back-core.d.ts +5 -3
  110. package/dist/hooks/hand-back-core.d.ts.map +1 -1
  111. package/dist/hooks/hand-back-core.js +17 -10
  112. package/dist/hooks/hand-back-core.js.map +1 -1
  113. package/dist/hooks/session-start-unit-state-hook.d.ts.map +1 -1
  114. package/dist/hooks/session-start-unit-state-hook.js +5 -3
  115. package/dist/hooks/session-start-unit-state-hook.js.map +1 -1
  116. package/dist/orchestrator/assets-drift.d.ts +5 -0
  117. package/dist/orchestrator/assets-drift.d.ts.map +1 -1
  118. package/dist/orchestrator/assets-drift.js +12 -3
  119. package/dist/orchestrator/assets-drift.js.map +1 -1
  120. package/dist/orchestrator/project-initializer.d.ts.map +1 -1
  121. package/dist/orchestrator/project-initializer.js +1 -0
  122. package/dist/orchestrator/project-initializer.js.map +1 -1
  123. package/dist/orchestrator/templates/ai-src/commands/create-test.md +2 -2
  124. package/dist/orchestrator/templates/ai-src/commands/run-test.md +1 -1
  125. package/package.json +3 -3
  126. package/src/capabilities/discover.ts +15 -2
  127. package/src/cli/commands/audit.ts +1 -1
  128. package/src/cli/commands/contract-derive-command.ts +7 -6
  129. package/src/cli/commands/contract.ts +1 -1
  130. package/src/cli/commands/delivery.ts +7 -2
  131. package/src/cli/commands/journey.ts +4 -4
  132. package/src/cli/commands/next.ts +8 -6
  133. package/src/cli/index.ts +1 -1
  134. package/src/cli/project-sungen-invocation.ts +39 -0
  135. package/src/contract/derive-free-feature.ts +12 -2
  136. package/src/contract/trace/collect-trace-evidence.ts +1 -1
  137. package/src/contract/trace/test-step-join.ts +6 -5
  138. package/src/contract/trace/trace-file-reader.ts +17 -7
  139. package/src/contract/trace/trace-run-record.ts +14 -8
  140. package/src/contract/trace/trace-steps.ts +1 -1
  141. package/src/contract/trace/trace-zip-limits.ts +6 -2
  142. package/src/decisions/confirmed-decision-commands.ts +25 -4
  143. package/src/decisions/human-confirmation.ts +5 -0
  144. package/src/exporters/matrix/xlsx-cells.ts +1 -1
  145. package/src/graph/evaluate-graph.ts +9 -5
  146. package/src/graph/node-decl.ts +7 -0
  147. package/src/graph/nodes/test-case-contract-nodes.ts +9 -4
  148. package/src/graph/nodes/unit-contract-node.ts +8 -1
  149. package/src/graph/nodes/unit-design-nodes.ts +2 -1
  150. package/src/harness/catalog/gate-rules.yaml +2 -2
  151. package/src/harness/gates/audit-report-verification.ts +51 -23
  152. package/src/harness/gates/delivery-checklist.ts +2 -2
  153. package/src/harness/gates/delivery-decision-gate.ts +29 -1
  154. package/src/harness/gates/delivery-decision-step.ts +19 -3
  155. package/src/harness/journey.ts +95 -13
  156. package/src/harness/provenance.ts +99 -11
  157. package/src/hooks/guard-protected-paths.ts +3 -2
  158. package/src/hooks/guard-rules.ts +1 -1
  159. package/src/hooks/guard-tty-spoof-rule.ts +3 -3
  160. package/src/hooks/hand-back-core.ts +17 -11
  161. package/src/hooks/session-start-unit-state-hook.ts +5 -3
  162. package/src/orchestrator/assets-drift.ts +12 -5
  163. package/src/orchestrator/project-initializer.ts +1 -0
  164. package/src/orchestrator/templates/ai-src/commands/create-test.md +2 -2
  165. package/src/orchestrator/templates/ai-src/commands/run-test.md +1 -1
@@ -711,6 +711,8 @@ async function exportMatrixTarget(
711
711
  }
712
712
 
713
713
  const { model, mapMissing, mapErrors } = loadMatrixModel(paths);
714
+ // An audit that is untrusted or FAILs never yields an official deliverable: --preview renders a DRAFT (#935).
715
+ if (model && decisions?.auditDraft(qaDir(cwd, target))) model.manifest.approvalState = 'draft';
714
716
 
715
717
  if (mapMissing) {
716
718
  log(`${COLOR.yellow}${paths.label}: no delivery map${COLOR.reset} (${path.relative(cwd, paths.mapFile)})`);
@@ -1095,9 +1097,12 @@ export function registerDeliveryCommand(program: Command): void {
1095
1097
  // an approval are the normal state of a live project. Preflight skips are
1096
1098
  // not counted here: reaching this point in an explicit scope means
1097
1099
  // --continue-on-missing was passed, which IS the opt-in to tolerate them.
1098
- const failed = explicitScope
1100
+ // --check reports like its checklist: an audit box that does not hold is a non-zero exit (#935).
1101
+ const auditFails = (decisions?.untrustedAudits.length ?? 0) + (decisions?.failingAudits.length ?? 0);
1102
+ if (options.check && auditFails) log(`${COLOR.red}✗ --check: ${auditFails} unit(s) whose audit does not pass — see the audit box above.${COLOR.reset}`);
1103
+ const failed = (explicitScope
1099
1104
  ? notExported.length > 0
1100
- : matrixSummaries.length === 0;
1105
+ : matrixSummaries.length === 0) || (!!options.check && auditFails > 0);
1101
1106
  if (failed) process.exit(1);
1102
1107
  return;
1103
1108
  }
@@ -1,9 +1,9 @@
1
1
  import { Command } from 'commander';
2
2
  import * as path from 'path';
3
- import { runJourney, signoff, renderJourneyBoard, rollupJourney, renderRollupBoard, writeJourneyFileBestEffort } from '../../harness/journey';
3
+ import { runJourney, renderJourneyBoard, rollupJourney, renderRollupBoard, writeJourneyFileBestEffort } from '../../harness/journey';
4
4
  import { reportSlug } from '../../harness/unit-paths';
5
5
  import { findUnitDir } from '../resolve-unit';
6
- import { answerPendingMigration, decideWithConfirmation, DECIDE_KINDS, waiveWithConfirmation } from '../../decisions/confirmed-decision-commands';
6
+ import { answerPendingMigration, decideWithConfirmation, DECIDE_KINDS, signoffWithConfirmation, waiveWithConfirmation } from '../../decisions/confirmed-decision-commands';
7
7
  import { processIO } from '../../decisions/human-confirmation';
8
8
 
9
9
  const collect = (value: string, previous: string[]): string[] => [...previous, value];
@@ -22,7 +22,7 @@ export function registerJourneyCommand(program: Command): void {
22
22
  .option('--payload <json>', 'Kind-specific fields as a JSON object, e.g. a gate-override\'s {"rule","severity","scope"} (with --decide)')
23
23
  .option('--visibility <v>', 'internal (default, never exported) | client (with --decide)')
24
24
  .option('--supersedes <id>', 'The decision this one replaces — the old entry is never edited (with --decide)')
25
- .option('--signoff', 'Sign off the review queue — the single human gate (allowed only when every other obligation is satisfied/waived)')
25
+ .option('--signoff', 'Sign off the review queue — the single human gate (allowed only when every other obligation is satisfied/waived); a human types a confirmation on a TTY')
26
26
  .option('--by <name>', 'Who is signing off (recorded with --signoff)')
27
27
  .option('--json', 'Output the raw JSON report')
28
28
  .action(async (options) => {
@@ -63,7 +63,7 @@ export function registerJourneyCommand(program: Command): void {
63
63
  const report = options.waive
64
64
  ? await waiveWithConfirmation({ projectRoot: process.cwd(), unit: name, obligation: options.waive, reason: options.reason || '', io })
65
65
  : options.signoff
66
- ? signoff(process.cwd(), name, options.by)
66
+ ? await signoffWithConfirmation({ projectRoot: process.cwd(), unit: name, by: options.by, io })
67
67
  : runJourney(process.cwd(), name);
68
68
 
69
69
  const outDir = path.join(process.cwd(), '.sungen', 'journey');
@@ -20,6 +20,7 @@ import { testCaseStatusLabel } from '../../graph/test-case-satisfied';
20
20
  import { testCaseStates } from '../../graph/test-case-states';
21
21
  import { deriveSteps, NextStep, readUnitState, UnitState } from '../../harness/next-step';
22
22
  import { resolveUnit, resolveUnitCandidates, AmbiguousUnitError } from '../resolve-unit';
23
+ import { withProjectInvocation } from '../project-sungen-invocation';
23
24
 
24
25
  /** Every unit the project holds, so `sungen next` with no argument still answers. */
25
26
  function allUnits(cwd: string): Array<{ name: string; kind: UnitState['kind']; dir: string }> {
@@ -35,15 +36,16 @@ function allUnits(cwd: string): Array<{ name: string; kind: UnitState['kind']; d
35
36
  return out;
36
37
  }
37
38
 
38
- function renderSteps(steps: NextStep[]): void {
39
+ /** Steps as text, each `sungen …` command as this project runs it (#939); `--json` keeps them canonical. */
40
+ function renderSteps(steps: NextStep[], cwd: string): void {
39
41
  for (const st of steps) {
40
42
  const mark = st.kind === 'blocked' ? '⛔' : st.kind === 'optional' ? '○' : st.kind === 'decide' ? '⚖' : '→';
41
- console.log(` ${mark} ${st.command}${st.kind === 'decide' ? ' (a QA decision — ask, do not run)' : ''}`);
43
+ console.log(` ${mark} ${withProjectInvocation(st.command, cwd)}${st.kind === 'decide' ? ' (a QA decision — ask, do not run)' : ''}`);
42
44
  console.log(` ${st.because}`);
43
45
  }
44
46
  }
45
47
 
46
- function render(s: UnitState): void {
48
+ function render(s: UnitState, cwd: string): void {
47
49
  const L = console.log;
48
50
  L('');
49
51
  L(`━━━ Next steps: ${s.unit} (${s.kind}) ━━━`);
@@ -59,7 +61,7 @@ function render(s: UnitState): void {
59
61
  if (s.audit) L(` audit: ${s.audit.overall}/10 [${s.audit.gateStatus}] · ${s.audit.findings.length} finding(s)`);
60
62
  else if (s.auditNotCounted) L(` audit: ${s.auditNotCounted.state === 'stale' ? 'stale' : 'not trusted'} — ${s.auditNotCounted.reason}`);
61
63
  L('');
62
- renderSteps(s.steps);
64
+ renderSteps(s.steps, cwd);
63
65
  L('');
64
66
  }
65
67
 
@@ -89,7 +91,7 @@ function caseView(s: UnitState, cwd: string, unitDir: string, id: string, json:
89
91
  if (tc.anchors.length) L(` anchors: ${tc.anchors.map((a) => `${a.anchor} (${a.change})`).join(', ')}`);
90
92
  for (const f of tc.findings) L(` finding: ${f.code} [${f.confidence.label} ${f.confidence.score}] ${f.citation}`);
91
93
  L('');
92
- if (steps.length) renderSteps(steps);
94
+ if (steps.length) renderSteps(steps, cwd);
93
95
  else L(` ✓ nothing to do for this test case — ${testCaseStatusLabel(tc)}`);
94
96
  L('');
95
97
  }
@@ -131,7 +133,7 @@ export function registerNextCommand(program: Command): void {
131
133
  console.log(JSON.stringify(states.length === 1 ? states[0] : states, null, 2));
132
134
  return;
133
135
  }
134
- for (const s of states) render(s);
136
+ for (const s of states) render(s, cwd);
135
137
  } catch (e) {
136
138
  console.error(`Error: ${e instanceof Error ? e.message : e}`);
137
139
  process.exit(1);
package/src/cli/index.ts CHANGED
@@ -56,7 +56,7 @@ async function main() {
56
56
  // process exit (not postAction) because several commands exit with a MEANINGFUL code that skips
57
57
  // commander's hooks (`depth-lint` → 2). It points at `sungen next` instead of restating a step,
58
58
  // so it cannot drift from the project state. A failure prints it on stderr after the error;
59
- // `--json`/`--grep` keep it off stdout (#836); `next` and `hook` never print one. The line and
59
+ // `--json`/`--grep` keep it off stdout (#836); `hook` never prints one, `next` only when it fails (#937). The line and
60
60
  // those rules live in the hook core (src/hooks/hand-back-core.ts) — this file only wires it.
61
61
  const handBack = attachCliHandBack(process.argv, process.cwd());
62
62
  const program = new Command();
@@ -0,0 +1,39 @@
1
+ /**
2
+ * How a hint names sungen inside a project (#939, items 1–2).
3
+ *
4
+ * A project that installs its own sungen (`npm i -D @sun-asterisk/sungen`) runs it with `npx sungen`;
5
+ * a bare `sungen` is whatever global install the machine has — in the field test an older 3.2.24-beta
6
+ * beside the project's 3.3.0-alpha. And `sungen update` without a flag switches to the stable
7
+ * channel, so on an alpha or beta install the update hint must keep the channel it runs on.
8
+ *
9
+ * sungenInvocation(root) 'npx sungen' when <root>/node_modules/@sun-asterisk/sungen exists, else 'sungen'
10
+ * withProjectInvocation(cmd, root) a canonical `sungen …` command as this project runs it
11
+ * updateCommand(root, version?) `<invocation> update` plus `--alpha` / `--beta` (`--tag <id>` for another prerelease)
12
+ *
13
+ * Only the project root itself is looked at — where `npm i` puts a QA project's install — so a
14
+ * monorepo checkout above a fixture does not turn its hints into `npx`.
15
+ */
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ import { getPackageVersion } from '../exporters/package-info';
19
+
20
+ export function sungenInvocation(projectRoot: string): string {
21
+ return fs.existsSync(path.join(projectRoot, 'node_modules', '@sun-asterisk', 'sungen', 'package.json')) ? 'npx sungen' : 'sungen';
22
+ }
23
+
24
+ /** `sungen audit --screen x` → `npx sungen audit --screen x` in a project with its own install; anything else unchanged. */
25
+ export function withProjectInvocation(command: string, projectRoot: string): string {
26
+ const inv = sungenInvocation(projectRoot);
27
+ return inv !== 'sungen' && /^sungen\b/.test(command) ? `${inv}${command.slice('sungen'.length)}` : command;
28
+ }
29
+
30
+ /** The `sungen update` flag that keeps the channel of `version` (its prerelease id); '' for stable. */
31
+ export function updateChannelFlag(version: string = getPackageVersion()): string {
32
+ const id = version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-]+)/)?.[1];
33
+ if (!id) return '';
34
+ return id === 'alpha' || id === 'beta' ? ` --${id}` : ` --tag ${id}`;
35
+ }
36
+
37
+ export function updateCommand(projectRoot: string, version: string = getPackageVersion()): string {
38
+ return `${sungenInvocation(projectRoot)} update${updateChannelFlag(version)}`;
39
+ }
@@ -35,7 +35,8 @@ export interface DeriveResult {
35
35
  file: string;
36
36
  written: boolean;
37
37
  scenarios: Array<{ id: string; title: string; steps: number; low: number; opaque: number }>;
38
- skipped: Array<{ id: string; reason: string }>;
38
+ /** `approved: true` — skipped because QA already approved it (derive never rewrites one), not for want of a trace. */
39
+ skipped: Array<{ id: string; reason: string; approved?: true }>;
39
40
  refused?: SecretHit[];
40
41
  note?: string;
41
42
  }
@@ -58,6 +59,15 @@ function semanticHits(text: string, label: string): SecretHit[] {
58
59
  } finally { fs.rmSync(dir, { recursive: true, force: true }); }
59
60
  }
60
61
 
62
+ /**
63
+ * Nothing was derived and nothing explains it away: no test of the unit left an admissible trace
64
+ * (#932). Not when the scenarios are approved (derive leaves those alone by design), up to date,
65
+ * refused, or the project is mobile (`note`).
66
+ */
67
+ export function nothingDerived(r: DeriveResult): boolean {
68
+ return !r.note && !r.refused?.length && r.scenarios.length === 0 && !r.skipped.some((s) => s.approved);
69
+ }
70
+
61
71
  export async function deriveFreeFeature(i: DeriveInput): Promise<DeriveResult> {
62
72
  const unitBase = path.basename(i.unit.name);
63
73
  const file = path.join(i.unit.dir, 'features', `${unitBase}.free.feature`);
@@ -79,7 +89,7 @@ export async function deriveFreeFeature(i: DeriveInput): Promise<DeriveResult> {
79
89
  for (const id of [...byId.keys()].sort((a, b) => a.localeCompare(b, 'en'))) {
80
90
  const runs = admissible(byId.get(id)!);
81
91
  if (!runs.length) { result.skipped.push({ id, reason: byId.get(id)![0].reason ?? 'no admissible trace' }); continue; }
82
- if (kept.some((b) => b.id === id && !b.proposed)) { result.skipped.push({ id, reason: 'approved — derive never rewrites an approved scenario; use `sungen contract check --trace`' }); continue; }
92
+ if (kept.some((b) => b.id === id && !b.proposed)) { result.skipped.push({ id, reason: 'approved — derive never rewrites an approved scenario; use `sungen contract check --trace`', approved: true }); continue; }
83
93
  const secrets = new Set<string>();
84
94
  const rendered = runs.map((r) => {
85
95
  const steps = traceIntentSteps(r.trace!);
@@ -34,7 +34,7 @@ export interface ScenarioEvidence { runs: Map<string, TraceRun[]>; problems: str
34
34
 
35
35
  /** The reason a run is not judged once the verdict's deadline has passed (fail closed). */
36
36
  export const OVER_BUDGET = 'verdict budget exceeded; run `sungen contract check`';
37
- const NO_RECORD = "no sungen run record under test-results/ — run the free tests with trace: 'on' and the sungen run-record reporter";
37
+ const NO_RECORD = "no sungen run record under test-results-free/ (or test-results/) — run the free tests with their own config: outputDir 'test-results-free', trace: 'on' and the sungen run-record reporter";
38
38
 
39
39
  /** The real path of `file` when it is a regular file INSIDE the project (symlinks resolved); else null. */
40
40
  export function insideProject(projectRoot: string, file: string): string | null {
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Join the test runner's steps (test.trace) with the browser calls (<N>-trace.trace) — phase 07
3
- * criterion 2, proven 114/114 on a real app. A library call's `stepId` is the runner step's
4
- * `callId`; walking the runner's `parentId` chain says where the call ran:
3
+ * criterion 2, proven 114/114 on a real app. Up to format 9 a library call's `stepId` is the runner
4
+ * step's `callId`; from format 10 (Playwright 1.64) the two share ONE id and `stepId` is gone, so the
5
+ * call's own `callId` is the step. Walking the runner's `parentId` chain says where the call ran:
5
6
  *
6
7
  * body the test function itself, with the path of enclosing `test.step` titles
7
8
  * hook a user `beforeEach` / `afterEach` (stage setup | teardown)
@@ -26,7 +27,7 @@ export type CallPhase =
26
27
  | { phase: 'unjoined' };
27
28
 
28
29
  export interface JoinedCall {
29
- part: string; callId: string; callNumber: number; class: string; method: string;
30
+ part: string; callId: string; class: string; method: string;
30
31
  params: Record<string, unknown>; stepId?: string; where: CallPhase; failed: boolean; startTime: number;
31
32
  }
32
33
 
@@ -90,9 +91,9 @@ export function joinCalls(trace: ParsedTrace): { steps: Map<string, RunnerStep>;
90
91
  for (const e of part.events) {
91
92
  if (e.type !== 'before') continue;
92
93
  const callId = str(e.callId);
93
- const stepId = str(e.stepId) || undefined;
94
+ const stepId = str(e.stepId) || (part.version >= 10 ? callId : '') || undefined;
94
95
  calls.push({
95
- part: part.name, callId, callNumber: Number(callId.replace(/^call@/, '')) || 0, class: str(e.class), method: str(e.method),
96
+ part: part.name, callId, class: str(e.class), method: str(e.method),
96
97
  params: (e.params && typeof e.params === 'object' ? e.params : {}) as Record<string, unknown>,
97
98
  stepId, where: stepId ? classifyStep(steps, stepId) : { phase: 'unjoined' }, failed: failed.has(callId), startTime: Number(e.startTime) || 0,
98
99
  });
@@ -5,7 +5,7 @@
5
5
  * test.trace the test runner's steps (test.step, hooks, fixtures, expect, pw:api)
6
6
  * <N>-trace.trace a library part: the browser calls, frame snapshots, logs
7
7
  * <N>-trace.network resources (stylesheets inlined into a replayed snapshot come from here)
8
- * <N>-trace.stacks call number → source frames (`file:line` of an opaque step)
8
+ * <N>-trace.stacks call id → source frames (`file:line` of an opaque step)
9
9
  * resources/<sha1> resource bodies
10
10
  *
11
11
  * Every part that has a header (test.trace and each library part) must declare a supported version;
@@ -18,7 +18,12 @@ import { SUPPORTED_TRACE_VERSIONS, traceZipLimits, TraceRejected, TraceZipLimits
18
18
 
19
19
  export type TraceEvent = { type: string } & Record<string, unknown>;
20
20
  export interface StackFrame { file: string; line: number; column: number }
21
- export interface LibraryPart { name: string; events: TraceEvent[]; network: TraceEvent[]; stacks: Map<number, StackFrame[]> }
21
+ /**
22
+ * One library part. `version` is its declared format (already accepted by the gate); `stacks` is
23
+ * keyed by call id — v8/v9 wrote a call NUMBER (`11` → `call@11`, Playwright's legacy id), v10 the
24
+ * call id itself, which it shares with the runner step (`ulgt@50`).
25
+ */
26
+ export interface LibraryPart { name: string; version: number; events: TraceEvent[]; network: TraceEvent[]; stacks: Map<string, StackFrame[]> }
22
27
 
23
28
  export interface ParsedTrace {
24
29
  label: string;
@@ -56,19 +61,21 @@ function parseJsonl(zip: ZipArchive, name: string): TraceEvent[] {
56
61
  return out;
57
62
  }
58
63
 
59
- function parseStacks(zip: ZipArchive, name: string): Map<number, StackFrame[]> {
60
- const out = new Map<number, StackFrame[]>();
64
+ function parseStacks(zip: ZipArchive, name: string): Map<string, StackFrame[]> {
65
+ const out = new Map<string, StackFrame[]>();
61
66
  if (!zip.has(name)) return out;
62
67
  let doc: { files?: unknown; stacks?: unknown };
63
68
  try { doc = JSON.parse(zip.readText(name)); } catch { throw new TraceRejected('trace.malformed', `${name} is not JSON`); }
64
69
  const files = Array.isArray(doc.files) ? doc.files.map(String) : [];
65
70
  for (const row of Array.isArray(doc.stacks) ? doc.stacks : []) {
66
- if (!Array.isArray(row) || typeof row[0] !== 'number' || !Array.isArray(row[1])) continue;
71
+ if (!Array.isArray(row) || !Array.isArray(row[1])) continue;
72
+ const id = typeof row[0] === 'number' ? `call@${row[0]}` : row[0];
73
+ if (typeof id !== 'string' || !CALL_ID.test(id)) continue;
67
74
  const frames = (row[1] as unknown[]).filter(Array.isArray).map((f) => {
68
75
  const [fileIdx, line, column] = f as number[];
69
76
  return { file: files[fileIdx] ?? '', line: Number(line) || 0, column: Number(column) || 0 };
70
77
  });
71
- out.set(row[0], frames);
78
+ out.set(id, frames);
72
79
  }
73
80
  return out;
74
81
  }
@@ -81,9 +88,12 @@ export function readTraceBuffer(buf: Buffer, label: string, limits: TraceZipLimi
81
88
  const runner = parseJsonl(zip, 'test.trace');
82
89
  const library: LibraryPart[] = zip.names().filter((n) => /^\d+-trace\.trace$/.test(n)).sort().map((n) => {
83
90
  const base = n.replace(/\.trace$/, '');
91
+ const events = parseJsonl(zip, n);
92
+ const version = headerOf(events)?.version;
84
93
  return {
85
94
  name: n,
86
- events: parseJsonl(zip, n),
95
+ version: typeof version === 'number' ? version : NaN, // a non-number is refused just below
96
+ events,
87
97
  network: zip.has(`${base}.network`) ? parseJsonl(zip, `${base}.network`) : [],
88
98
  stacks: parseStacks(zip, `${base}.stacks`),
89
99
  };
@@ -4,9 +4,10 @@
4
4
  * the code that produced it live, so an unknown key, a wrong type or a malformed hash means the
5
5
  * record is not trusted at all (every trace it would vouch for is unbound).
6
6
  *
7
- * Found by convention: `<project>/test-results/sungen-run-record*.json` (one per run or shard).
8
- * Playwright wipes `test-results/` at the start of every run, so a record and its traces share a
9
- * lifetime.
7
+ * Found by convention: `<project>/test-results-free/sungen-run-record*.json` — the free-test config's
8
+ * own outputDir (#941) — or `<project>/test-results/sungen-run-record*.json` (one per run or shard).
9
+ * Playwright wipes its outputDir at the start of every run, so a record and its traces share a
10
+ * lifetime; the free run has its own so the compiled suite's next run cannot delete its traces.
10
11
  */
11
12
  import * as fs from 'fs';
12
13
  import * as path from 'path';
@@ -31,13 +32,18 @@ const ATTEMPT = new Set(['testId', 'file', 'title', 'retry', 'status', 'startTim
31
32
  const SHA = /^[0-9a-f]{64}$/;
32
33
  const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
33
34
 
34
- export const RUN_RECORD_DIR = 'test-results';
35
+ /** The free / impl:code run's own outputDir (the documented free-test config); compiled runs clear `test-results/`. */
36
+ export const FREE_RUN_DIR = 'test-results-free';
37
+ /** Where run records are read, in this order: the free run's dir, then Playwright's default (older configs). */
38
+ export const RUN_RECORD_DIRS: readonly string[] = [FREE_RUN_DIR, 'test-results'];
35
39
 
36
40
  export function findRunRecords(projectRoot: string): string[] {
37
- const dir = path.join(projectRoot, RUN_RECORD_DIR);
38
- let names: string[];
39
- try { names = fs.readdirSync(dir); } catch { return []; }
40
- return names.filter((n) => /^sungen-run-record(?:\.[A-Za-z0-9._-]+)?\.json$/.test(n)).sort().map((n) => path.join(dir, n));
41
+ return RUN_RECORD_DIRS.flatMap((rel) => {
42
+ const dir = path.join(projectRoot, rel);
43
+ let names: string[];
44
+ try { names = fs.readdirSync(dir); } catch { return []; }
45
+ return names.filter((n) => /^sungen-run-record(?:\.[A-Za-z0-9._-]+)?\.json$/.test(n)).sort().map((n) => path.join(dir, n));
46
+ });
41
47
  }
42
48
 
43
49
  function attemptOf(raw: unknown, i: number): RunRecordAttempt {
@@ -70,7 +70,7 @@ export function traceIntentSteps(trace: ParsedTrace): IntentStep[] {
70
70
  const runner = c.stepId ? steps.get(c.stepId) : undefined;
71
71
  out.push({
72
72
  kind, method: c.method, callId: c.callId, params: c.params, group: groupOf(steps, c.stepId),
73
- location: firstFrame(runner?.stack) ?? firstFrame(stacks.get(c.part)?.get(c.callNumber)),
73
+ location: firstFrame(runner?.stack) ?? firstFrame(stacks.get(c.part)?.get(c.callId)),
74
74
  ...(verbatim ? { verbatim } : {}), startTime: c.startTime,
75
75
  });
76
76
  }
@@ -50,8 +50,12 @@ export function traceZipLimits(env: Record<string, string | undefined> = process
50
50
  };
51
51
  }
52
52
 
53
- /** Trace format versions this reader was verified against: 8 = Playwright 1.57, 9 = 1.63. */
54
- export const SUPPORTED_TRACE_VERSIONS: ReadonlySet<number> = new Set([8, 9]);
53
+ /**
54
+ * Trace format versions this reader was verified against: 8 = Playwright 1.57, 9 = 1.63, 10 = 1.64
55
+ * (one shared call id for a library call and its runner step; stacks keyed by that id). Anything
56
+ * else — a newer format included — is refused until it has been checked the same way.
57
+ */
58
+ export const SUPPORTED_TRACE_VERSIONS: ReadonlySet<number> = new Set([8, 9, 10]);
55
59
 
56
60
  export type TraceRejectCode =
57
61
  | 'zip.too-large' | 'zip.too-many-entries' | 'zip.entry-too-large' | 'zip.total-too-large' | 'zip.ratio'
@@ -3,6 +3,7 @@
3
3
  * CLI runs on process.stdin/stderr is the code a test drives with a simulated TTY.
4
4
  *
5
5
  * waive `sungen journey --screen <u> --waive <OB> --reason …` phrase: the obligation id
6
+ * signoff `sungen journey --screen <u> --signoff [--by <name>]` phrase: SIGN-OFF (review W3)
6
7
  * decide `sungen journey [--screen <u>] --decide <kind> --text … --reason … [--anchor …]` phrase: the kind
7
8
  * migration a leftover write-ahead sentinel → REVERT or KEEP (takumi T6)
8
9
  *
@@ -16,13 +17,13 @@
16
17
  import * as path from 'path';
17
18
  import { assertNoPendingMigration, pendingMigrationState, PendingMigration, readPendingMigration, resolvePendingMigration, sentinelPath } from '../basis/migration-sentinel';
18
19
  import { parseAnchorId } from '../basis/anchor-grammar';
19
- import { JourneyReport, runJourney, waive } from '../harness/journey';
20
+ import { assertCanSignOff, assertWaivable, JourneyReport, runJourney, signoff, signoffSigner, waive } from '../harness/journey';
20
21
  import { reportSlug } from '../harness/unit-paths';
21
22
  import { appendDecision, AppendResult } from './append-decision';
22
23
  import { PROJECT_SCOPE, unitDecisionScope } from './decision-file';
23
24
  import { DecisionDraft, DecisionKind, draftErrors, JsonValue } from './decision-schema';
24
25
  import { gitUserName } from './decision-source';
25
- import { askMigrationChoice, ConfirmationIO, formatCommand, requireTypedConfirmation } from './human-confirmation';
26
+ import { askMigrationChoice, ConfirmationIO, formatCommand, requireInteractive, requireTypedConfirmation } from './human-confirmation';
26
27
  import { readDecisions } from './read-decisions';
27
28
  import { overridePayloadErrors } from '../harness/gates/gate-override-validation';
28
29
 
@@ -30,8 +31,7 @@ export interface WaiveInput { projectRoot: string; unit: string; obligation: str
30
31
 
31
32
  export async function waiveWithConfirmation(i: WaiveInput): Promise<JourneyReport> {
32
33
  if (!i.reason || !i.reason.trim()) throw new Error('A reason is required to waive (anti-amnesia: a waiver must record WHY). Use --reason "...".');
33
- const valid = runJourney(i.projectRoot, i.unit).obligations.map((o) => o.id);
34
- if (!valid.includes(i.obligation)) throw new Error(`Unknown obligation "${i.obligation}". Valid: ${valid.join(', ')}`);
34
+ assertWaivable(runJourney(i.projectRoot, i.unit), i.unit, i.obligation);
35
35
  await requireTypedConfirmation({
36
36
  what: `waive ${i.obligation} for ${i.unit}`,
37
37
  phrase: i.obligation,
@@ -42,6 +42,27 @@ export async function waiveWithConfirmation(i: WaiveInput): Promise<JourneyRepor
42
42
  return waive(i.projectRoot, i.unit, i.obligation, i.reason);
43
43
  }
44
44
 
45
+ export interface SignoffInput { projectRoot: string; unit: string; by?: string; io: ConfirmationIO }
46
+
47
+ /**
48
+ * The single human gate (DOS §3.4, S5), typed by a person on a terminal: the gate suggests it, so an
49
+ * agent must not be able to give it. Refused before the prompt when the board cannot be signed off.
50
+ */
51
+ export async function signoffWithConfirmation(i: SignoffInput): Promise<JourneyReport> {
52
+ const what = `sign off ${i.unit} (the single human gate before delivery)`;
53
+ const command = formatCommand(['sungen', 'journey', '--screen', i.unit, '--signoff', ...(i.by ? ['--by', i.by] : [])]);
54
+ requireInteractive(i.io, what, command); // before the board is read: a refused sign-off writes nothing
55
+ const { signer, gitUser } = signoffSigner(i.projectRoot, i.unit, i.by); // nobody to name → refused, no prompt
56
+ const before = runJourney(i.projectRoot, i.unit);
57
+ assertCanSignOff(before);
58
+ await requireTypedConfirmation({
59
+ what, phrase: 'SIGN-OFF', command,
60
+ summary: [`by: ${signer}${signer !== gitUser ? ` (recorded by git user ${gitUser})` : ''}`, `${before.needsYou.length} item(s) in the review queue — you have reviewed them`, 'it goes stale as soon as the audit changes (sign off again then)'],
61
+ io: i.io,
62
+ });
63
+ return signoff(i.projectRoot, i.unit, i.by);
64
+ }
65
+
45
66
  /** Kinds `--decide` records. The others have a command of their own that computes their payload. */
46
67
  export const DECIDE_KINDS: readonly DecisionKind[] = ['gate-override', 'clarification'];
47
68
  const OWN_COMMAND: Partial<Record<DecisionKind, string>> = {
@@ -79,6 +79,11 @@ function refuseWithoutTty(what: string, command: string): HumanConfirmationRefus
79
79
  );
80
80
  }
81
81
 
82
+ /** Refuse up front, before any state is read or written, when no human is at a terminal. */
83
+ export function requireInteractive(io: ConfirmationIO, what: string, command: string): void {
84
+ if (!isInteractive(io)) throw refuseWithoutTty(what, command);
85
+ }
86
+
82
87
  /** A fresh, hard-to-guess suffix for one prompt. */
83
88
  export const confirmationNonce = (): string => randomBytes(3).toString('hex').toUpperCase();
84
89
 
@@ -49,7 +49,7 @@ export function draftBanner(ws: ExcelJS.Worksheet, model: MatrixModel): void {
49
49
  if (model.manifest.approvalState !== 'draft') return;
50
50
  ws.mergeCells('A5:H5');
51
51
  const c = ws.getCell('A5');
52
- c.value = 'DRAFT — NOT APPROVED FOR OFFICIAL DELIVERY (review-required findings or proposed groups remain)';
52
+ c.value = 'DRAFT — NOT APPROVED FOR OFFICIAL DELIVERY (review-required findings, proposed groups, or an audit that does not pass)';
53
53
  c.font = { bold: true, size: 11, name: ARIAL_FONT, color: { argb: 'FFFFFFFF' } };
54
54
  c.fill = { type: 'pattern', pattern: 'solid', fgColor: DRAFT_RED };
55
55
  c.alignment = { horizontal: 'center', vertical: 'middle' };
@@ -4,7 +4,9 @@
4
4
  *
5
5
  * open active ∧ ¬satisfied (a test-case node is open when at least one case is)
6
6
  * frontier open ∧ (every `requires` satisfied ∨ ¬blocking)
7
- * cut an open blocking node ends the walk — nothing ranked after it is offered
7
+ * cut an open blocking node ends the walk — nothing ranked after it is offered, except a
8
+ * node's instances that `surfacesPastCut` (a hard contract verdict, #940), which follow
9
+ * the cutting step and never cut themselves
8
10
  *
9
11
  * `--case <id>` (`testCase` option): the frontier is that case's test-case nodes plus the blocking
10
12
  * unit nodes before them; non-blocking unit advice is left to the unit view. A `caseOnly` test-case
@@ -41,22 +43,24 @@ export function evaluateGraph(
41
43
  const ordered = [...nodes].sort((a, b) => a.rank - b.rank);
42
44
  const open = new Set<string>();
43
45
  const out: NextStep[] = [];
46
+ let cut = false;
44
47
  for (const node of ordered) {
45
48
  if (!node.appliesTo.includes(s.kind) || (node.granularity === 'test-case' && node.caseOnly && !opts.testCase)) continue;
49
+ if (cut && !node.surfacesPastCut) continue;
46
50
  let step: NextStep | null = null;
47
51
  if (node.granularity === 'unit') {
48
- if (!node.active(s) || node.satisfied(s)) continue;
52
+ if (!node.active(s) || node.satisfied(s) || (cut && !node.surfacesPastCut!(s))) continue;
49
53
  if (!opts.testCase || node.blocking) step = withMeta(node, node.step(s));
50
54
  } else {
51
- const cases = openCases(node, s, tcStates, opts.testCase);
55
+ const cases = openCases(node, s, tcStates, opts.testCase).filter((tc) => !cut || node.surfacesPastCut!(s, tc));
52
56
  if (!cases.length) continue;
53
57
  const single = Boolean(opts.testCase);
54
58
  step = withMeta(node, node.step(s, cases, single), single ? cases[0].id : undefined);
55
59
  }
56
60
  open.add(node.id);
57
61
  const ready = node.requires.every((r) => !open.has(r));
58
- if (step && (ready || !node.blocking)) out.push(step);
59
- if (node.blocking) break;
62
+ if (step && (cut || ready || !node.blocking)) out.push(step);
63
+ if (node.blocking && !cut) cut = true;
60
64
  }
61
65
  return out;
62
66
  }
@@ -42,6 +42,11 @@ export interface UnitNodeDecl extends NodeDeclBase {
42
42
  active(s: UnitState): boolean;
43
43
  satisfied(s: UnitState): boolean;
44
44
  step(s: UnitState): StepCore;
45
+ /**
46
+ * Offered even when a blocking node ranked before it cut the walk — only while this holds (#940: a
47
+ * hard contract verdict must not hide behind a stale or failing audit). It never cuts by itself.
48
+ */
49
+ surfacesPastCut?(s: UnitState): boolean;
45
50
  }
46
51
 
47
52
  export interface TestCaseNodeDecl extends NodeDeclBase {
@@ -55,6 +60,8 @@ export interface TestCaseNodeDecl extends NodeDeclBase {
55
60
  satisfied(s: UnitState, tc: TestCaseState): boolean;
56
61
  /** One step for `cases` (all open at this node): a unit-level fold, or exactly one case when `single`. */
57
62
  step(s: UnitState, cases: TestCaseState[], single: boolean): StepCore;
63
+ /** The cases offered even past a cut (see UnitNodeDecl.surfacesPastCut). */
64
+ surfacesPastCut?(s: UnitState, tc: TestCaseState): boolean;
58
65
  }
59
66
 
60
67
  export type NodeDecl = UnitNodeDecl | TestCaseNodeDecl;
@@ -11,7 +11,9 @@
11
11
  * tc.conformance its trace is in review: a `low` target identity do contract check --trace
12
12
  * · its final attempt did not pass (inconclusive) do run-test --ids
13
13
  * · a step no code can compare (opaque) decide contract check
14
- * Exempt cases (DOS-11.2) open nothing. A proposed case (never approved) asks nothing of its own: a
14
+ * tc.contract-check (a weaker / retargeted / lost check, or an uncommitted lock entry) and tc.unproven
15
+ * are hard verdicts: they are offered past a stale or failing audit (the cut at unit.audit-stale /
16
+ * unit.gate-fail), #940; tc.contract-approve (changed, not weaker) is review and waits behind it. Exempt cases (DOS-11.2) open nothing. A proposed case (never approved) asks nothing of its own: a
15
17
  * compiled test without a lock is never blocked, and the unit view offers the approval.
16
18
  */
17
19
  import { ALL_KINDS, StepCore, TestCaseNodeDecl } from '../node-decl';
@@ -29,13 +31,14 @@ const withDetail = (cases: TestCaseState[]): string =>
29
31
  const check = (s: UnitState, trace = false): string => `sungen contract check --screen ${s.unit}${trace ? ' --trace' : ''}`;
30
32
 
31
33
  function contractNode(id: string, rank: number, title: string, actor: TestCaseNodeDecl['actor'], gates: string[], caseOnly: boolean,
32
- active: (tc: TestCaseState) => boolean, step: Step): TestCaseNodeDecl {
34
+ active: (tc: TestCaseState) => boolean, step: Step, hard?: (tc: TestCaseState) => boolean): TestCaseNodeDecl {
33
35
  return {
34
36
  id, title, actor, granularity: 'test-case', appliesTo: ALL_KINDS, blocking: false, rank, gates, step,
35
37
  requires: caseOnly ? ['unit.gate-fail'] : ['unit.run'], produces: ['contracts.lock.yaml', 'decisions/<id>.yaml'],
36
38
  ...(caseOnly ? { caseOnly: true as const } : {}),
37
39
  active: (_s, tc) => active(tc),
38
40
  satisfied: (_s, tc) => exemptionOf(tc) !== null,
41
+ ...(hard ? { surfacesPastCut: (_s: UnitState, tc: TestCaseState) => hard(tc) } : {}),
39
42
  };
40
43
  }
41
44
 
@@ -60,10 +63,12 @@ export const TEST_CASE_CONTRACT_NODES: readonly TestCaseNodeDecl[] = [
60
63
  (tc) => Boolean(tc.contract?.uncommitted) || HARD_LADDER.has(classOf(tc) ?? ''),
61
64
  (s, [c]) => ({ kind: 'blocked', command: check(s), because: c.contract?.uncommitted
62
65
  ? `its lock entry is not what QA approved and committed (contract.lock-uncommitted) — \`sungen next ${s.unit}\` names the repair; \`sungen delivery\` is blocked until then`
63
- : `this case breaks its approved contract (${c.contract?.class}${c.contract?.detail ? `: ${c.contract.detail}` : ''}) — restore the approved check (in the .feature, or in the hand-written test of a free / impl:code case), or a QA approves the change on a terminal (\`sungen contract approve --screen ${s.unit} --scenario ${c.id}\`). \`sungen delivery\` is blocked until then; never weaken an expected value to go green` })),
66
+ : `this case breaks its approved contract (${c.contract?.class}${c.contract?.detail ? `: ${c.contract.detail}` : ''}) — restore the approved check (in the .feature, or in the hand-written test of a free / impl:code case), or a QA approves the change on a terminal (\`sungen contract approve --screen ${s.unit} --scenario ${c.id}\`). \`sungen delivery\` is blocked until then; never weaken an expected value to go green` }),
67
+ (tc) => Boolean(tc.contract?.uncommitted) || HARD_LADDER.has(classOf(tc) ?? '')),
64
68
  contractNode('tc.unproven', 136, 'Prove the approved free cases with a trace', 'ai', ['contract.unproven'], false,
65
69
  (tc) => classOf(tc) === 'unproven',
66
- (s, cases) => ({ kind: 'do', command: `/sungen:run-test ${s.unit}${idsArg(cases)}`, because: `${cases.length} approved free / impl:code test case(s) have no trace that proves them (${withDetail(cases)}) — run them again with trace: 'on' and the sungen run-record reporter (run-test Phase 4 does), then \`${check(s, true)}\`. Until then \`sungen contract check\` fails (contract.unproven) and \`sungen delivery\` is blocked; re-approving or editing the .feature cannot prove them` })),
70
+ (s, cases) => ({ kind: 'do', command: `/sungen:run-test ${s.unit}${idsArg(cases)}`, because: `${cases.length} approved free / impl:code test case(s) have no trace that proves them (${withDetail(cases)}) — run them again with trace: 'on' and the sungen run-record reporter (run-test Phase 4 does), then \`${check(s, true)}\`. Until then \`sungen contract check\` fails (contract.unproven) and \`sungen delivery\` is blocked; re-approving or editing the .feature cannot prove them` }),
71
+ (tc) => classOf(tc) === 'unproven'),
67
72
  contractNode('tc.conformance', 138, 'Settle the traces in review', 'ai', ['contract.opaque-step', 'contract.inconclusive'], false,
68
73
  (tc) => classOf(tc) === 'opaque' || classOf(tc) === 'inconclusive', conformanceStep),
69
74
  ];
@@ -3,7 +3,8 @@
3
3
  * verdict `sungen contract check` and the delivery guard rebuild (buildContractVerdict), so the three
4
4
  * never disagree. Always `decide`: an agent presents it and never approves its own edit.
5
5
  *
6
- * unit.contract-approve (rank 105, after the audit gate, before the first run)
6
+ * unit.contract-approve (rank 105, after the audit gate, before the first run; a hard verdict —
7
+ * hard or uncommitted — is offered past a stale or failing audit too, #940)
7
8
  * error the verdict could not be built → check it on a terminal
8
9
  * uncommitted by kind: tampered → re-approve or restore · untracked → commit after review ·
9
10
  * edited → review and commit, or `git checkout --` the lock · unverified → commit the
@@ -66,6 +67,12 @@ export const UNIT_CONTRACT_NODES: readonly UnitNodeDecl[] = [
66
67
  active: (s) => Boolean(s.contract),
67
68
  satisfied: nothingToDecide,
68
69
  step: contractStep,
70
+ // A hard verdict must not wait behind a re-audit (#940): the audit's PASS says nothing about it —
71
+ // a broken approved check, or a lock that is not what QA approved and committed (contract.lock-
72
+ // uncommitted, hard in `contract check`; the step names that repair first). What stays behind the
73
+ // cut: a changed-not-weaker contract (review — QA re-approves after the design is fixed) and a
74
+ // verdict that could not be built (not a verdict; `contract check` names the error).
75
+ surfacesPastCut: (s) => !s.contract!.error && (s.contract!.uncommitted > 0 || s.contract!.hard > 0),
69
76
  },
70
77
  {
71
78
  id: 'unit.contract-proposed', title: 'Approve the Test Contract', actor: 'human', granularity: 'unit',
@@ -10,6 +10,7 @@
10
10
  * 6 unit.flow-inventory a flow contract without `flows:` → add it, re-audit
11
11
  */
12
12
  import { ALL_KINDS, UnitNodeDecl, unitKindDir } from '../node-decl';
13
+ import { updateChannelFlag } from '../../cli/project-sungen-invocation';
13
14
 
14
15
  const design = (unit: string): string => `/sungen:create-test ${unit}`;
15
16
 
@@ -34,7 +35,7 @@ export const UNIT_DESIGN_NODES: readonly UnitNodeDecl[] = [
34
35
  satisfied: (s) => !s.assetsDrift,
35
36
  step: (s) => ({
36
37
  kind: 'do',
37
- command: 'sungen update',
38
+ command: `sungen update${updateChannelFlag(s.assetsDrift!.runningVersion)}`, // keep the channel (#939)
38
39
  because: `this project's AI commands/skills were written by sungen ${s.assetsDrift!.recordedVersion} and you are running ${s.assetsDrift!.runningVersion} — upgrading the package does NOT refresh them, so any rule added since ${s.assetsDrift!.recordedVersion} is not in effect for the next run`,
39
40
  }),
40
41
  },
@@ -535,8 +535,8 @@ rules:
535
535
  stage: deliver
536
536
  severity: error
537
537
  kind: soft
538
- rationale: "Pre-delivery halt: everything automated plus the single human sign-off must be satisfied or waived with a reason."
539
- fix: "Close the open obligations and sign off, or waive with a reason."
538
+ rationale: "Pre-delivery halt: everything automated must be satisfied or waived with a reason, plus the single human sign-off, which is given (sungen journey --signoff), never waived."
539
+ fix: "Close or waive the open obligations, then a human signs off: sungen journey --screen <unit> --signoff --by <name>."
540
540
  since: "<=3.2.34"
541
541
  offAllowed: false
542
542