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

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 (206) hide show
  1. package/dist/cli/commands/contract-derive-command.d.ts.map +1 -1
  2. package/dist/cli/commands/contract-derive-command.js +2 -0
  3. package/dist/cli/commands/contract-derive-command.js.map +1 -1
  4. package/dist/cli/commands/delivery.d.ts.map +1 -1
  5. package/dist/cli/commands/delivery.js +21 -3
  6. package/dist/cli/commands/delivery.js.map +1 -1
  7. package/dist/cli/commands/generate.d.ts.map +1 -1
  8. package/dist/cli/commands/generate.js +3 -1
  9. package/dist/cli/commands/generate.js.map +1 -1
  10. package/dist/cli/commands/next.d.ts +5 -2
  11. package/dist/cli/commands/next.d.ts.map +1 -1
  12. package/dist/cli/commands/next.js +14 -1
  13. package/dist/cli/commands/next.js.map +1 -1
  14. package/dist/cli/commands/triage.d.ts +2 -1
  15. package/dist/cli/commands/triage.d.ts.map +1 -1
  16. package/dist/cli/commands/triage.js +42 -2
  17. package/dist/cli/commands/triage.js.map +1 -1
  18. package/dist/cli/commands/update.d.ts.map +1 -1
  19. package/dist/cli/commands/update.js +6 -2
  20. package/dist/cli/commands/update.js.map +1 -1
  21. package/dist/cli/index.js +4 -0
  22. package/dist/cli/index.js.map +1 -1
  23. package/dist/cli/json-error-on-exit.d.ts +34 -0
  24. package/dist/cli/json-error-on-exit.d.ts.map +1 -0
  25. package/dist/cli/json-error-on-exit.js +63 -0
  26. package/dist/cli/json-error-on-exit.js.map +1 -0
  27. package/dist/cli/waiting-for-you-block.d.ts +15 -0
  28. package/dist/cli/waiting-for-you-block.d.ts.map +1 -0
  29. package/dist/cli/waiting-for-you-block.js +19 -0
  30. package/dist/cli/waiting-for-you-block.js.map +1 -0
  31. package/dist/contract/derive-free-feature.d.ts +2 -0
  32. package/dist/contract/derive-free-feature.d.ts.map +1 -1
  33. package/dist/contract/derive-free-feature.js +22 -0
  34. package/dist/contract/derive-free-feature.js.map +1 -1
  35. package/dist/decisions/decision-schema.d.ts +2 -1
  36. package/dist/decisions/decision-schema.d.ts.map +1 -1
  37. package/dist/decisions/decision-schema.js +2 -1
  38. package/dist/decisions/decision-schema.js.map +1 -1
  39. package/dist/decisions/legacy-waiver-migration.d.ts +2 -0
  40. package/dist/decisions/legacy-waiver-migration.d.ts.map +1 -1
  41. package/dist/decisions/legacy-waiver-migration.js +5 -2
  42. package/dist/decisions/legacy-waiver-migration.js.map +1 -1
  43. package/dist/graph/board-human-actions.d.ts +5 -0
  44. package/dist/graph/board-human-actions.d.ts.map +1 -0
  45. package/dist/graph/board-human-actions.js +83 -0
  46. package/dist/graph/board-human-actions.js.map +1 -0
  47. package/dist/graph/evaluate-graph.d.ts +16 -0
  48. package/dist/graph/evaluate-graph.d.ts.map +1 -1
  49. package/dist/graph/evaluate-graph.js +29 -6
  50. package/dist/graph/evaluate-graph.js.map +1 -1
  51. package/dist/graph/human-action.d.ts +21 -0
  52. package/dist/graph/human-action.d.ts.map +1 -0
  53. package/dist/graph/human-action.js +3 -0
  54. package/dist/graph/human-action.js.map +1 -0
  55. package/dist/graph/manual-triage-decisions.d.ts +19 -0
  56. package/dist/graph/manual-triage-decisions.d.ts.map +1 -0
  57. package/dist/graph/manual-triage-decisions.js +53 -0
  58. package/dist/graph/manual-triage-decisions.js.map +1 -0
  59. package/dist/graph/node-decl.d.ts +9 -0
  60. package/dist/graph/node-decl.d.ts.map +1 -1
  61. package/dist/graph/node-decl.js +4 -1
  62. package/dist/graph/node-decl.js.map +1 -1
  63. package/dist/graph/nodes/test-case-contract-nodes.d.ts.map +1 -1
  64. package/dist/graph/nodes/test-case-contract-nodes.js +16 -3
  65. package/dist/graph/nodes/test-case-contract-nodes.js.map +1 -1
  66. package/dist/graph/nodes/test-case-nodes.d.ts +3 -0
  67. package/dist/graph/nodes/test-case-nodes.d.ts.map +1 -1
  68. package/dist/graph/nodes/test-case-nodes.js +33 -4
  69. package/dist/graph/nodes/test-case-nodes.js.map +1 -1
  70. package/dist/graph/nodes/unit-audit-nodes.d.ts.map +1 -1
  71. package/dist/graph/nodes/unit-audit-nodes.js +1 -0
  72. package/dist/graph/nodes/unit-audit-nodes.js.map +1 -1
  73. package/dist/graph/nodes/unit-contract-node.d.ts.map +1 -1
  74. package/dist/graph/nodes/unit-contract-node.js +8 -0
  75. package/dist/graph/nodes/unit-contract-node.js.map +1 -1
  76. package/dist/graph/test-case-results.d.ts +1 -0
  77. package/dist/graph/test-case-results.d.ts.map +1 -1
  78. package/dist/graph/test-case-results.js +27 -3
  79. package/dist/graph/test-case-results.js.map +1 -1
  80. package/dist/graph/test-case-satisfied.d.ts +5 -4
  81. package/dist/graph/test-case-satisfied.d.ts.map +1 -1
  82. package/dist/graph/test-case-satisfied.js +4 -0
  83. package/dist/graph/test-case-satisfied.js.map +1 -1
  84. package/dist/graph/test-case-state-shape.d.ts +7 -1
  85. package/dist/graph/test-case-state-shape.d.ts.map +1 -1
  86. package/dist/graph/unit-results-freshness.d.ts +29 -0
  87. package/dist/graph/unit-results-freshness.d.ts.map +1 -0
  88. package/dist/graph/unit-results-freshness.js +213 -0
  89. package/dist/graph/unit-results-freshness.js.map +1 -0
  90. package/dist/harness/audit.d.ts +3 -2
  91. package/dist/harness/audit.d.ts.map +1 -1
  92. package/dist/harness/audit.js +4 -17
  93. package/dist/harness/audit.js.map +1 -1
  94. package/dist/harness/catalog/gate-rules.yaml +1 -1
  95. package/dist/harness/defect-draft.d.ts.map +1 -1
  96. package/dist/harness/defect-draft.js +12 -2
  97. package/dist/harness/defect-draft.js.map +1 -1
  98. package/dist/harness/defects.d.ts +14 -0
  99. package/dist/harness/defects.d.ts.map +1 -1
  100. package/dist/harness/defects.js +14 -10
  101. package/dist/harness/defects.js.map +1 -1
  102. package/dist/harness/flow-check.d.ts +9 -0
  103. package/dist/harness/flow-check.d.ts.map +1 -1
  104. package/dist/harness/flow-check.js +11 -0
  105. package/dist/harness/flow-check.js.map +1 -1
  106. package/dist/harness/gates/audit-gate-verdict.d.ts.map +1 -1
  107. package/dist/harness/gates/audit-gate-verdict.js +2 -1
  108. package/dist/harness/gates/audit-gate-verdict.js.map +1 -1
  109. package/dist/harness/gates/audit-input-hash.d.ts +2 -0
  110. package/dist/harness/gates/audit-input-hash.d.ts.map +1 -1
  111. package/dist/harness/gates/audit-input-hash.js +30 -2
  112. package/dist/harness/gates/audit-input-hash.js.map +1 -1
  113. package/dist/harness/gates/audit-report-verification.js +1 -1
  114. package/dist/harness/gates/audit-report-verification.js.map +1 -1
  115. package/dist/harness/gates/delivery-checklist.d.ts +3 -0
  116. package/dist/harness/gates/delivery-checklist.d.ts.map +1 -1
  117. package/dist/harness/gates/delivery-checklist.js +37 -4
  118. package/dist/harness/gates/delivery-checklist.js.map +1 -1
  119. package/dist/harness/gates/in-run-decision-check.d.ts.map +1 -1
  120. package/dist/harness/gates/in-run-decision-check.js +21 -1
  121. package/dist/harness/gates/in-run-decision-check.js.map +1 -1
  122. package/dist/harness/journey.d.ts +21 -0
  123. package/dist/harness/journey.d.ts.map +1 -1
  124. package/dist/harness/journey.js +61 -11
  125. package/dist/harness/journey.js.map +1 -1
  126. package/dist/harness/next-step.d.ts +17 -0
  127. package/dist/harness/next-step.d.ts.map +1 -1
  128. package/dist/harness/next-step.js +19 -0
  129. package/dist/harness/next-step.js.map +1 -1
  130. package/dist/harness/triage-manual-classification.d.ts +32 -0
  131. package/dist/harness/triage-manual-classification.d.ts.map +1 -0
  132. package/dist/harness/triage-manual-classification.js +56 -0
  133. package/dist/harness/triage-manual-classification.js.map +1 -0
  134. package/dist/harness/unit-paths.d.ts +8 -0
  135. package/dist/harness/unit-paths.d.ts.map +1 -1
  136. package/dist/harness/unit-paths.js +23 -1
  137. package/dist/harness/unit-paths.js.map +1 -1
  138. package/dist/hooks/guard-tty-spoof-rule.d.ts +3 -1
  139. package/dist/hooks/guard-tty-spoof-rule.d.ts.map +1 -1
  140. package/dist/hooks/guard-tty-spoof-rule.js +3 -2
  141. package/dist/hooks/guard-tty-spoof-rule.js.map +1 -1
  142. package/dist/hooks/hand-back-core.d.ts +1 -1
  143. package/dist/hooks/hand-back-core.d.ts.map +1 -1
  144. package/dist/hooks/hand-back-core.js +4 -2
  145. package/dist/hooks/hand-back-core.js.map +1 -1
  146. package/dist/hooks/session-start-unit-state-hook.d.ts +2 -1
  147. package/dist/hooks/session-start-unit-state-hook.d.ts.map +1 -1
  148. package/dist/hooks/session-start-unit-state-hook.js +4 -1
  149. package/dist/hooks/session-start-unit-state-hook.js.map +1 -1
  150. package/dist/hooks/stop-next-step-hook.d.ts.map +1 -1
  151. package/dist/hooks/stop-next-step-hook.js +1 -0
  152. package/dist/hooks/stop-next-step-hook.js.map +1 -1
  153. package/dist/orchestrator/templates/ai-src/commands/delivery.md +2 -0
  154. package/dist/orchestrator/templates/ai-src/commands/review.md +2 -0
  155. package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.claude.md +11 -0
  156. package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.codex.md +11 -0
  157. package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.copilot.md +11 -0
  158. package/package.json +3 -3
  159. package/src/cli/commands/contract-derive-command.ts +1 -0
  160. package/src/cli/commands/delivery.ts +20 -3
  161. package/src/cli/commands/generate.ts +3 -1
  162. package/src/cli/commands/next.ts +14 -3
  163. package/src/cli/commands/triage.ts +38 -2
  164. package/src/cli/commands/update.ts +6 -2
  165. package/src/cli/index.ts +4 -0
  166. package/src/cli/json-error-on-exit.ts +72 -0
  167. package/src/cli/waiting-for-you-block.ts +23 -0
  168. package/src/contract/derive-free-feature.ts +21 -0
  169. package/src/decisions/decision-schema.ts +2 -1
  170. package/src/decisions/legacy-waiver-migration.ts +5 -1
  171. package/src/graph/board-human-actions.ts +80 -0
  172. package/src/graph/evaluate-graph.ts +37 -8
  173. package/src/graph/human-action.ts +33 -0
  174. package/src/graph/manual-triage-decisions.ts +55 -0
  175. package/src/graph/node-decl.ts +11 -0
  176. package/src/graph/nodes/test-case-contract-nodes.ts +20 -4
  177. package/src/graph/nodes/test-case-nodes.ts +37 -5
  178. package/src/graph/nodes/unit-audit-nodes.ts +2 -1
  179. package/src/graph/nodes/unit-contract-node.ts +12 -1
  180. package/src/graph/test-case-results.ts +25 -4
  181. package/src/graph/test-case-satisfied.ts +7 -4
  182. package/src/graph/test-case-state-shape.ts +7 -2
  183. package/src/graph/unit-results-freshness.ts +165 -0
  184. package/src/harness/audit.ts +4 -13
  185. package/src/harness/catalog/gate-rules.yaml +1 -1
  186. package/src/harness/defect-draft.ts +13 -2
  187. package/src/harness/defects.ts +22 -9
  188. package/src/harness/flow-check.ts +11 -0
  189. package/src/harness/gates/audit-gate-verdict.ts +2 -1
  190. package/src/harness/gates/audit-input-hash.ts +27 -3
  191. package/src/harness/gates/audit-report-verification.ts +2 -2
  192. package/src/harness/gates/delivery-checklist.ts +37 -5
  193. package/src/harness/gates/in-run-decision-check.ts +22 -1
  194. package/src/harness/journey.ts +79 -8
  195. package/src/harness/next-step.ts +31 -1
  196. package/src/harness/triage-manual-classification.ts +76 -0
  197. package/src/harness/unit-paths.ts +19 -0
  198. package/src/hooks/guard-tty-spoof-rule.ts +6 -3
  199. package/src/hooks/hand-back-core.ts +4 -2
  200. package/src/hooks/session-start-unit-state-hook.ts +4 -1
  201. package/src/hooks/stop-next-step-hook.ts +1 -0
  202. package/src/orchestrator/templates/ai-src/commands/delivery.md +2 -0
  203. package/src/orchestrator/templates/ai-src/commands/review.md +2 -0
  204. package/src/orchestrator/templates/ai-src/partials/waiting-for-you.claude.md +11 -0
  205. package/src/orchestrator/templates/ai-src/partials/waiting-for-you.codex.md +11 -0
  206. package/src/orchestrator/templates/ai-src/partials/waiting-for-you.copilot.md +11 -0
@@ -11,8 +11,12 @@
11
11
  * `--case <id>` (`testCase` option): the frontier is that case's test-case nodes plus the blocking
12
12
  * unit nodes before them; non-blocking unit advice is left to the unit view. A `caseOnly` test-case
13
13
  * node speaks only there (its unit-level decision is in the unit view already).
14
+ *
15
+ * evaluateHumanActions reads the SAME walk for "Waiting for you" (#954): the human-only actions behind
16
+ * the offered steps, and whether the walk was cut.
14
17
  */
15
18
  import type { NextStep, UnitState } from '../harness/next-step';
19
+ import type { HumanAction } from './human-action';
16
20
  import type { NodeDecl, StepCore } from './node-decl';
17
21
  import type { TestCaseState } from './test-case-state-shape';
18
22
 
@@ -37,30 +41,55 @@ function openCases(node: NodeDecl, s: UnitState, tcStates: TestCaseState[] | und
37
41
  return tcStates.filter((tc) => (!only || tc.id === only) && node.active(s, tc) && !node.satisfied(s, tc));
38
42
  }
39
43
 
40
- export function evaluateGraph(
41
- nodes: readonly NodeDecl[], s: UnitState, tcStates?: TestCaseState[], opts: EvaluateOptions = {},
42
- ): NextStep[] {
44
+ /** One offered step and what produced it: the node, and the cases open at it (test-case nodes). */
45
+ interface Offered { node: NodeDecl; step: NextStep; cases: TestCaseState[] }
46
+
47
+ /** The walk both answers share: the offered steps in rank order, and whether an open blocking node cut it. */
48
+ function walkFrontier(nodes: readonly NodeDecl[], s: UnitState, tcStates: TestCaseState[] | undefined, opts: EvaluateOptions): { offered: Offered[]; cut: boolean; cutBy?: NodeDecl } {
43
49
  const ordered = [...nodes].sort((a, b) => a.rank - b.rank);
44
50
  const open = new Set<string>();
45
- const out: NextStep[] = [];
51
+ const offered: Offered[] = [];
46
52
  let cut = false;
53
+ let cutBy: NodeDecl | undefined;
47
54
  for (const node of ordered) {
48
55
  if (!node.appliesTo.includes(s.kind) || (node.granularity === 'test-case' && node.caseOnly && !opts.testCase)) continue;
49
56
  if (cut && !node.surfacesPastCut) continue;
50
57
  let step: NextStep | null = null;
58
+ let cases: TestCaseState[] = [];
51
59
  if (node.granularity === 'unit') {
52
60
  if (!node.active(s) || node.satisfied(s) || (cut && !node.surfacesPastCut!(s))) continue;
53
61
  if (!opts.testCase || node.blocking) step = withMeta(node, node.step(s));
54
62
  } else {
55
- const cases = openCases(node, s, tcStates, opts.testCase).filter((tc) => !cut || node.surfacesPastCut!(s, tc));
63
+ cases = openCases(node, s, tcStates, opts.testCase).filter((tc) => !cut || node.surfacesPastCut!(s, tc));
56
64
  if (!cases.length) continue;
57
65
  const single = Boolean(opts.testCase);
58
66
  step = withMeta(node, node.step(s, cases, single), single ? cases[0].id : undefined);
59
67
  }
60
68
  open.add(node.id);
61
69
  const ready = node.requires.every((r) => !open.has(r));
62
- if (step && (cut || ready || !node.blocking)) out.push(step);
63
- if (node.blocking && !cut) cut = true;
70
+ if (step && (cut || ready || !node.blocking)) offered.push({ node, step, cases });
71
+ if (node.blocking && !cut) { cut = true; cutBy = node; }
64
72
  }
65
- return out;
73
+ return { offered, cut, ...(cutBy ? { cutBy } : {}) };
74
+ }
75
+
76
+ export function evaluateGraph(
77
+ nodes: readonly NodeDecl[], s: UnitState, tcStates?: TestCaseState[], opts: EvaluateOptions = {},
78
+ ): NextStep[] {
79
+ return walkFrontier(nodes, s, tcStates, opts).offered.map((o) => o.step);
80
+ }
81
+
82
+ /**
83
+ * The HUMAN-ONLY actions of the same frontier ("Waiting for you", #954) — never a second walk with
84
+ * rules of its own: each offered step's node says what a person runs (its `human` hook), and a decide
85
+ * step without one is its own command. `cut`: an open blocking node ended the walk, so only what does
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.
88
+ */
89
+ export function evaluateHumanActions(nodes: readonly NodeDecl[], s: UnitState, tcStates?: TestCaseState[]): { actions: HumanAction[]; cut: boolean; heldBy?: string } {
90
+ const { offered, cut, cutBy } = walkFrontier(nodes, s, tcStates, {});
91
+ const actions = offered.flatMap(({ node, step, cases }) => (node.human
92
+ ? node.human(s, step, cases)
93
+ : step.kind === 'decide' ? [{ command: step.command, reason: step.because, kind: 'decide' as const }] : []));
94
+ return { actions, cut, ...(cutBy ? { heldBy: cutBy.title } : {}) };
66
95
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A HUMAN-ONLY action of a unit — what `sungen next` lists under "Waiting for you" (#954) and `--json`
3
+ * carries as `humanActions`. An agent presents these and never runs them: they approve, sign, waive,
4
+ * classify or accept something on a person's behalf, and most refuse without a typed confirmation.
5
+ *
6
+ * `command` is canonical (`sungen …`, or a slash command); the text renders it as the project runs
7
+ * sungen (`npx sungen` with a local install). Placeholders a human fills are quoted (`"<name>"`, `"…"`)
8
+ * so a copied command never turns into a shell redirection.
9
+ */
10
+ export type HumanActionKind =
11
+ | 'contract-approve' // approve (or re-approve) the Test Contract — `sungen contract approve`
12
+ | 'accept-opaque' // accept a reviewed opaque trace step — `contract approve --scenario <id> --accept-opaque`
13
+ | 'contract-commit' // commit, or restore, a lock / approval decision through review
14
+ | 'contract-review' // read the contract verdict on a terminal (it could not be built)
15
+ | 'accept-viewpoint' // accept the changed viewpoint baseline — `sungen audit --accept-viewpoint`
16
+ | 'defect-draft' // decide whether a product-defect candidate is a defect — `sungen defect draft`
17
+ | 'rerun-environment' // re-run the reds the environment caused, once a human confirms it is up
18
+ | 'triage-classify' // classify a red by hand as test-defect / environment — `sungen triage --case --as`
19
+ | 'signoff' // the single human gate — `sungen journey --signoff`
20
+ | 'waive' // give a waiver again — `sungen journey --waive`
21
+ | 'decide' // record a decision — `sungen journey --decide`
22
+ | 'accept-in-run'; // deliver despite decisions recorded during the run — `sungen delivery --accept-in-run`
23
+
24
+ export interface HumanAction {
25
+ command: string;
26
+ reason: string;
27
+ kind: HumanActionKind;
28
+ /**
29
+ * `command` is an instruction a person carries out by hand (commit through review, restore from git),
30
+ * not a command line to copy — the block marks it so, and never renders it as one.
31
+ */
32
+ instruction?: true;
33
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Manual triage classifications (#928) — a red `sungen triage` could not classify, or classified
3
+ * wrongly, judged by hand with `sungen triage --screen <u> --case <id> --as <class> --reason "…"`
4
+ * (harness/triage-manual-classification.ts writes them). Each is a `triage-classification` decision
5
+ * in the unit's `decisions/`, read here for the test-case state (test-case-results.ts):
6
+ *
7
+ * payload { case: <id>, class: product-candidate | test-defect | environment, humanConfirmed? }
8
+ * evidence_hash the hash of the red final attempts it judged (TestCaseState.evidence): a re-run
9
+ * makes a new red, and the classification no longer applies to it
10
+ * in force verifies (read-decisions), not superseded, bound to today's red — and, for
11
+ * test-defect / environment, typed by a human on a terminal (`humanConfirmed`, only
12
+ * ever set after the typed confirmation): those classes can hide a product defect,
13
+ * so an entry without it is ignored (fail closed). product-candidate is stricter than
14
+ * any automatic class, so anyone may record it.
15
+ * The latest classification in force wins over the automatic one.
16
+ */
17
+ import { readDecisions } from '../decisions/read-decisions';
18
+ import { unitScopeOfDir } from '../harness/gates/unit-gate-overrides';
19
+
20
+ export const TRIAGE_DECISION_KIND = 'triage-classification' as const;
21
+ export type ManualTriageClass = 'product-candidate' | 'test-defect' | 'environment';
22
+ /** `--as` word → the triage class it records. */
23
+ export const MANUAL_TRIAGE_AS: Readonly<Record<string, ManualTriageClass>> = { product: 'product-candidate', 'test-defect': 'test-defect', environment: 'environment' };
24
+ /** Classes that can hide a product defect: a human types a confirmation on a terminal. */
25
+ export const HUMAN_ONLY_TRIAGE: ReadonlySet<ManualTriageClass> = new Set(['test-defect', 'environment']);
26
+
27
+ export interface ManualClassification {
28
+ decision: string;
29
+ caseId: string;
30
+ class: ManualTriageClass;
31
+ evidence: string;
32
+ reason: string;
33
+ by: string;
34
+ }
35
+
36
+ const isClass = (v: unknown): v is ManualTriageClass => Object.values(MANUAL_TRIAGE_AS).includes(v as ManualTriageClass);
37
+
38
+ /** The unit's classifications that can be in force, oldest first (read-decisions orders by time). */
39
+ export function manualClassifications(projectRoot: string, unitDir: string): ManualClassification[] {
40
+ const scope = unitScopeOfDir(projectRoot, unitDir);
41
+ if (!scope) return [];
42
+ return readDecisions(projectRoot, scope, { kind: TRIAGE_DECISION_KIND }).entries.flatMap((e) => {
43
+ const p = e.payload ?? {};
44
+ if (e.superseded_by || typeof p.case !== 'string' || !isClass(p.class) || typeof e.evidence_hash !== 'string') return [];
45
+ if (HUMAN_ONLY_TRIAGE.has(p.class) && p.humanConfirmed !== true) return [];
46
+ return [{ decision: e.id, caseId: p.case, class: p.class, evidence: e.evidence_hash, reason: e.reason, by: e.source.by }];
47
+ });
48
+ }
49
+
50
+ /** The latest classification of this case bound to this red, if any. */
51
+ export function manualClassificationFor(list: ManualClassification[], caseId: string, evidence: string | undefined): ManualClassification | undefined {
52
+ if (!evidence) return undefined;
53
+ for (let i = list.length - 1; i >= 0; i--) if (list[i].caseId === caseId && list[i].evidence === evidence) return list[i];
54
+ return undefined;
55
+ }
@@ -11,6 +11,7 @@
11
11
  * `sungen next <unit> --case <id>`).
12
12
  */
13
13
  import type { NextStep, UnitState } from '../harness/next-step';
14
+ import type { HumanAction, HumanActionKind } from './human-action';
14
15
  import type { TestCaseState } from './test-case-state-shape';
15
16
 
16
17
  export type NodeActor = 'ai' | 'human' | 'deterministic';
@@ -35,8 +36,18 @@ interface NodeDeclBase {
35
36
  gates: readonly string[];
36
37
  /** Artifacts the step produces, for a reader of the graph. */
37
38
  produces: readonly string[];
39
+ /**
40
+ * The HUMAN-ONLY actions behind an offered step ("Waiting for you", #954): what a person runs, with
41
+ * why. Without it a `decide` step is one action of kind `decide` and any other step is none. `cases`
42
+ * are the cases open at a test-case node (none for a unit node).
43
+ */
44
+ human?(s: UnitState, step: StepCore, cases: TestCaseState[]): HumanAction[];
38
45
  }
39
46
 
47
+ /** The `human` hook of a node whose decide step IS the human action: its command and reason, under `kind`. */
48
+ export const humanStep = (kind: HumanActionKind) => (_s: UnitState, step: StepCore): HumanAction[] =>
49
+ [{ command: step.command, reason: step.because, kind }];
50
+
40
51
  export interface UnitNodeDecl extends NodeDeclBase {
41
52
  granularity: 'unit';
42
53
  active(s: UnitState): boolean;
@@ -16,8 +16,9 @@
16
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
17
17
  * compiled test without a lock is never blocked, and the unit view offers the approval.
18
18
  */
19
- import { ALL_KINDS, StepCore, TestCaseNodeDecl } from '../node-decl';
19
+ import { ALL_KINDS, humanStep, StepCore, TestCaseNodeDecl } from '../node-decl';
20
20
  import type { UnitState } from '../../harness/next-step';
21
+ import type { HumanAction } from '../human-action';
21
22
  import type { TestCaseState } from '../test-case-state-shape';
22
23
  import { exemptionOf } from '../test-case-satisfied';
23
24
  import { idsArg, listed } from './test-case-nodes';
@@ -31,7 +32,7 @@ const withDetail = (cases: TestCaseState[]): string =>
31
32
  const check = (s: UnitState, trace = false): string => `sungen contract check --screen ${s.unit}${trace ? ' --trace' : ''}`;
32
33
 
33
34
  function contractNode(id: string, rank: number, title: string, actor: TestCaseNodeDecl['actor'], gates: string[], caseOnly: boolean,
34
- active: (tc: TestCaseState) => boolean, step: Step, hard?: (tc: TestCaseState) => boolean): TestCaseNodeDecl {
35
+ active: (tc: TestCaseState) => boolean, step: Step, hard?: (tc: TestCaseState) => boolean, human?: TestCaseNodeDecl['human']): TestCaseNodeDecl {
35
36
  return {
36
37
  id, title, actor, granularity: 'test-case', appliesTo: ALL_KINDS, blocking: false, rank, gates, step,
37
38
  requires: caseOnly ? ['unit.gate-fail'] : ['unit.run'], produces: ['contracts.lock.yaml', 'decisions/<id>.yaml'],
@@ -39,9 +40,23 @@ function contractNode(id: string, rank: number, title: string, actor: TestCaseNo
39
40
  active: (_s, tc) => active(tc),
40
41
  satisfied: (_s, tc) => exemptionOf(tc) !== null,
41
42
  ...(hard ? { surfacesPastCut: (_s: UnitState, tc: TestCaseState) => hard(tc) } : {}),
43
+ ...(human ? { human } : {}),
42
44
  };
43
45
  }
44
46
 
47
+ /**
48
+ * An opaque step is cleared by a QA accepting it as it reads now, never by re-approving (#923): one
49
+ * `--accept-opaque` per case whose only hold is the opaque step. A case held by a `low` identity is
50
+ * not acceptable yet (`contract check --trace` resolves it first), so it is not offered (#954).
51
+ */
52
+ function acceptOpaque(s: UnitState, _step: StepCore, cases: TestCaseState[]): HumanAction[] {
53
+ return cases.filter((c) => classOf(c) === 'opaque' && !c.contract?.lowIdentity).map((c) => ({
54
+ kind: 'accept-opaque',
55
+ command: `sungen contract approve --screen ${s.unit} --scenario ${c.id} --accept-opaque`,
56
+ reason: `${c.id} carries a step sungen cannot compare${c.contract?.detail ? ` (${c.contract.detail})` : ''} — a QA reviews it verbatim with its file:line in \`${check(s)}\`, then accepts it as it reads now; until then the contract is in review and \`sungen delivery\` waits`,
57
+ }));
58
+ }
59
+
45
60
  const conformanceStep: Step = (s, cases) => {
46
61
  const low = cases.filter((c) => c.contract?.lowIdentity);
47
62
  if (low.length) {
@@ -57,7 +72,8 @@ const conformanceStep: Step = (s, cases) => {
57
72
  export const TEST_CASE_CONTRACT_NODES: readonly TestCaseNodeDecl[] = [
58
73
  contractNode('tc.contract-approve', 106, 'Re-approve this changed case', 'human', ['contract.hash-changed'], true,
59
74
  (tc) => classOf(tc) === 'hash-changed' && !tc.contract?.uncommitted,
60
- (s, [c]) => ({ kind: 'decide', command: `sungen contract approve --screen ${s.unit} --scenario ${c.id}`, because: `this case changed since QA approved it, not weaker${c.contract?.detail ? ` (${c.contract.detail})` : ''} — a QA re-approves this scenario on a terminal; until then review blocks \`sungen delivery\`. An agent presents it and never approves its own edit` })),
75
+ (s, [c]) => ({ kind: 'decide', command: `sungen contract approve --screen ${s.unit} --scenario ${c.id}`, because: `this case changed since QA approved it, not weaker${c.contract?.detail ? ` (${c.contract.detail})` : ''} — a QA re-approves this scenario on a terminal; until then review blocks \`sungen delivery\`. An agent presents it and never approves its own edit` }),
76
+ undefined, humanStep('contract-approve')),
61
77
  contractNode('tc.contract-check', 107, 'Restore the approved check of this case', 'human',
62
78
  ['contract.oracle-weakened', 'contract.target-changed', 'contract.assertion-lost', 'contract.lock-uncommitted'], true,
63
79
  (tc) => Boolean(tc.contract?.uncommitted) || HARD_LADDER.has(classOf(tc) ?? ''),
@@ -70,5 +86,5 @@ export const TEST_CASE_CONTRACT_NODES: readonly TestCaseNodeDecl[] = [
70
86
  (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
87
  (tc) => classOf(tc) === 'unproven'),
72
88
  contractNode('tc.conformance', 138, 'Settle the traces in review', 'ai', ['contract.opaque-step', 'contract.inconclusive'], false,
73
- (tc) => classOf(tc) === 'opaque' || classOf(tc) === 'inconclusive', conformanceStep),
89
+ (tc) => classOf(tc) === 'opaque' || classOf(tc) === 'inconclusive', conformanceStep, undefined, acceptOpaque),
74
90
  ];
@@ -8,12 +8,16 @@
8
8
  * tc.rerun-stale changed after the recorded run (run manifest) do run-test --change-only
9
9
  * tc.triage red, not classified by a current triage do sungen triage
10
10
  * tc.product-candidate triage found a product-defect candidate decide sungen defect draft
11
+ * tc.rerun-environment a red the environment caused (#928) decide run-test --ids, once a human confirms it is up
12
+ * tc.fix-test a red the test caused (#928) do run-test --ids (auto-fix)
13
+ * The class is `sungen triage`'s, or a manual classification in force (`sungen triage --case --as`).
11
14
  * They sit after unit.run (which cuts a unit that never ran) and before delivery. tc.triage takes
12
15
  * the reds the cases account for; unit.triage keeps the rest (unattributedReds), so a red no case
13
16
  * explains — an @cases row, a renamed scenario, a nested describe title — still gets a triage step.
14
17
  */
15
- import { ALL_KINDS, StepCore, TestCaseNodeDecl } from '../node-decl';
18
+ import { ALL_KINDS, humanStep, StepCore, TestCaseNodeDecl } from '../node-decl';
16
19
  import type { UnitState } from '../../harness/next-step';
20
+ import type { HumanAction } from '../human-action';
17
21
  import type { TestCaseState } from '../test-case-state-shape';
18
22
  import { exemptionOf, TestCaseNeed, triggerOf } from '../test-case-satisfied';
19
23
 
@@ -23,17 +27,33 @@ export const listed = (cases: TestCaseState[]): string => `${cases.slice(0, 4).m
23
27
  export const idsArg = (cases: TestCaseState[]): string =>
24
28
  (cases.length <= MAX_IDS && cases.every((c) => !c.unstableId) ? ` --ids ${cases.map((c) => c.id).join(',')}` : '');
25
29
  const flag = (s: UnitState): string => (s.kind === 'api' ? '--api' : '--screen');
30
+ /** Up to three cases with the triage rule that judged each. */
31
+ const withRule = (cases: TestCaseState[]): string =>
32
+ `${cases.slice(0, 3).map((c) => `${c.id}: ${c.triage?.ruleId}`).join('; ')}${cases.length > 3 ? '; …' : ''}`;
26
33
 
27
34
  function caseNode(id: string, need: TestCaseNeed, rank: number, title: string, actor: TestCaseNodeDecl['actor'],
28
- step: (s: UnitState, cases: TestCaseState[], single: boolean) => StepCore, produces: string[]): TestCaseNodeDecl {
35
+ step: (s: UnitState, cases: TestCaseState[], single: boolean) => StepCore, produces: string[], human?: TestCaseNodeDecl['human']): TestCaseNodeDecl {
29
36
  return {
30
37
  id, title, actor, granularity: 'test-case', appliesTo: ALL_KINDS, requires: ['unit.run'], blocking: false, rank,
31
38
  gates: [], produces, step,
32
39
  active: (_s, tc) => triggerOf(tc) === need,
33
40
  satisfied: (_s, tc) => exemptionOf(tc) !== null,
41
+ ...(human ? { human } : {}),
34
42
  };
35
43
  }
36
44
 
45
+ /**
46
+ * A red triage left unclassified is a human's to classify (#928): test-defect and environment take it
47
+ * off the product-defect path, so they need a person on a terminal. One action per such case (#954).
48
+ */
49
+ function classifyByHand(s: UnitState, _step: StepCore, cases: TestCaseState[]): HumanAction[] {
50
+ return cases.filter((c) => c.triage?.class === 'unclassified').map((c) => ({
51
+ kind: 'triage-classify',
52
+ command: `sungen triage ${flag(s)} ${s.unit} --case ${c.id} --as "<test-defect|environment>" --reason "…"`,
53
+ reason: `${c.id} failed and \`sungen triage\` left it unclassified — read its evidence (trace, error), then classify it: test-defect or environment take it off the product-defect path, so a human types them on a terminal (\`--as product\` anyone may record)`,
54
+ }));
55
+ }
56
+
37
57
  export const TEST_CASE_NODES: readonly TestCaseNodeDecl[] = [
38
58
  caseNode('tc.run', 'run', 131, 'Run the cases that never ran', 'ai', (s, cases) => ({
39
59
  kind: 'do',
@@ -50,14 +70,26 @@ export const TEST_CASE_NODES: readonly TestCaseNodeDecl[] = [
50
70
  return {
51
71
  kind: 'do',
52
72
  command: `sungen triage ${flag(s)} ${s.unit}`,
53
- because: `${cases.length} test case(s) failed in the latest run and are not classified yet${left ? ` (${left} left unclassified by the last triage — read its evidence and classify by hand)` : ''} — classify each (known defect, test defect, environment, product candidate) before exporting or extending: an unexplained red in the deliverable is a finding nobody has judged`,
73
+ because: `${cases.length} test case(s) failed in the latest run and are not classified yet${left ? ` (${left} left unclassified by the last triage — read its evidence and classify by hand: \`sungen triage ${flag(s)} ${s.unit} --case <id> --as product|test-defect|environment --reason "…"\`; test-defect and environment are a human's call, on a terminal)` : ''} — classify each (known defect, test defect, environment, product candidate) before exporting or extending: an unexplained red in the deliverable is a finding nobody has judged`,
54
74
  };
55
- }, ['.sungen/triage/<unit>.json']),
75
+ }, ['.sungen/triage/<unit>.json'], classifyByHand),
56
76
  caseNode('tc.product-candidate', 'product-candidate', 134, 'Decide on the product-defect candidates', 'human', (s, cases, single) => ({
57
77
  kind: 'decide',
58
78
  command: `sungen defect draft ${flag(s)} ${s.unit}${single ? ` --scenario ${cases[0].id}` : ''}`,
59
79
  because: `${cases.length} red test case(s) look like a product defect (${listed(cases)}) — the app answered and the answer differs from the oracle. A QA decides whether it is a defect to record; an agent presents it and does not draft it on its own`,
60
- }), ['qa/defects/DEF-<n>-<slug>.md']),
80
+ }), ['qa/defects/DEF-<n>-<slug>.md'], humanStep('defect-draft')),
81
+ // decide, not do: an agent re-running into an environment that is still down gets the same red and
82
+ // the same step again — a loop with no stop. A human confirms the environment first (#928 review W3).
83
+ caseNode('tc.rerun-environment', 'rerun-environment', 139, 'Re-run the cases the environment failed, once it is up', 'human', (s, cases) => ({
84
+ kind: 'decide',
85
+ command: `/sungen:run-test ${s.unit}${idsArg(cases)}`,
86
+ because: `${cases.length} red test case(s) failed on the environment, not the app (${withRule(cases)}) — the run never reached a usable app, so their results say nothing about it yet. A human confirms the app, base URL, network, auth state or device is up, then they are re-run; an agent presents this and does not re-run on its own — into an environment that is still down, the same red comes back`,
87
+ }), ['specs/generated/<unit>/<unit>-test-result.json'], humanStep('rerun-environment')),
88
+ caseNode('tc.fix-test', 'fix-test', 139.5, 'Fix the tests triaged as test defects', 'ai', (s, cases) => ({
89
+ kind: 'do',
90
+ command: `/sungen:run-test ${s.unit}${idsArg(cases)}`,
91
+ because: `${cases.length} red test case(s) are test defects (${withRule(cases)}) — the test, not the app, failed: fix its selector, test data or step (run-test's auto-fix reads the triage evidence), then re-run them; never weaken an assertion to make one pass`,
92
+ }), ['selectors/<unit>.yaml', 'specs/generated/<unit>/<unit>-test-result.json']),
61
93
  ];
62
94
 
63
95
  /** Red final attempts the test cases account for (a case read without the count stands for one). */
@@ -8,7 +8,7 @@
8
8
  * 9 unit.repair-findings findings with a known repair → repair + re-audit, names ≤ 4 codes
9
9
  * 10 unit.gate-fail gateStatus FAIL → blocked, cuts
10
10
  */
11
- import { ALL_KINDS, UnitNodeDecl } from '../node-decl';
11
+ import { ALL_KINDS, humanStep, UnitNodeDecl } from '../node-decl';
12
12
  import type { UnitState } from '../../harness/next-step';
13
13
 
14
14
  /** The finding codes worth naming in a next step — each maps to a specific repair. */
@@ -66,6 +66,7 @@ export const UNIT_AUDIT_NODES: readonly UnitNodeDecl[] = [
66
66
  command: `sungen audit --screen ${s.unit} --accept-viewpoint`,
67
67
  because: 'test-viewpoint.md changed since the accepted baseline, so the ledger and traceability axes are not evidence until a QA confirms the new declaration. A decision, not a step: an agent presents it and does not run it — accepting the yardstick you just wrote makes both axes read 100% by construction',
68
68
  }),
69
+ human: humanStep('accept-viewpoint'),
69
70
  },
70
71
  {
71
72
  id: 'unit.repair-findings', title: 'Repair the named findings', actor: 'ai', granularity: 'unit',
@@ -16,8 +16,9 @@
16
16
  * A free / impl:code scenario only its TRACE cannot prove — unproven, inconclusive, opaque — is not a
17
17
  * unit decision: the case nodes say what proves it (test-case-contract-nodes.ts, phase 09).
18
18
  */
19
- import { ALL_KINDS, StepCore, UnitNodeDecl, unitKindDir } from '../node-decl';
19
+ import { ALL_KINDS, humanStep, StepCore, UnitNodeDecl, unitKindDir } from '../node-decl';
20
20
  import type { UnitState } from '../../harness/next-step';
21
+ import type { HumanActionKind } from '../human-action';
21
22
 
22
23
  const HUMAN_ONLY = 'A QA decision on a terminal: an agent presents it and never approves its own edit';
23
24
  const approveCmd = (s: UnitState): string => `sungen contract approve --screen ${s.unit}`;
@@ -50,6 +51,10 @@ function contractStep(s: UnitState): StepCore {
50
51
  return { kind: 'decide', command: approveCmd(s), because: `${c.review} approved scenario(s) changed since QA approved them (changed, not weaker) — review blocks \`sungen delivery\` until QA re-approves. ${HUMAN_ONLY}` };
51
52
  }
52
53
 
54
+ /** What the human does for the step contractStep offered: approve, commit / restore through review, or read the verdict. */
55
+ const contractActionKind = (command: string): HumanActionKind =>
56
+ command.startsWith('sungen contract approve') ? 'contract-approve' : command.startsWith('sungen contract check') ? 'contract-review' : 'contract-commit';
57
+
53
58
  const CONTRACT_RULES = ['contract.hash-changed', 'contract.oracle-weakened', 'contract.target-changed', 'contract.assertion-lost', 'contract.lock-uncommitted'];
54
59
  /** The verdict is a pass and it was built: nothing blocks, nothing to repair. */
55
60
  const verdictClean = (s: UnitState): boolean => !s.contract!.error && s.contract!.verdict === 'pass';
@@ -67,6 +72,11 @@ export const UNIT_CONTRACT_NODES: readonly UnitNodeDecl[] = [
67
72
  active: (s) => Boolean(s.contract),
68
73
  satisfied: nothingToDecide,
69
74
  step: contractStep,
75
+ human: (_s, step) => {
76
+ const kind = contractActionKind(step.command);
77
+ // A lock repair is a commit (or `git checkout --`) after review — prose, never a sungen command to copy.
78
+ return [{ command: step.command, reason: step.because, kind, ...(kind === 'contract-commit' ? { instruction: true as const } : {}) }];
79
+ },
70
80
  // A hard verdict must not wait behind a re-audit (#940): the audit's PASS says nothing about it —
71
81
  // a broken approved check, or a lock that is not what QA approved and committed (contract.lock-
72
82
  // uncommitted, hard in `contract check`; the step names that repair first). What stays behind the
@@ -85,5 +95,6 @@ export const UNIT_CONTRACT_NODES: readonly UnitNodeDecl[] = [
85
95
  command: approveCmd(s),
86
96
  because: `${s.contract!.proposed} scenario(s) have no approved contract yet — approving locks the checks QA reviewed, so a later edit that weakens one (a healer's included) fails \`sungen contract check\`. ${HUMAN_ONLY}; proposed scenarios do not block`,
87
97
  }),
98
+ human: humanStep('contract-approve'),
88
99
  },
89
100
  ];
@@ -7,15 +7,21 @@
7
7
  * stale the run manifest (`sungen changed`) selects the scenario: its steps/tags, a selector or a
8
8
  * test-data key it uses, or a project input changed since the recorded run
9
9
  * triage the rows of `.sungen/triage/<unit>.json` for the case, only when that triage is not
10
- * older than the case's result files (a re-run voids it)
10
+ * older than the case's result files (a re-run voids it) — unless a manual classification
11
+ * in force judged this very red (manual-triage-decisions.ts, #928): that one wins
12
+ * evidence a red case's hash of its red final attempts (file, title, status, start, duration,
13
+ * messages): what a manual classification is bound to
11
14
  */
15
+ import { createHash } from 'crypto';
12
16
  import * as fs from 'fs';
13
17
  import * as path from 'path';
18
+ import { canonicalJson } from '../decisions/decision-identity';
14
19
  import { loadPlaywrightReport } from '../exporters/playwright-report-parser';
15
20
  import type { PlaywrightResult } from '../exporters/types';
16
21
  import { buildRunManifest, readRunManifest, selectChanged } from '../harness/run-manifest';
17
22
  import { readTriageReport } from '../harness/triage-unit';
18
23
  import type { TriageClass } from '../harness/triage';
24
+ import { manualClassificationFor, manualClassifications } from './manual-triage-decisions';
19
25
  import type { FeatureCase } from './test-case-identity';
20
26
  import type { TestCaseResult, TestCaseState } from './test-case-state-shape';
21
27
  import { generatedDirs, RESULT_FILE } from './unit-artifact-probes';
@@ -24,7 +30,7 @@ const RED = new Set<PlaywrightResult['status']>(['failed', 'timedOut', 'interrup
24
30
  /** The class a case shows when its rows disagree: what needs a human first. */
25
31
  const TRIAGE_PRECEDENCE: TriageClass[] = ['product-candidate', 'unclassified', 'test-defect', 'environment', 'known-defect', 'blocked-capability', 'flaky'];
26
32
 
27
- export interface CaseOutcome { result: TestCaseResult; failedResults: number; resultFiles: string[]; stale?: string; triage?: TestCaseState['triage'] }
33
+ export interface CaseOutcome { result: TestCaseResult; failedResults: number; resultFiles: string[]; stale?: string; triage?: TestCaseState['triage']; evidence?: string }
28
34
 
29
35
  const ownsTitle = (key: string, title: string): boolean => key === title || key.endsWith(` > ${title}`)
30
36
  || key.startsWith(`${title} — `) || key.includes(` > ${title} — `);
@@ -63,16 +69,28 @@ function triageRows(projectRoot: string, unitId: string): { at: number; rows: Ar
63
69
  return { at: report && typeof report.generatedAt === 'string' ? Date.parse(report.generatedAt) : NaN, rows };
64
70
  }
65
71
 
72
+ /** The red final attempts of one case, hashed: the same red reads the same; a re-run (new start, duration) does not. */
73
+ function redEvidence(projectRoot: string, reds: Array<{ file: string; key: string; r: PlaywrightResult }>): string {
74
+ const rows = reds.map(({ file, key, r }) => ({
75
+ file: path.relative(projectRoot, file).split(path.sep).join('/'), key, status: r.status,
76
+ startTime: r.startTime ?? null, duration: r.duration ?? null, messages: r.errorMessages ?? (r.error ? [r.error] : []),
77
+ })).sort((a, b) => (`${a.file}\n${a.key}` < `${b.file}\n${b.key}` ? -1 : 1));
78
+ return createHash('sha256').update(canonicalJson(rows)).digest('hex');
79
+ }
80
+
66
81
  export function caseOutcomes(projectRoot: string, unitDir: string, unit: string, unitId: string, cases: FeatureCase[]): CaseOutcome[] {
67
82
  const byFeature = resultsByFeature(projectRoot, unit, unitId);
68
83
  const stale = staleReasons(projectRoot, unitDir, unit);
69
84
  const triage = triageRows(projectRoot, unitId);
85
+ const manual = manualClassifications(projectRoot, unitDir);
70
86
  return cases.map((c) => {
71
87
  const statuses: PlaywrightResult['status'][] = [];
72
88
  const files: string[] = [];
89
+ const reds: Array<{ file: string; key: string; r: PlaywrightResult }> = [];
73
90
  for (const { file, report } of byFeature.get(c.featureBase) ?? []) {
74
- const mine = [...report.entries()].filter(([key]) => ownsTitle(key, c.title)).map(([, r]) => r.status);
75
- if (mine.length) { statuses.push(...mine); files.push(file); }
91
+ const mine = [...report.entries()].filter(([key]) => ownsTitle(key, c.title));
92
+ if (mine.length) { statuses.push(...mine.map(([, r]) => r.status)); files.push(file); }
93
+ for (const [key, r] of mine) if (RED.has(r.status)) reds.push({ file, key, r });
76
94
  }
77
95
  const result: TestCaseResult = statuses.some((s) => RED.has(s)) ? 'failed'
78
96
  : statuses.includes('passed') ? 'passed' : statuses.includes('skipped') ? 'skipped' : 'never-run';
@@ -81,10 +99,13 @@ export function caseOutcomes(projectRoot: string, unitDir: string, unit: string,
81
99
  resultFiles: files.map((f) => path.relative(projectRoot, f).split(path.sep).join('/')),
82
100
  };
83
101
  if (result !== 'never-run' && stale[c.title]) out.stale = stale[c.title];
102
+ if (result === 'failed') out.evidence = redEvidence(projectRoot, reds);
84
103
  const current = !Number.isNaN(triage.at) && files.every((f) => mtime(f) <= triage.at);
85
104
  const rows = current ? triage.rows.filter((r) => rowOwns(r.scenario, c.title)) : [];
86
105
  const worst = TRIAGE_PRECEDENCE.find((cls) => rows.some((r) => r.class === cls));
87
106
  if (worst) out.triage = { class: worst, ruleId: rows.find((r) => r.class === worst)!.ruleId };
107
+ const byHand = manualClassificationFor(manual, c.id, out.evidence);
108
+ if (byHand) out.triage = { class: byHand.class, ruleId: 'manual', decision: byHand.decision };
88
109
  return out;
89
110
  });
90
111
  }
@@ -8,14 +8,15 @@
8
8
  * trigger — what the case needs otherwise, first match wins:
9
9
  * never run → run · result older than the scenario (run manifest) → rerun-stale ·
10
10
  * red with no current triage, or left `unclassified` by it → triage (a classification
11
- * is still owed) · triaged product-candidate → a QA decision
12
- * A pass — Pass (Mocked) included — needs nothing. A red triaged test-defect / environment has been
13
- * judged; the triage report and the deliverable carry it (no P0 node — a P1 follow-up).
11
+ * is still owed) · triaged product-candidate → a QA decision · triaged environment →
12
+ * rerun-environment (once it is up) · triaged test-defect → fix-test (#928)
13
+ * A pass — Pass (Mocked) included — needs nothing. The class may be `sungen triage`'s or a manual
14
+ * classification in force (manual-triage-decisions.ts); test-case-results.ts decides which.
14
15
  */
15
16
  import type { TestCaseState } from './test-case-state-shape';
16
17
 
17
18
  export type TestCaseExemption = 'manual' | 'requires' | 'known-defect' | 'deferred' | 'blocked-capability';
18
- export type TestCaseNeed = 'run' | 'rerun-stale' | 'triage' | 'product-candidate';
19
+ export type TestCaseNeed = 'run' | 'rerun-stale' | 'triage' | 'product-candidate' | 'rerun-environment' | 'fix-test';
19
20
 
20
21
  const tagged = (tc: TestCaseState, tag: string): boolean => tc.tags.some((t) => t === tag || t.startsWith(`${tag}:`));
21
22
 
@@ -36,6 +37,8 @@ export function triggerOf(tc: TestCaseState): TestCaseNeed | null {
36
37
  if (tc.result === 'failed') {
37
38
  if (!tc.triage || tc.triage.class === 'unclassified') return 'triage';
38
39
  if (tc.triage.class === 'product-candidate') return 'product-candidate';
40
+ if (tc.triage.class === 'environment') return 'rerun-environment';
41
+ if (tc.triage.class === 'test-defect') return 'fix-test';
39
42
  }
40
43
  return null;
41
44
  }
@@ -52,8 +52,13 @@ export interface TestCaseState {
52
52
  resultFiles: string[];
53
53
  /** Set when the scenario changed after the run that produced its result (run manifest). */
54
54
  stale?: string;
55
- /** The current `sungen triage` row for a red case — absent when triage has not judged this result. */
56
- triage?: { class: TriageClass; ruleId: string };
55
+ /**
56
+ * The class of a red case: a manual classification in force (`ruleId: 'manual'` and its decision id,
57
+ * #928), else the current `sungen triage` row — absent when neither has judged this result.
58
+ */
59
+ triage?: { class: TriageClass; ruleId: string; decision?: string };
60
+ /** Hash of the red final attempts the case accounts for (failed only) — a manual classification is bound to it; a re-run changes it. */
61
+ evidence?: string;
57
62
  /** Spec anchors the case was generated from (manifest), and whether they moved since. */
58
63
  anchors: Array<{ anchor: string; change: 'keep' | 'regenerate' | 'retire' | 'unknown' }>;
59
64
  findings: LocatedFinding[];