@sun-asterisk/sungen 3.3.0-alpha.6 → 3.3.0-alpha.8

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 (185) hide show
  1. package/dist/cli/case-triage-hint.d.ts +14 -0
  2. package/dist/cli/case-triage-hint.d.ts.map +1 -0
  3. package/dist/cli/case-triage-hint.js +23 -0
  4. package/dist/cli/case-triage-hint.js.map +1 -0
  5. package/dist/cli/commands/contract-derive-command.d.ts.map +1 -1
  6. package/dist/cli/commands/contract-derive-command.js +4 -1
  7. package/dist/cli/commands/contract-derive-command.js.map +1 -1
  8. package/dist/cli/commands/contract.d.ts.map +1 -1
  9. package/dist/cli/commands/contract.js +5 -2
  10. package/dist/cli/commands/contract.js.map +1 -1
  11. package/dist/cli/commands/delivery.d.ts.map +1 -1
  12. package/dist/cli/commands/delivery.js +50 -16
  13. package/dist/cli/commands/delivery.js.map +1 -1
  14. package/dist/cli/commands/gate.d.ts.map +1 -1
  15. package/dist/cli/commands/gate.js +2 -1
  16. package/dist/cli/commands/gate.js.map +1 -1
  17. package/dist/cli/commands/generate.d.ts.map +1 -1
  18. package/dist/cli/commands/generate.js +14 -2
  19. package/dist/cli/commands/generate.js.map +1 -1
  20. package/dist/cli/commands/journey.d.ts.map +1 -1
  21. package/dist/cli/commands/journey.js +4 -2
  22. package/dist/cli/commands/journey.js.map +1 -1
  23. package/dist/cli/commands/next.d.ts +7 -3
  24. package/dist/cli/commands/next.d.ts.map +1 -1
  25. package/dist/cli/commands/next.js +28 -5
  26. package/dist/cli/commands/next.js.map +1 -1
  27. package/dist/cli/commands/triage.d.ts.map +1 -1
  28. package/dist/cli/commands/triage.js +5 -3
  29. package/dist/cli/commands/triage.js.map +1 -1
  30. package/dist/cli/commands/update.d.ts.map +1 -1
  31. package/dist/cli/commands/update.js +6 -2
  32. package/dist/cli/commands/update.js.map +1 -1
  33. package/dist/cli/index.js +4 -0
  34. package/dist/cli/index.js.map +1 -1
  35. package/dist/cli/json-error-on-exit.d.ts +34 -0
  36. package/dist/cli/json-error-on-exit.d.ts.map +1 -0
  37. package/dist/cli/json-error-on-exit.js +63 -0
  38. package/dist/cli/json-error-on-exit.js.map +1 -0
  39. package/dist/cli/project-sungen-invocation.d.ts +6 -0
  40. package/dist/cli/project-sungen-invocation.d.ts.map +1 -1
  41. package/dist/cli/project-sungen-invocation.js +19 -0
  42. package/dist/cli/project-sungen-invocation.js.map +1 -1
  43. package/dist/cli/run-command-hints.d.ts +9 -0
  44. package/dist/cli/run-command-hints.d.ts.map +1 -0
  45. package/dist/cli/run-command-hints.js +84 -0
  46. package/dist/cli/run-command-hints.js.map +1 -0
  47. package/dist/cli/waiting-for-you-block.d.ts +18 -0
  48. package/dist/cli/waiting-for-you-block.d.ts.map +1 -0
  49. package/dist/cli/waiting-for-you-block.js +22 -0
  50. package/dist/cli/waiting-for-you-block.js.map +1 -0
  51. package/dist/contract/derive-free-feature.d.ts +2 -0
  52. package/dist/contract/derive-free-feature.d.ts.map +1 -1
  53. package/dist/contract/derive-free-feature.js +22 -0
  54. package/dist/contract/derive-free-feature.js.map +1 -1
  55. package/dist/decisions/legacy-waiver-migration.d.ts +2 -0
  56. package/dist/decisions/legacy-waiver-migration.d.ts.map +1 -1
  57. package/dist/decisions/legacy-waiver-migration.js +5 -2
  58. package/dist/decisions/legacy-waiver-migration.js.map +1 -1
  59. package/dist/exporters/csv-exporter.d.ts +11 -2
  60. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  61. package/dist/exporters/csv-exporter.js +8 -4
  62. package/dist/exporters/csv-exporter.js.map +1 -1
  63. package/dist/exporters/matrix/export.d.ts +6 -1
  64. package/dist/exporters/matrix/export.d.ts.map +1 -1
  65. package/dist/exporters/matrix/export.js +11 -4
  66. package/dist/exporters/matrix/export.js.map +1 -1
  67. package/dist/exporters/xlsx-exporter.d.ts +3 -2
  68. package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
  69. package/dist/exporters/xlsx-exporter.js +4 -4
  70. package/dist/exporters/xlsx-exporter.js.map +1 -1
  71. package/dist/graph/board-human-actions.d.ts +5 -0
  72. package/dist/graph/board-human-actions.d.ts.map +1 -0
  73. package/dist/graph/board-human-actions.js +83 -0
  74. package/dist/graph/board-human-actions.js.map +1 -0
  75. package/dist/graph/evaluate-graph.d.ts +18 -0
  76. package/dist/graph/evaluate-graph.d.ts.map +1 -1
  77. package/dist/graph/evaluate-graph.js +31 -6
  78. package/dist/graph/evaluate-graph.js.map +1 -1
  79. package/dist/graph/human-action.d.ts +21 -0
  80. package/dist/graph/human-action.d.ts.map +1 -0
  81. package/dist/graph/human-action.js +3 -0
  82. package/dist/graph/human-action.js.map +1 -0
  83. package/dist/graph/node-decl.d.ts +9 -0
  84. package/dist/graph/node-decl.d.ts.map +1 -1
  85. package/dist/graph/node-decl.js +4 -1
  86. package/dist/graph/node-decl.js.map +1 -1
  87. package/dist/graph/nodes/test-case-contract-nodes.d.ts.map +1 -1
  88. package/dist/graph/nodes/test-case-contract-nodes.js +16 -3
  89. package/dist/graph/nodes/test-case-contract-nodes.js.map +1 -1
  90. package/dist/graph/nodes/test-case-nodes.d.ts.map +1 -1
  91. package/dist/graph/nodes/test-case-nodes.js +16 -4
  92. package/dist/graph/nodes/test-case-nodes.js.map +1 -1
  93. package/dist/graph/nodes/unit-audit-nodes.d.ts.map +1 -1
  94. package/dist/graph/nodes/unit-audit-nodes.js +1 -0
  95. package/dist/graph/nodes/unit-audit-nodes.js.map +1 -1
  96. package/dist/graph/nodes/unit-contract-node.d.ts.map +1 -1
  97. package/dist/graph/nodes/unit-contract-node.js +8 -0
  98. package/dist/graph/nodes/unit-contract-node.js.map +1 -1
  99. package/dist/graph/unit-artifact-probes.d.ts.map +1 -1
  100. package/dist/graph/unit-artifact-probes.js +3 -1
  101. package/dist/graph/unit-artifact-probes.js.map +1 -1
  102. package/dist/harness/catalog/gate-rules.yaml +1 -1
  103. package/dist/harness/gates/audit-report-verification.d.ts +2 -0
  104. package/dist/harness/gates/audit-report-verification.d.ts.map +1 -1
  105. package/dist/harness/gates/audit-report-verification.js +8 -3
  106. package/dist/harness/gates/audit-report-verification.js.map +1 -1
  107. package/dist/harness/gates/delivery-checklist.d.ts.map +1 -1
  108. package/dist/harness/gates/delivery-checklist.js +9 -1
  109. package/dist/harness/gates/delivery-checklist.js.map +1 -1
  110. package/dist/harness/gates/in-run-decision-check.d.ts.map +1 -1
  111. package/dist/harness/gates/in-run-decision-check.js +10 -3
  112. package/dist/harness/gates/in-run-decision-check.js.map +1 -1
  113. package/dist/harness/journey.d.ts +21 -0
  114. package/dist/harness/journey.d.ts.map +1 -1
  115. package/dist/harness/journey.js +61 -11
  116. package/dist/harness/journey.js.map +1 -1
  117. package/dist/harness/next-step.d.ts +16 -0
  118. package/dist/harness/next-step.d.ts.map +1 -1
  119. package/dist/harness/next-step.js +14 -0
  120. package/dist/harness/next-step.js.map +1 -1
  121. package/dist/hooks/guard-tty-spoof-rule.d.ts +3 -2
  122. package/dist/hooks/guard-tty-spoof-rule.d.ts.map +1 -1
  123. package/dist/hooks/guard-tty-spoof-rule.js +2 -2
  124. package/dist/hooks/guard-tty-spoof-rule.js.map +1 -1
  125. package/dist/hooks/hand-back-core.d.ts +5 -2
  126. package/dist/hooks/hand-back-core.d.ts.map +1 -1
  127. package/dist/hooks/hand-back-core.js +10 -4
  128. package/dist/hooks/hand-back-core.js.map +1 -1
  129. package/dist/hooks/session-start-unit-state-hook.d.ts +2 -1
  130. package/dist/hooks/session-start-unit-state-hook.d.ts.map +1 -1
  131. package/dist/hooks/session-start-unit-state-hook.js +4 -1
  132. package/dist/hooks/session-start-unit-state-hook.js.map +1 -1
  133. package/dist/hooks/stop-next-step-hook.d.ts.map +1 -1
  134. package/dist/hooks/stop-next-step-hook.js +1 -0
  135. package/dist/hooks/stop-next-step-hook.js.map +1 -1
  136. package/dist/orchestrator/templates/ai-src/commands/delivery.md +2 -0
  137. package/dist/orchestrator/templates/ai-src/commands/review.md +2 -0
  138. package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.claude.md +11 -0
  139. package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.codex.md +11 -0
  140. package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.copilot.md +11 -0
  141. package/package.json +3 -3
  142. package/src/cli/case-triage-hint.ts +31 -0
  143. package/src/cli/commands/contract-derive-command.ts +3 -1
  144. package/src/cli/commands/contract.ts +5 -2
  145. package/src/cli/commands/delivery.ts +49 -16
  146. package/src/cli/commands/gate.ts +2 -1
  147. package/src/cli/commands/generate.ts +13 -2
  148. package/src/cli/commands/journey.ts +4 -2
  149. package/src/cli/commands/next.ts +30 -9
  150. package/src/cli/commands/triage.ts +5 -3
  151. package/src/cli/commands/update.ts +6 -2
  152. package/src/cli/index.ts +4 -0
  153. package/src/cli/json-error-on-exit.ts +72 -0
  154. package/src/cli/project-sungen-invocation.ts +20 -0
  155. package/src/cli/run-command-hints.ts +47 -0
  156. package/src/cli/waiting-for-you-block.ts +27 -0
  157. package/src/contract/derive-free-feature.ts +21 -0
  158. package/src/decisions/legacy-waiver-migration.ts +5 -1
  159. package/src/exporters/csv-exporter.ts +12 -4
  160. package/src/exporters/matrix/export.ts +11 -4
  161. package/src/exporters/xlsx-exporter.ts +5 -4
  162. package/src/graph/board-human-actions.ts +80 -0
  163. package/src/graph/evaluate-graph.ts +39 -8
  164. package/src/graph/human-action.ts +33 -0
  165. package/src/graph/node-decl.ts +11 -0
  166. package/src/graph/nodes/test-case-contract-nodes.ts +20 -4
  167. package/src/graph/nodes/test-case-nodes.ts +19 -5
  168. package/src/graph/nodes/unit-audit-nodes.ts +2 -1
  169. package/src/graph/nodes/unit-contract-node.ts +12 -1
  170. package/src/graph/unit-artifact-probes.ts +3 -1
  171. package/src/harness/catalog/gate-rules.yaml +1 -1
  172. package/src/harness/gates/audit-report-verification.ts +7 -2
  173. package/src/harness/gates/delivery-checklist.ts +11 -1
  174. package/src/harness/gates/in-run-decision-check.ts +11 -4
  175. package/src/harness/journey.ts +79 -8
  176. package/src/harness/next-step.ts +27 -1
  177. package/src/hooks/guard-tty-spoof-rule.ts +5 -4
  178. package/src/hooks/hand-back-core.ts +11 -5
  179. package/src/hooks/session-start-unit-state-hook.ts +4 -1
  180. package/src/hooks/stop-next-step-hook.ts +1 -0
  181. package/src/orchestrator/templates/ai-src/commands/delivery.md +2 -0
  182. package/src/orchestrator/templates/ai-src/commands/review.md +2 -0
  183. package/src/orchestrator/templates/ai-src/partials/waiting-for-you.claude.md +11 -0
  184. package/src/orchestrator/templates/ai-src/partials/waiting-for-you.codex.md +11 -0
  185. package/src/orchestrator/templates/ai-src/partials/waiting-for-you.copilot.md +11 -0
@@ -586,7 +586,7 @@ rules:
586
586
  severity: review
587
587
  kind: hard
588
588
  rationale: "A check on a JS value, API or canvas result is not tied to an element and cannot be compared automatically (DOS-14.6); it always goes to review and is locked at review."
589
- fix: "QA reviews the verbatim step and its file:line, then approves."
589
+ fix: "QA reviews the verbatim step and its file:line (`sungen contract check`), then accepts it as it reads now on a terminal: `sungen contract approve --screen <unit> --scenario <id> --accept-opaque`. A plain re-approve never clears it."
590
590
  since: "p0"
591
591
  offAllowed: false
592
592
  - id: contract.nondeterministic
@@ -58,6 +58,8 @@ function provenanceField(r: Record<string, unknown>, key: 'sungenVersion' | 'cat
58
58
  const writtenBy = (r: Record<string, unknown>): string | null => provenanceField(r, 'sungenVersion');
59
59
 
60
60
  export interface RulesStamp { catalogHash: string | null; rulesHash: string | null }
61
+ /** rulesProblem's answer for a report that records no rules (`provenance.catalogHash` / `rulesHash`): an older sungen wrote it. */
62
+ export const NO_RULES_STAMP = 'audited by an older sungen, which did not record the rules it scored with';
61
63
  /** What getProvenance writes when it could not read a catalog — never a match, on either side. */
62
64
  const UNKNOWN = 'unknown';
63
65
 
@@ -67,7 +69,7 @@ const UNKNOWN = 'unknown';
67
69
  * unknown == unknown proves nothing.
68
70
  */
69
71
  export function rulesProblem(was: RulesStamp, now: RulesStamp): string | null {
70
- if (!was.catalogHash || !was.rulesHash) return 'it does not say which rules scored it (no provenance.catalogHash / rulesHash)';
72
+ if (!was.catalogHash || !was.rulesHash) return NO_RULES_STAMP;
71
73
  if ([was.catalogHash, was.rulesHash].includes(UNKNOWN)) return 'the rules that scored it are unknown (provenance says "unknown")';
72
74
  if (!now.catalogHash || !now.rulesHash || [now.catalogHash, now.rulesHash].includes(UNKNOWN)) return 'this sungen cannot read its own rules (catalog or rules hash unknown), so it trusts no report';
73
75
  if (was.catalogHash === now.catalogHash && was.rulesHash === now.rulesHash) return null;
@@ -162,7 +164,10 @@ export function verifyAuditReport(projectRoot: string, unit: string): AuditVerif
162
164
  }
163
165
  const stale: AuditVerification['problems'] = [];
164
166
  const rules = otherRules(r);
165
- if (rules) stale.push({ code: 'rules-changed', message: `${writtenBy(r) ? `written by sungen ${writtenBy(r)}, ` : ''}${rules}` });
167
+ // A report from before the rules stamp reads in plain words, with the one command that clears it (#966).
168
+ if (rules) stale.push({ code: 'rules-changed', message: rules === NO_RULES_STAMP
169
+ ? `audited by an older sungen${writtenBy(r) ? ` (${writtenBy(r)})` : ''} — run \`sungen audit --screen ${unit}\` once`
170
+ : `${writtenBy(r) ? `written by sungen ${writtenBy(r)}, ` : ''}${rules}` });
166
171
  if (r.decisionsHash !== current) stale.push({ code: 'decisions-changed', message: 'the gate-override decisions changed since this audit' });
167
172
  if (!unitDir) return result('invalid', [{ code: 'unit-unresolved', message: `"${unit}" does not name exactly one unit, so the audit's inputs cannot be checked` }]);
168
173
  if (r.inputsHash !== auditInputsHash(unitDir, unit, r.decisionsHash as string)) {
@@ -50,13 +50,23 @@ async function quietly<T>(fn: () => Promise<T>): Promise<T> {
50
50
  try { return await fn(); } finally { Object.assign(console, saved); }
51
51
  }
52
52
 
53
+ /** Findings a fresh `sungen generate` cures: the spec is out of step with the .feature, not hollow. */
54
+ const REGENERATE_CURES = /^(?:DRIFT|MISSING in spec|EXTRA in spec|Count mismatch)\b/;
55
+
56
+ /** The first finding's first sentence — its cause after "→" kept (#943) — and, when regenerating cures it, the command. */
57
+ function scriptCheckFailure(u: ChecklistUnit, finding = 'not faithful'): string {
58
+ const first = finding.split(/\. /)[0].slice(0, 240);
59
+ const generate = `sungen generate --${u.kind === 'flow' ? 'flow' : u.kind === 'api' ? 'api' : 'screen'} ${u.unitName}`;
60
+ return `FAIL — ${first}${REGENERATE_CURES.test(finding) ? ` — run \`${generate}\` after fixing the scenario` : ''} (\`sungen script-check\` exits 2)`;
61
+ }
62
+
53
63
  async function scriptCheckBox(u: ChecklistUnit): Promise<ChecklistBox> {
54
64
  try {
55
65
  const r = await quietly(() => runScriptCheck(u.unitDir, u.unitName, u.kind));
56
66
  if (!r.specPath) return box('script-check', 'skipped', 'no compiled spec to compare: nothing was compiled for this unit');
57
67
  return r.status === 'OK'
58
68
  ? box('script-check', 'pass', `OK — the spec is a 1:1 of the .feature, no drift (${r.automatedScenarios} automated)`)
59
- : box('script-check', 'fail', `FAIL — ${(r.findings[0] ?? 'not faithful').split(/ → |\. /)[0].slice(0, 160)} (\`sungen script-check\` exits 2)`);
69
+ : box('script-check', 'fail', scriptCheckFailure(u, r.findings[0]));
60
70
  } catch (e) {
61
71
  return box('script-check', 'fail', `could not run: ${why(e)}`);
62
72
  }
@@ -23,7 +23,8 @@
23
23
  * `humanConfirmed`, #928) is a claim the entry makes about itself — a script can write one. What backs
24
24
  * it is review, so it is in-run while git HEAD does not hold it (and, with no git, always: nothing
25
25
  * shows it was reviewed). Judged by HEAD membership only: it is always recorded after the run it
26
- * judges, so its commit time says nothing.
26
+ * judges, so its commit time says nothing. Only a test-defect / environment entry that is not
27
+ * superseded counts — the only kind the reader puts in force on that claim.
27
28
  */
28
29
  import * as fs from 'fs';
29
30
  import * as path from 'path';
@@ -31,6 +32,7 @@ import { DECISION_FILE_PATH } from '../../decisions/decision-file';
31
32
  import { DecisionVisibility } from '../../decisions/decision-schema';
32
33
  import { gitOutput } from '../../decisions/git-head-trust';
33
34
  import { readDecisions } from '../../decisions/read-decisions';
35
+ import { HUMAN_ONLY_TRIAGE, ManualTriageClass, TRIAGE_DECISION_KIND } from '../../graph/manual-triage-decisions';
34
36
  import { migratedWaiverReview } from './migrated-waiver-review';
35
37
 
36
38
  export const IN_RUN_KINDS = ['gate-override', 'waiver', 'contract-approval'] as const;
@@ -64,9 +66,14 @@ export function runStartOf(resultsPath: string | null): string | null {
64
66
  }
65
67
 
66
68
  const isInRunKind = (kind: string): boolean => (IN_RUN_KINDS as readonly string[]).includes(kind);
67
- /** A triage classification claiming a human typed it (#928): reviewed only once git HEAD holds it. */
68
- const claimsHumanTriage = (e: { kind: string; payload?: Record<string, unknown> }): boolean =>
69
- e.kind === 'triage-classification' && e.payload?.humanConfirmed === true;
69
+ /**
70
+ * A triage classification claiming a human typed it (#928): reviewed only once git HEAD holds it.
71
+ * Only what the reader (manual-triage-decisions.ts) can put in force: a superseded entry is never read,
72
+ * and a product classification needs no human — its claim backs nothing, so it blocks nothing.
73
+ */
74
+ const claimsHumanTriage = (e: { kind: string; payload?: Record<string, unknown>; superseded_by?: string }): boolean =>
75
+ e.kind === TRIAGE_DECISION_KIND && !e.superseded_by && e.payload?.humanConfirmed === true
76
+ && HUMAN_ONLY_TRIAGE.has(e.payload?.class as ManualTriageClass);
70
77
 
71
78
  /** In-run decisions among `scopes`, judged against one run start (null: HEAD membership only) and the committed set. */
72
79
  export function inRunDecisions(projectRoot: string, scopes: string[], runStart: string | null, committed: Set<string> | null): InRunDecision[] {
@@ -243,6 +243,52 @@ function computeNext(r: JourneyReport, unit: string): string {
243
243
  * A read-only checkout gets the same board without throwing.
244
244
  */
245
245
  export function runJourney(projectRoot: string, unit: string): JourneyReport {
246
+ const r = reconcileJourney(projectRoot, unit);
247
+ try {
248
+ commitWaiverSync(projectRoot, unit, r.slug, r.sync, r.statePath, JSON.stringify(r.next, null, 2));
249
+ } catch (err) {
250
+ if (!(err instanceof DecisionConflictError)) throw err;
251
+ const rel = path.relative(projectRoot, err.path).split(path.sep).join('/');
252
+ if (!r.decisionFindings.some((f) => f.path === rel)) r.decisionFindings.push({ rule: 'decision.mutated', path: rel, message: err.message });
253
+ }
254
+ return finishReport(r, unit);
255
+ }
256
+
257
+ /** A waiver the board asks a human to give again: its evidence moved (stale), or git HEAD does not hold it (untrusted). */
258
+ export interface WaiverToRedecide { obligation: string; title: string; reason: string; why: 'stale' | 'untrusted' }
259
+
260
+ /** What the board asks of a human, read without writing anything (`sungen next`'s "Waiting for you", #954). */
261
+ export interface JourneyHumanAsks {
262
+ report: JourneyReport;
263
+ redecide: WaiverToRedecide[];
264
+ /** OB-signoff: `pending` (never given), `stale` (given before the audit changed), `given`. */
265
+ signoff: { status: 'pending' | 'stale' | 'given'; at?: string };
266
+ /** `sungen journey --signoff` would be accepted now (assertCanSignOff holds). */
267
+ canSignOff: boolean;
268
+ }
269
+
270
+ /** The board's asks, read-only: no import, no dual-write, no state file — the same reconcile `runJourney` commits. */
271
+ export function readJourneyHumanAsks(projectRoot: string, unit: string): JourneyHumanAsks {
272
+ const r = reconcileJourney(projectRoot, unit);
273
+ const report = finishReport(r, unit);
274
+ let canSignOff = report.obligations.some((o) => o.id === SIGNOFF_OBLIGATION);
275
+ try { assertCanSignOff(report); } catch { canSignOff = false; }
276
+ return { report, redecide: r.redecide, signoff: r.signoff, canSignOff };
277
+ }
278
+
279
+ interface Reconciled {
280
+ report: JourneyReport;
281
+ slug: string;
282
+ statePath: string;
283
+ sync: ReturnType<typeof planWaiverSync>;
284
+ next: JourneyState;
285
+ decisionFindings: DecisionGateFinding[];
286
+ redecide: WaiverToRedecide[];
287
+ signoff: JourneyHumanAsks['signoff'];
288
+ }
289
+
290
+ /** Compute fresh, apply the waivers and the sign-off in force — read-only; `runJourney` commits the plan. */
291
+ function reconcileJourney(projectRoot: string, unit: string): Reconciled {
246
292
  const slug = reportSlug(unit);
247
293
  assertNoPendingMigration(projectRoot, slug);
248
294
  const report = computeFresh(projectRoot, unit);
@@ -250,9 +296,11 @@ export function runJourney(projectRoot: string, unit: string): JourneyReport {
250
296
  const state = loadState(sp) || { unit, auditHash: '', waivers: {} };
251
297
  const curHash = auditHashOf(projectRoot, slug);
252
298
  const sync = planWaiverSync(projectRoot, unit, slug, sp, state.waivers);
299
+ const redecide: WaiverToRedecide[] = [];
253
300
  for (const u of sync.untrusted) {
254
301
  const title = report.obligations.find((o) => o.id === u.obligation)?.title ?? u.obligation;
255
302
  const ignored = unwaivableWhy(u.obligation, unit);
303
+ if (!ignored) redecide.push({ obligation: u.obligation, title, reason: u.waiver.reason, why: 'untrusted' });
256
304
  report.needsYou.unshift({ text: ignored ? ignoredWaiverText(u.obligation, ignored, u.waiver.reason) : untrustedWaiverText(unit, slug, title, u), anchor: { vpId: u.obligation } });
257
305
  }
258
306
 
@@ -267,30 +315,33 @@ export function runJourney(projectRoot: string, unit: string): JourneyReport {
267
315
  ob.waivedReason = w.reason;
268
316
  ob.detail = `waived — ${w.reason}`;
269
317
  } else {
318
+ redecide.push({ obligation: ob.id, title: ob.title, reason: w.reason, why: 'stale' });
270
319
  report.needsYou.unshift({ text: `⚠️ Waiver on "${ob.title}" is STALE (evidence changed since ${w.at}) — re-decide. Was: ${w.reason}`, anchor: { vpId: ob.id } });
271
320
  }
272
321
  }
273
322
  // S5 — sign-off reconcile: a recorded sign-off satisfies OB-signoff while its evidence holds;
274
323
  // if the audit changed since, the sign-off is STALE → re-surface for re-review (anti-amnesia).
275
324
  const signoffOb = report.obligations.find((o) => o.id === 'OB-signoff');
325
+ let signoff: Reconciled['signoff'] = { status: 'pending' };
276
326
  if (signoffOb && state.signoff) {
277
327
  if (state.signoff.auditHashAtSignoff === curHash) {
328
+ signoff = { status: 'given', at: state.signoff.at };
278
329
  signoffOb.status = 'satisfied';
279
330
  signoffOb.detail = `signed off${state.signoff.by ? ' by ' + state.signoff.by : ''}${state.signoff.recordedBy ? ` (recorded by ${state.signoff.recordedBy})` : ''} at ${state.signoff.at}`;
280
331
  } else {
332
+ signoff = { status: 'stale', at: state.signoff.at };
281
333
  report.needsYou.unshift({ text: `⚠️ Sign-off is STALE (suite changed since ${state.signoff.at}) — re-review and sign off again.`, anchor: { vpId: 'OB-signoff' } });
282
334
  }
283
335
  }
284
336
  // Phase-03 carry-over: a decision that does not verify is a FINDING the gate blocks on, not a crash.
285
337
  const decisionFindings = journeyDecisionFindings(projectRoot, unitOverrides(projectRoot, unit));
286
338
  const next: JourneyState = { unit, auditHash: curHash, waivers: sync.stateWaivers, signoff: state.signoff };
287
- try {
288
- commitWaiverSync(projectRoot, unit, slug, sync, sp, JSON.stringify(next, null, 2));
289
- } catch (err) {
290
- if (!(err instanceof DecisionConflictError)) throw err;
291
- const rel = path.relative(projectRoot, err.path).split(path.sep).join('/');
292
- if (!decisionFindings.some((f) => f.path === rel)) decisionFindings.push({ rule: 'decision.mutated', path: rel, message: err.message });
293
- }
339
+ return { report, slug, statePath: sp, sync, next, decisionFindings, redecide, signoff };
340
+ }
341
+
342
+ /** The decision findings on the board, then the next suggestion — after the waivers and the sign-off. */
343
+ function finishReport(r: Reconciled, unit: string): JourneyReport {
344
+ const { report, decisionFindings } = r;
294
345
  if (decisionFindings.length) {
295
346
  report.decisionFindings = decisionFindings;
296
347
  for (const f of [...decisionFindings].reverse()) report.needsYou.unshift({ text: decisionFindingText(f), anchor: { file: f.path } });
@@ -408,11 +459,31 @@ export function writeJourneyFileBestEffort(file: string, text: string): string |
408
459
 
409
460
  const ICON: Record<ObStatus, string> = { satisfied: '✅', 'needs-work': '⚠️ ', pending: '⏳', waived: '🚫', overridden: '🔓' };
410
461
 
462
+ /** `ms` as a person reads a duration: `850 ms`, `45 s`, `8 min 40 s`. */
463
+ function elapsedLabel(ms: number): string {
464
+ if (ms < 1000) return `${ms} ms`;
465
+ const s = Math.round(ms / 1000);
466
+ return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min${s % 60 ? ` ${s % 60} s` : ''}`;
467
+ }
468
+
469
+ /**
470
+ * A ledger step as the board shows it (#939 item 8). A step recorded with its elapsed milliseconds in the
471
+ * NAME (`--step "viewpoint 45000"`, the time meant for `--ms`) printed as a bare number; it reads
472
+ * `viewpoint (45 s)` now, and `timed` says the line needs its legend.
473
+ */
474
+ function phaseLabel(step: string): { label: string; timed: boolean } {
475
+ const m = step.match(/^(.*\S)\s+(\d+)$/);
476
+ return m ? { label: `${m[1]} (${elapsedLabel(Number(m[2]))})`, timed: true } : { label: step, timed: false };
477
+ }
478
+
411
479
  export function renderJourneyBoard(r: JourneyReport): string {
412
480
  const L: string[] = [];
413
481
  L.push(`# Journey — ${r.unit}`);
414
482
  L.push('');
415
- L.push(`📍 **You are here:** ${r.youAreHere}` + (r.phasesDone.length ? ` (phases: ${r.phasesDone.join(' → ')})` : ''));
483
+ const phases = r.phasesDone.map(phaseLabel);
484
+ const legend = phases.some((p) => p.timed) || phaseLabel(r.youAreHere).timed
485
+ ? ` — a time is that phase's elapsed time, from the ledger (\`sungen ledger report --screen ${r.unit}\`)` : '';
486
+ L.push(`📍 **You are here:** ${phaseLabel(r.youAreHere).label}` + (phases.length ? ` (phases: ${phases.map((p) => p.label).join(' → ')})${legend}` : ''));
416
487
  if (r.gateStatus) L.push(` gate: **${r.gateStatus}** · score: ${r.score ?? '—'}/10`);
417
488
  L.push('');
418
489
  L.push('## What must still be true');
@@ -19,7 +19,9 @@ import * as path from 'path';
19
19
  import { reportSlug } from './unit-paths';
20
20
  import { checkAssetsDrift } from '../orchestrator/assets-drift';
21
21
  import type { HeadSnapshot } from '../contract/contract-git';
22
- import { evaluateGraph, EvaluateOptions } from '../graph/evaluate-graph';
22
+ import { boardHumanActions } from '../graph/board-human-actions';
23
+ import { evaluateGraph, evaluateHumanActions, EvaluateOptions } from '../graph/evaluate-graph';
24
+ import type { HumanAction } from '../graph/human-action';
23
25
  import { CORE_NODES } from '../graph/nodes/core-node-catalog';
24
26
  import type { TestCaseState } from '../graph/test-case-state-shape';
25
27
  import { testCaseStates } from '../graph/test-case-states';
@@ -93,6 +95,19 @@ export interface UnitState {
93
95
  /** Which run the results come from and what they are older than (#942); absent without results. Advisory, decides no step. */
94
96
  results?: ResultsFreshness;
95
97
  steps: NextStep[];
98
+ /**
99
+ * Every pending HUMAN-ONLY action ("Waiting for you", #954): the frontier's decisions (deriveHumanActions)
100
+ * and the board's asks (sign-off, a waiver to give again, a decision recorded during the run). Set by
101
+ * readUnitState; commands canonical (`sungen …`). An agent presents them and never runs them.
102
+ */
103
+ humanActions?: HumanAction[];
104
+ /**
105
+ * The title of the blocking step that cut the frontier: past it only actions that do not depend on it
106
+ * are listed (sign-off, accept-in-run and the first contract approval wait). Absent when nothing cuts.
107
+ */
108
+ humanActionsHeldBy?: string;
109
+ /** The canonical command of that blocking step (#966) — what clears the hold. */
110
+ humanActionsHeldByCommand?: string;
96
111
  }
97
112
 
98
113
  export interface ReadUnitStateOptions {
@@ -170,6 +185,12 @@ export function readUnitState(
170
185
  if (results) state.results = results;
171
186
  } catch { /* advisory: results that cannot be dated add nothing */ }
172
187
  state.steps = deriveSteps(state);
188
+ const graph = evaluateHumanActions(CORE_NODES, state, state.testCases);
189
+ const seen = new Set<string>();
190
+ state.humanActions = [...graph.actions, ...boardHumanActions(projectRoot, unitDir, state, graph.cut)]
191
+ .filter((a) => !seen.has(a.command) && Boolean(seen.add(a.command)));
192
+ if (graph.heldBy) state.humanActionsHeldBy = graph.heldBy;
193
+ if (graph.heldByCommand) state.humanActionsHeldByCommand = graph.heldByCommand;
173
194
  return state;
174
195
  }
175
196
 
@@ -181,3 +202,8 @@ export function readUnitState(
181
202
  export function deriveSteps(s: UnitState, opts: EvaluateOptions = {}): NextStep[] {
182
203
  return evaluateGraph(CORE_NODES, s, s.testCases, opts);
183
204
  }
205
+
206
+ /** The human-only actions of the same frontier (#954) — the graph's part of `humanActions`, without the board. */
207
+ export function deriveHumanActions(s: UnitState): HumanAction[] {
208
+ return evaluateHumanActions(CORE_NODES, s, s.testCases).actions;
209
+ }
@@ -2,8 +2,9 @@
2
2
  * Guard rule `tty-spoof` (phase 06, M5): ASK before an agent runs a human-confirmed sungen command
3
3
  * through a terminal emulator.
4
4
  *
5
- * `contract approve`, `journey --waive`, `journey --decide`, `journey --signoff` and `triage --as test-defect|environment`
6
- * (#928: a red classified away from the product-defect path) refuse without a TTY (DOS-13.4), and
5
+ * `contract approve`, `journey --waive`, `journey --decide`, `journey --signoff`, `triage --as test-defect|environment`
6
+ * (#928: a red classified away from the product-defect path) and `delivery --accept-in-run` (delivering past
7
+ * decisions recorded during the run) refuse without a TTY (DOS-13.4), and
7
8
  * an agent's Bash has none — unless it borrows one: `script`, `expect`, `unbuffer`, or a pty from
8
9
  * Python/Perl/zsh. Then the prompt sees a "terminal" and a piped phrase reads as a human typing it.
9
10
  * This rule spots the pair in the raw command (quotes included, so `bash -c "…"` and `expect -c '…'`
@@ -14,7 +15,7 @@
14
15
  import type { GuardRule } from './guard-rules';
15
16
 
16
17
  const TTY_EMULATOR = /\b(?:script|unbuffer|expect|pty|zpty|openpty|forkpty|ptyprocess)\b/i;
17
- const HUMAN_GATED = /\bsungen\b[\s\S]*?\b(?:contract['",\s]+approve\b|journey\b[\s\S]*?--(?:waive|decide|signoff)\b|triage\b[\s\S]*?--as[\s='",]+(?:test-defect|environment)\b)/i;
18
+ const HUMAN_GATED = /\bsungen\b[\s\S]*?\b(?:contract['",\s]+approve\b|journey\b[\s\S]*?--(?:waive|decide|signoff)\b|triage\b[\s\S]*?--as[\s='",]+(?:test-defect|environment)\b|delivery\b[\s\S]*?--accept-in-run\b)/i;
18
19
 
19
20
  export const ttySpoof: GuardRule = {
20
21
  id: 'tty-spoof',
@@ -25,7 +26,7 @@ export const ttySpoof: GuardRule = {
25
26
  return {
26
27
  status: 'ask',
27
28
  askReason: 'sungen: this runs a human-confirmed sungen action (`contract approve` / `journey --waive|--decide|--signoff` / '
28
- + '`triage --as test-defect|environment`) through a terminal '
29
+ + '`triage --as test-defect|environment` / `delivery --accept-in-run`) through a terminal '
29
30
  + 'emulator (script / expect / unbuffer / pty). Those confirmations exist so a PERSON at a terminal decides — allow only if you '
30
31
  + 'are that person and asked for exactly this; otherwise run the command yourself in your own terminal.',
31
32
  };
@@ -10,7 +10,8 @@
10
10
  * commander's preAction, so a usage error — which never reaches an action — and `main().catch`
11
11
  * ended in silence. The state now starts the moment parsing does; the action only refines it.
12
12
  */
13
- import { sungenInvocation, withProjectInvocation } from '../cli/project-sungen-invocation';
13
+ import { sungenInvocation, withProjectInvocationInText } from '../cli/project-sungen-invocation';
14
+ import { waitingForYouLines } from '../cli/waiting-for-you-block';
14
15
  import { resolveUnitCandidates } from '../cli/resolve-unit';
15
16
  import { readUnitState, NextStep, UnitState } from '../harness/next-step';
16
17
  import { NO_OP, HookContext, HookInput, HookResult } from './hook-result-contract';
@@ -87,10 +88,14 @@ export function attachCliHandBack(argv: string[], cwd: string): { onAction: (nam
87
88
 
88
89
  const MARK: Record<NextStep['kind'], string> = { do: '→', blocked: '⛔', optional: '○', decide: '⚖' };
89
90
 
90
- /** `sungen next` rendered compactly for an agent's context; commands as `root` runs sungen. */
91
+ /**
92
+ * `sungen next` rendered compactly for an agent's context; commands — the step's own and those quoted in
93
+ * its reason (#966) — as `root` runs sungen, the way `sungen next` prints them.
94
+ */
91
95
  export function formatSteps(steps: NextStep[], max = 4, root?: string): string[] {
96
+ const asRun = (text: string): string => (root ? withProjectInvocationInText(text, root) : text);
92
97
  return steps.slice(0, max).map((st) =>
93
- ` ${MARK[st.kind]} ${root ? withProjectInvocation(st.command, root) : st.command}${st.kind === 'decide' ? ' (a QA decision — ask, do not run)' : ''}\n ${st.because}`);
98
+ ` ${MARK[st.kind]} ${asRun(st.command)}${st.kind === 'decide' ? ' (a QA decision — ask, do not run)' : ''}\n ${asRun(st.because)}`);
94
99
  }
95
100
 
96
101
  /** The unit a session is on (a named one when it exists, else the nearest) and its derived state. */
@@ -101,12 +106,13 @@ export function locateUnitState(ctx: HookContext, preferred?: string): { root: s
101
106
  return unit ? { root, unit, state: readUnitState(root, unit.dir, unit.name, unit.kind) } : null;
102
107
  }
103
108
 
104
- /** Hook core: the next steps for the session's unit (input.unit, else the nearest) as context. */
109
+ /** Hook core: the next steps for the session's unit (input.unit, else the nearest) as context, then what waits for a human (#954). */
105
110
  export function run(input: HookInput, ctx: HookContext): HookResult {
106
111
  const found = locateUnitState(ctx, typeof input.unit === 'string' ? input.unit : undefined);
107
112
  if (!found || found.state.steps.length === 0) return NO_OP;
108
113
  return {
109
114
  status: 'context',
110
- output: [`${handBackLine(found.unit.name, sungenInvocation(found.root))} — derived from what the project holds now:`, ...formatSteps(found.state.steps, 4, found.root)].join('\n'),
115
+ output: [`${handBackLine(found.unit.name, sungenInvocation(found.root))} — derived from what the project holds now:`, ...formatSteps(found.state.steps, 4, found.root),
116
+ ...waitingForYouLines(found.state.humanActions, found.root, found.state.humanActionsHeldBy, found.state.humanActionsHeldByCommand)].join('\n'),
111
117
  };
112
118
  }
@@ -5,11 +5,13 @@
5
5
  * After `/clear` or a context compaction the agent has lost the thread: it re-derives the state
6
6
  * from memory, or worse, re-runs work that is already done. This injects what `sungen next`
7
7
  * derives for the session's unit (the one cwd sits in, else the most recently active): what the
8
- * project already holds — stated as "do not redo" — the audit verdict, and the next steps.
8
+ * project already holds — stated as "do not redo" — the audit verdict, the next steps, and what waits
9
+ * for a human (#954).
9
10
  */
10
11
  import { NO_OP, HookMeta, SungenHook } from './hook-result-contract';
11
12
  import { formatSteps, locateUnitState } from './hand-back-core';
12
13
  import { sungenInvocation } from '../cli/project-sungen-invocation';
14
+ import { waitingForYouLines } from '../cli/waiting-for-you-block';
13
15
  import { listUnits } from './sungen-project-root';
14
16
  import type { UnitState } from '../harness/next-step';
15
17
 
@@ -48,6 +50,7 @@ export const sessionStartUnitStateHook: SungenHook = {
48
50
  state.audit ? `Audit: ${state.audit.overall}/10 [${state.audit.gateStatus}] · ${state.audit.findings.length} finding(s).` : '',
49
51
  `Next (\`${sungen} next ${unit.name}\`, derived from the project state):`,
50
52
  ...formatSteps(state.steps, 4, root),
53
+ ...waitingForYouLines(state.humanActions, root, state.humanActionsHeldBy, state.humanActionsHeldByCommand),
51
54
  input.source === 'compact' ? 'Context was compacted: re-read the unit\'s files before acting on them.' : '',
52
55
  others > 0 ? `Working on another unit? Ask \`${sungen} next <unit>\` (or \`${sungen} next\` for all).` : '',
53
56
  ];
@@ -30,6 +30,7 @@ export const stopNextStepHook: SungenHook = {
30
30
  output: [
31
31
  '[sungen] This turn ran sungen work but ended without the next-step hand-back.',
32
32
  'Hand back now: present these steps to the user with AskUserQuestion and stop — do not run them yourself.',
33
+ ...(/Waiting for you/.test(core.output) ? ['Quote the "Waiting for you" block verbatim: the user runs those commands in their own terminal (`! <command>`); never run them.'] : []),
33
34
  core.output,
34
35
  ].join('\n'),
35
36
  };
@@ -237,3 +237,5 @@ sungen delivery [names...] # matrix (default; needs the map) → XLSX onl
237
237
  Outputs: `qa/deliverables/<unit>-testcases.xlsx` (Overview + Auto + Manual + Coverage sheets) by default;
238
238
  `--format csv` writes `<unit>-testcases.csv` instead (flat, `Level` column `item|variant`),
239
239
  `--format both` writes both. Legacy mode writes the classic files instead.
240
+
241
+ <!-- include: waiting-for-you -->
@@ -97,3 +97,5 @@ You are an **independent QA Reviewer** — you did not author these tests. You d
97
97
  5. **Trace + next** — `sungen trace --screen ${input:name}` (human-loop focus), then offer:
98
98
  - **`/sungen-run-test ${input:name}`** (Recommended) · **`/sungen-delivery ${input:name}`** · **`/sungen-create-test ${input:name}`** · Done.
99
99
  {{/cap}}
100
+
101
+ <!-- include: waiting-for-you -->
@@ -0,0 +1,11 @@
1
+ <!-- sungen:waiting-for-you v1 -->
2
+ ## Waiting for you — the commands only the QA runs
3
+
4
+ Right before the hand-back question, run `sungen next {{unitArg}}` and quote its **⏳ Waiting for you**
5
+ block **verbatim** — every command exactly as printed, each with its reason. Those commands approve,
6
+ sign off, waive, classify a red, accept in-run decisions or re-run once the environment is up: they are
7
+ the QA's alone, and most refuse without a person typing a confirmation. Tell the QA to run each one in
8
+ their own terminal — in Claude Code, type `! <command>` in the prompt (a `/sungen:…` command is typed
9
+ as is). **Never run them yourself**, and never pipe or type a confirmation into them. When `sungen next`
10
+ prints no such block, say that nothing is waiting for the QA.
11
+ <!-- /sungen:waiting-for-you -->
@@ -0,0 +1,11 @@
1
+ <!-- sungen:waiting-for-you v1 -->
2
+ ## Waiting for you — the commands only the QA runs
3
+
4
+ Right before the hand-back list, run `sungen next {{unitArg}}` and quote its **⏳ Waiting for you**
5
+ block **verbatim** — every command exactly as printed, each with its reason. Those commands approve,
6
+ sign off, waive, classify a red, accept in-run decisions or re-run once the environment is up: they are
7
+ the QA's alone, and most refuse without a person typing a confirmation. Tell the QA to run each one
8
+ themselves, in their own terminal (a slash command is typed as is). **Never run them yourself**, and
9
+ never pipe or type a confirmation into them. When `sungen next` prints no such block, say that nothing
10
+ is waiting for the QA.
11
+ <!-- /sungen:waiting-for-you -->
@@ -0,0 +1,11 @@
1
+ <!-- sungen:waiting-for-you v1 -->
2
+ ## Waiting for you — the commands only the QA runs
3
+
4
+ Right before the hand-back list, run `sungen next {{unitArg}}` and quote its **⏳ Waiting for you**
5
+ block **verbatim** — every command exactly as printed, each with its reason. Those commands approve,
6
+ sign off, waive, classify a red, accept in-run decisions or re-run once the environment is up: they are
7
+ the QA's alone, and most refuse without a person typing a confirmation. Tell the QA to run each one
8
+ themselves, in their own terminal (a slash command is typed as is). **Never run them yourself**, and
9
+ never pipe or type a confirmation into them. When `sungen next` prints no such block, say that nothing
10
+ is waiting for the QA.
11
+ <!-- /sungen:waiting-for-you -->