@sun-asterisk/sungen 3.3.0-alpha.6 → 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 (130) 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 +4 -2
  11. package/dist/cli/commands/next.d.ts.map +1 -1
  12. package/dist/cli/commands/next.js +7 -0
  13. package/dist/cli/commands/next.js.map +1 -1
  14. package/dist/cli/commands/update.d.ts.map +1 -1
  15. package/dist/cli/commands/update.js +6 -2
  16. package/dist/cli/commands/update.js.map +1 -1
  17. package/dist/cli/index.js +4 -0
  18. package/dist/cli/index.js.map +1 -1
  19. package/dist/cli/json-error-on-exit.d.ts +34 -0
  20. package/dist/cli/json-error-on-exit.d.ts.map +1 -0
  21. package/dist/cli/json-error-on-exit.js +63 -0
  22. package/dist/cli/json-error-on-exit.js.map +1 -0
  23. package/dist/cli/waiting-for-you-block.d.ts +15 -0
  24. package/dist/cli/waiting-for-you-block.d.ts.map +1 -0
  25. package/dist/cli/waiting-for-you-block.js +19 -0
  26. package/dist/cli/waiting-for-you-block.js.map +1 -0
  27. package/dist/contract/derive-free-feature.d.ts +2 -0
  28. package/dist/contract/derive-free-feature.d.ts.map +1 -1
  29. package/dist/contract/derive-free-feature.js +22 -0
  30. package/dist/contract/derive-free-feature.js.map +1 -1
  31. package/dist/decisions/legacy-waiver-migration.d.ts +2 -0
  32. package/dist/decisions/legacy-waiver-migration.d.ts.map +1 -1
  33. package/dist/decisions/legacy-waiver-migration.js +5 -2
  34. package/dist/decisions/legacy-waiver-migration.js.map +1 -1
  35. package/dist/graph/board-human-actions.d.ts +5 -0
  36. package/dist/graph/board-human-actions.d.ts.map +1 -0
  37. package/dist/graph/board-human-actions.js +83 -0
  38. package/dist/graph/board-human-actions.js.map +1 -0
  39. package/dist/graph/evaluate-graph.d.ts +16 -0
  40. package/dist/graph/evaluate-graph.d.ts.map +1 -1
  41. package/dist/graph/evaluate-graph.js +29 -6
  42. package/dist/graph/evaluate-graph.js.map +1 -1
  43. package/dist/graph/human-action.d.ts +21 -0
  44. package/dist/graph/human-action.d.ts.map +1 -0
  45. package/dist/graph/human-action.js +3 -0
  46. package/dist/graph/human-action.js.map +1 -0
  47. package/dist/graph/node-decl.d.ts +9 -0
  48. package/dist/graph/node-decl.d.ts.map +1 -1
  49. package/dist/graph/node-decl.js +4 -1
  50. package/dist/graph/node-decl.js.map +1 -1
  51. package/dist/graph/nodes/test-case-contract-nodes.d.ts.map +1 -1
  52. package/dist/graph/nodes/test-case-contract-nodes.js +16 -3
  53. package/dist/graph/nodes/test-case-contract-nodes.js.map +1 -1
  54. package/dist/graph/nodes/test-case-nodes.d.ts.map +1 -1
  55. package/dist/graph/nodes/test-case-nodes.js +16 -4
  56. package/dist/graph/nodes/test-case-nodes.js.map +1 -1
  57. package/dist/graph/nodes/unit-audit-nodes.d.ts.map +1 -1
  58. package/dist/graph/nodes/unit-audit-nodes.js +1 -0
  59. package/dist/graph/nodes/unit-audit-nodes.js.map +1 -1
  60. package/dist/graph/nodes/unit-contract-node.d.ts.map +1 -1
  61. package/dist/graph/nodes/unit-contract-node.js +8 -0
  62. package/dist/graph/nodes/unit-contract-node.js.map +1 -1
  63. package/dist/harness/catalog/gate-rules.yaml +1 -1
  64. package/dist/harness/gates/delivery-checklist.d.ts.map +1 -1
  65. package/dist/harness/gates/delivery-checklist.js +9 -1
  66. package/dist/harness/gates/delivery-checklist.js.map +1 -1
  67. package/dist/harness/gates/in-run-decision-check.d.ts.map +1 -1
  68. package/dist/harness/gates/in-run-decision-check.js +10 -3
  69. package/dist/harness/gates/in-run-decision-check.js.map +1 -1
  70. package/dist/harness/journey.d.ts +21 -0
  71. package/dist/harness/journey.d.ts.map +1 -1
  72. package/dist/harness/journey.js +61 -11
  73. package/dist/harness/journey.js.map +1 -1
  74. package/dist/harness/next-step.d.ts +14 -0
  75. package/dist/harness/next-step.d.ts.map +1 -1
  76. package/dist/harness/next-step.js +12 -0
  77. package/dist/harness/next-step.js.map +1 -1
  78. package/dist/hooks/guard-tty-spoof-rule.d.ts +3 -2
  79. package/dist/hooks/guard-tty-spoof-rule.d.ts.map +1 -1
  80. package/dist/hooks/guard-tty-spoof-rule.js +2 -2
  81. package/dist/hooks/guard-tty-spoof-rule.js.map +1 -1
  82. package/dist/hooks/hand-back-core.d.ts +1 -1
  83. package/dist/hooks/hand-back-core.d.ts.map +1 -1
  84. package/dist/hooks/hand-back-core.js +4 -2
  85. package/dist/hooks/hand-back-core.js.map +1 -1
  86. package/dist/hooks/session-start-unit-state-hook.d.ts +2 -1
  87. package/dist/hooks/session-start-unit-state-hook.d.ts.map +1 -1
  88. package/dist/hooks/session-start-unit-state-hook.js +4 -1
  89. package/dist/hooks/session-start-unit-state-hook.js.map +1 -1
  90. package/dist/hooks/stop-next-step-hook.d.ts.map +1 -1
  91. package/dist/hooks/stop-next-step-hook.js +1 -0
  92. package/dist/hooks/stop-next-step-hook.js.map +1 -1
  93. package/dist/orchestrator/templates/ai-src/commands/delivery.md +2 -0
  94. package/dist/orchestrator/templates/ai-src/commands/review.md +2 -0
  95. package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.claude.md +11 -0
  96. package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.codex.md +11 -0
  97. package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.copilot.md +11 -0
  98. package/package.json +3 -3
  99. package/src/cli/commands/contract-derive-command.ts +1 -0
  100. package/src/cli/commands/delivery.ts +20 -3
  101. package/src/cli/commands/generate.ts +3 -1
  102. package/src/cli/commands/next.ts +7 -2
  103. package/src/cli/commands/update.ts +6 -2
  104. package/src/cli/index.ts +4 -0
  105. package/src/cli/json-error-on-exit.ts +72 -0
  106. package/src/cli/waiting-for-you-block.ts +23 -0
  107. package/src/contract/derive-free-feature.ts +21 -0
  108. package/src/decisions/legacy-waiver-migration.ts +5 -1
  109. package/src/graph/board-human-actions.ts +80 -0
  110. package/src/graph/evaluate-graph.ts +37 -8
  111. package/src/graph/human-action.ts +33 -0
  112. package/src/graph/node-decl.ts +11 -0
  113. package/src/graph/nodes/test-case-contract-nodes.ts +20 -4
  114. package/src/graph/nodes/test-case-nodes.ts +19 -5
  115. package/src/graph/nodes/unit-audit-nodes.ts +2 -1
  116. package/src/graph/nodes/unit-contract-node.ts +12 -1
  117. package/src/harness/catalog/gate-rules.yaml +1 -1
  118. package/src/harness/gates/delivery-checklist.ts +11 -1
  119. package/src/harness/gates/in-run-decision-check.ts +11 -4
  120. package/src/harness/journey.ts +79 -8
  121. package/src/harness/next-step.ts +24 -1
  122. package/src/hooks/guard-tty-spoof-rule.ts +5 -4
  123. package/src/hooks/hand-back-core.ts +4 -2
  124. package/src/hooks/session-start-unit-state-hook.ts +4 -1
  125. package/src/hooks/stop-next-step-hook.ts +1 -0
  126. package/src/orchestrator/templates/ai-src/commands/delivery.md +2 -0
  127. package/src/orchestrator/templates/ai-src/commands/review.md +2 -0
  128. package/src/orchestrator/templates/ai-src/partials/waiting-for-you.claude.md +11 -0
  129. package/src/orchestrator/templates/ai-src/partials/waiting-for-you.codex.md +11 -0
  130. package/src/orchestrator/templates/ai-src/partials/waiting-for-you.copilot.md +11 -0
@@ -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 -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sun-asterisk/sungen",
3
- "version": "3.3.0-alpha.6",
3
+ "version": "3.3.0-alpha.7",
4
4
  "description": "Deterministic E2E Test Compiler - Gherkin + Selectors → Playwright tests",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -40,8 +40,8 @@
40
40
  "@babel/types": "^7.28.5",
41
41
  "@cucumber/gherkin": "^37.0.0",
42
42
  "@cucumber/messages": "^31.0.0",
43
- "@sungen/driver-data-factory": "3.3.0-alpha.6",
44
- "@sungen/driver-ui": "3.3.0-alpha.6",
43
+ "@sungen/driver-data-factory": "3.3.0-alpha.7",
44
+ "@sungen/driver-ui": "3.3.0-alpha.7",
45
45
  "chalk": "^5.6.2",
46
46
  "commander": "^14.0.2",
47
47
  "dotenv": "^17.2.3",
@@ -19,6 +19,7 @@ function print(r: DeriveResult, unit: string): void {
19
19
  L(`━━━ Contract derive: ${unit} — free tests' traces → ${rel} ━━━`);
20
20
  L('');
21
21
  if (r.note) L(` ⚠ ${r.note}`);
22
+ for (const w of r.warnings ?? []) L(` ⚠ ${w}`);
22
23
  for (const s of r.scenarios) L(` + ${s.title} (${s.steps} steps${s.low ? `, ${s.low} low identity` : ''}${s.opaque ? `, ${s.opaque} opaque` : ''})`);
23
24
  for (const s of r.skipped) L(` - ${s.id} skipped: ${s.reason}`);
24
25
  if (r.refused?.length) {
@@ -41,6 +41,7 @@ import { MatrixFinding } from '../../exporters/matrix/types';
41
41
  import { applyManualCarry, ManualCarryReport, PriorManualResults, readPriorManualResults } from '../../exporters/matrix/carry-manual-results';
42
42
  import { formatTestOutcome, resultTotals } from '../../exporters/matrix/result-totals';
43
43
  import { readCapabilities } from '../../harness/capability';
44
+ import { splitFeatureBlocks } from '../../contract/free-feature-blocks';
44
45
  import { processIO } from '../../decisions/human-confirmation';
45
46
  import { DeliveryDecisionGate } from '../../harness/gates/delivery-decision-gate';
46
47
  import { runDeliveryDecisionStep } from '../../harness/gates/delivery-decision-step';
@@ -101,14 +102,30 @@ function makeTarget(screen: string, featureBaseName: string, kind: UnitKind): De
101
102
  }
102
103
 
103
104
  /**
104
- * List all `.feature` files inside a screen/flow/api unit as separate targets.
105
- * Returns empty array when the directory has no features yet.
105
+ * A derived `*.free.feature` none of whose scenarios QA has approved yet (every one still `@proposed`):
106
+ * a proposal, not a delivery unit — counted as one, it ended in "Not exported" (#939). Approving a
107
+ * scenario drops its tag (`contract approve`), and from then on the file is a unit like any other.
108
+ */
109
+ function proposalOnly(featureFile: string): boolean {
110
+ if (!featureFile.endsWith('.free.feature')) return false;
111
+ try {
112
+ // No scenario block at all (empty, or not Gherkin) is not a proposal: it stays listed and the preflight names it.
113
+ const blocks = splitFeatureBlocks(fs.readFileSync(featureFile, 'utf8'));
114
+ return blocks.length > 0 && blocks.every((b) => b.proposed);
115
+ } catch {
116
+ return false; // unreadable: listed, so the preflight names the problem
117
+ }
118
+ }
119
+
120
+ /**
121
+ * List all `.feature` files inside a screen/flow/api unit as separate targets — a proposal-only
122
+ * free feature excepted. Returns empty array when the directory has no features yet.
106
123
  */
107
124
  function listFeatureTargets(cwd: string, screen: string, kind: UnitKind): DeliveryTarget[] {
108
125
  const featuresDir = path.join(cwd, 'qa', qaParent(kind), screen, 'features');
109
126
  if (!fs.existsSync(featuresDir)) return [];
110
127
  return fs.readdirSync(featuresDir)
111
- .filter((f) => f.endsWith('.feature'))
128
+ .filter((f) => f.endsWith('.feature') && !proposalOnly(path.join(featuresDir, f)))
112
129
  .map((f) => makeTarget(screen, f.slice(0, -'.feature'.length), kind))
113
130
  .sort((a, b) => a.featureBaseName.localeCompare(b.featureBaseName));
114
131
  }
@@ -255,7 +255,9 @@ export function registerGenerateCommand(program: Command): void {
255
255
  if (shown.length < ddWarnings.length) console.log(` … and ${ddWarnings.length - shown.length} more (run with -v to list all)`);
256
256
  }
257
257
 
258
- const nextStep = platform === 'mobile' ? 'npm run test:mobile' : 'npx playwright test --ui';
258
+ // Never the interactive `--ui` (#939): an agent cannot drive it, and run-test is what records the results.
259
+ const unit = screenName || flowName || apiName;
260
+ const nextStep = platform === 'mobile' ? 'npm run test:mobile' : unit ? `/sungen:run-test ${unit} (or the plain run: npx playwright test)` : 'npx playwright test';
259
261
  console.log(`Next step: ${nextStep}\n`);
260
262
  } catch (error) {
261
263
  console.error('Error:', error instanceof Error ? error.message : error);
@@ -9,9 +9,11 @@
9
9
  * sungen next <unit> --case <id> one test case: its state, and its steps after the blocking
10
10
  * unit steps before them (OES-10.3)
11
11
  * --json the same, machine-readable; steps add nodeId, granularity,
12
- * testCase?, gates?; the unit adds `results` (#942); every older
12
+ * testCase?, gates?; the unit adds `results` (#942) and
13
+ * `humanActions: [{command, reason, kind}]` (#954); every older
13
14
  * field is kept. stdout is the payload alone (#660, #836)
14
- * The text names the run the results come from and warns when they are older than the suite (#942).
15
+ * The text names the run the results come from and warns when they are older than the suite (#942),
16
+ * and ends with "Waiting for you": every pending human-only command, with why (#954).
15
17
  */
16
18
  import { Command } from 'commander';
17
19
  import * as fs from 'fs';
@@ -23,6 +25,7 @@ import { describeResultsRun } from '../../graph/unit-results-freshness';
23
25
  import { deriveSteps, NextStep, readUnitState, UnitState } from '../../harness/next-step';
24
26
  import { resolveUnit, resolveUnitCandidates, AmbiguousUnitError } from '../resolve-unit';
25
27
  import { withProjectInvocation } from '../project-sungen-invocation';
28
+ import { waitingForYouLines } from '../waiting-for-you-block';
26
29
 
27
30
  /** Every unit the project holds, so `sungen next` with no argument still answers. */
28
31
  function allUnits(cwd: string): Array<{ name: string; kind: UnitState['kind']; dir: string }> {
@@ -69,6 +72,8 @@ function render(s: UnitState, cwd: string): void {
69
72
  L('');
70
73
  renderSteps(s.steps, cwd);
71
74
  L('');
75
+ const waiting = waitingForYouLines(s.humanActions, cwd, s.humanActionsHeldBy);
76
+ if (waiting.length) { for (const line of waiting) L(line); L(''); }
72
77
  }
73
78
 
74
79
  /** `--case <id>`: the case's state and its frontier. Exits 1 when the case cannot be found. */
@@ -26,7 +26,9 @@ import { syncProjectSungenDeps } from '../../orchestrator/project-deps-sync';
26
26
  *
27
27
  * The public `--skip-npm-install` flag is still available for users who
28
28
  * already installed the binary by other means (CI pipelines, corporate
29
- * mirror, manual download) and only want to refresh the project AI assets.
29
+ * mirror, manual download). It skips only step 1: the project sync still
30
+ * re-pins package.json (security overrides included) and runs `npm install`
31
+ * there — `--skip-project` skips that (#939).
30
32
  */
31
33
 
32
34
  const SKIP_NPM_ENV = 'SUNGEN_UPDATE_SKIP_NPM';
@@ -70,9 +72,11 @@ export function registerUpdateCommand(program: Command): void {
70
72
  '--tag <dist-tag>',
71
73
  'Install a specific npm dist-tag (e.g. latest, beta, alpha, next). Overrides --beta / --alpha.',
72
74
  )
75
+ // #939: it skips only the GLOBAL install — the project is still synced, so the help says so.
73
76
  .option(
74
77
  '--skip-npm-install',
75
- 'Skip the global npm install step (refresh project AI assets only)',
78
+ 'Skip the global npm install step. The project is still synced: its sungen packages are re-pinned in package.json '
79
+ + '(security `overrides` included) and `npm install` runs in the project — add --skip-project to skip that too',
76
80
  false,
77
81
  )
78
82
  .option(
package/src/cli/index.ts CHANGED
@@ -45,6 +45,7 @@ import { capabilityRegistry } from '../capabilities/registry';
45
45
  import { discoverAndRegisterCapabilities } from '../capabilities/discover';
46
46
  import { warnAssetsDrift } from '../orchestrator/assets-drift';
47
47
  import { attachCliHandBack } from '../hooks/hand-back-core';
48
+ import { attachJsonErrorOnExit, commandSupportsJson } from './json-error-on-exit';
48
49
  import { registerHookCommand } from '../hooks/hook-cli-command';
49
50
 
50
51
  // Read version from package.json so `--version` never drifts from the released version.
@@ -60,6 +61,8 @@ async function main() {
60
61
  // those rules live in the hook core (src/hooks/hand-back-core.ts) — this file only wires it.
61
62
  const handBack = attachCliHandBack(process.argv, process.cwd());
62
63
  const program = new Command();
64
+ // #939: a failed `--json` command prints {"error":{code,message}} on stdout (json-error-on-exit.ts).
65
+ const jsonErrors = attachJsonErrorOnExit(process.argv, (args) => commandSupportsJson(program, args));
63
66
 
64
67
  program
65
68
  .name('sungen')
@@ -130,6 +133,7 @@ registerSetupMobileEnvCommand(program);
130
133
  // The action knows its unit and output mode; the hand-back default above is refined here.
131
134
  program.hook('preAction', (_thisCommand, actionCommand) => {
132
135
  handBack.onAction(actionCommand.name(), actionCommand.opts() as Record<string, unknown>, actionCommand.args);
136
+ jsonErrors.onAction();
133
137
  });
134
138
 
135
139
  await program.parseAsync(process.argv);
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `--json` failures speak JSON too (#939 item 7). A command that supports `--json` and fails used to
3
+ * print its error on stderr and NOTHING on stdout, so a caller parsing stdout got an empty string. Now,
4
+ * when such a command exits non-zero without having written stdout, the exit prints one object:
5
+ *
6
+ * {"error":{"code":"command-failed" | "usage-error","message":"<the error it printed>"}}
7
+ *
8
+ * command-failed the action ran and failed (`next nope --json`)
9
+ * usage-error commander refused the arguments before any action ran (`audit --json --bogus`)
10
+ * The human error stays on stderr, unchanged. A command that printed its own JSON keeps it, whatever
11
+ * its exit code (`contract check --json` exits 2 with the verdict); a command without `--json` is never
12
+ * touched — commander's own "unknown option '--json'" is its usage error, not a JSON caller's.
13
+ * Like the hand-back, it hangs off process exit: commands exit from inside their actions.
14
+ */
15
+ import type { Command } from 'commander';
16
+
17
+ export type JsonErrorCode = 'command-failed' | 'usage-error';
18
+ export interface JsonError { error: { code: JsonErrorCode; message: string } }
19
+
20
+ const ANSI = /\u001b\[[0-9;]*m/g;
21
+ const ERROR_LINE = /^(?:Error|error|Fatal error|❌[^:]*):\s*/;
22
+ const MAX_CAPTURE = 64 * 1024;
23
+
24
+ /** The error object for what the command printed on stderr: from its first error line on, the hand-back dropped. */
25
+ export function jsonErrorPayload(stderr: string, actionRan: boolean, exitCode: number): JsonError {
26
+ const lines = stderr.replace(ANSI, '').split('\n').map((l) => l.trimEnd()).filter((l) => l && !/^Next: /.test(l));
27
+ const at = lines.findIndex((l) => ERROR_LINE.test(l));
28
+ const picked = at >= 0 ? lines.slice(at) : lines.slice(-1);
29
+ const message = picked.join('\n').replace(ERROR_LINE, '').trim() || `exited with code ${exitCode}`;
30
+ return { error: { code: actionRan ? 'command-failed' : 'usage-error', message } };
31
+ }
32
+
33
+ /** The (sub)command `args` name, walked down from `program`, defines `--json`. */
34
+ export function commandSupportsJson(program: Command, args: string[]): boolean {
35
+ let cmd: Command = program;
36
+ for (const a of args) {
37
+ if (a.startsWith('-')) continue;
38
+ const sub = cmd.commands.find((c) => c.name() === a || c.aliases().includes(a));
39
+ if (!sub) break;
40
+ cmd = sub;
41
+ }
42
+ return cmd !== program && cmd.options.some((o) => o.long === '--json');
43
+ }
44
+
45
+ /**
46
+ * Wire it to process exit for `argv` (a no-op without `--json`). `supportsJson` is asked at exit, once
47
+ * every command is registered; `onAction` marks that an action ran (a failure then is not a usage error).
48
+ */
49
+ export function attachJsonErrorOnExit(argv: string[], supportsJson: (args: string[]) => boolean): { onAction: () => void } {
50
+ const args = argv.slice(2);
51
+ if (!args.includes('--json')) return { onAction: () => undefined };
52
+ let wroteStdout = false;
53
+ let stderr = '';
54
+ let actionRan = false;
55
+ const writeOut = process.stdout.write.bind(process.stdout);
56
+ const writeErr = process.stderr.write.bind(process.stderr);
57
+ process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
58
+ if (String(chunk).length) wroteStdout = true;
59
+ return (writeOut as (...a: unknown[]) => boolean)(chunk, ...rest);
60
+ }) as typeof process.stdout.write;
61
+ process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => {
62
+ if (stderr.length < MAX_CAPTURE) stderr += String(chunk);
63
+ return (writeErr as (...a: unknown[]) => boolean)(chunk, ...rest);
64
+ }) as typeof process.stderr.write;
65
+ process.on('exit', (code) => {
66
+ try {
67
+ if (code === 0 || wroteStdout || !supportsJson(args)) return;
68
+ writeOut(`${JSON.stringify(jsonErrorPayload(stderr, actionRan, code))}\n`);
69
+ } catch { /* the exit code is the contract; this object is a courtesy */ }
70
+ });
71
+ return { onAction: () => { actionRan = true; } };
72
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The "Waiting for you" block (#954) — every pending human-only action of a unit, as ready-to-copy
3
+ * commands with the reason under each. One renderer for the three places that show it: `sungen next`
4
+ * (text) and the Stop / SessionStart hook context. Commands as the project runs sungen (`npx sungen`
5
+ * with a local install, #939); `--json` keeps them canonical in `humanActions`. An instruction (a commit
6
+ * through review, a `git checkout --`) is marked `✎ … (by hand, not a command)`, never shown as one to copy.
7
+ */
8
+ import type { HumanAction } from '../graph/human-action';
9
+ import { withProjectInvocation } from './project-sungen-invocation';
10
+
11
+ export const WAITING_FOR_YOU_HEAD = '⏳ Waiting for you — a human runs these in their own terminal (in Claude Code: ! <command>); an agent never runs them:';
12
+
13
+ /**
14
+ * The block's lines (indented two spaces), or none when nothing waits for a human. `heldBy`: the title
15
+ * of the blocking step that cut the frontier — the block is not exhaustive then, and says what holds the rest.
16
+ */
17
+ export function waitingForYouLines(actions: readonly HumanAction[] | undefined, projectRoot: string, heldBy?: string): string[] {
18
+ if (!actions?.length) return [];
19
+ return [` ${WAITING_FOR_YOU_HEAD}`, ...actions.flatMap((a) => [
20
+ a.instruction ? ` ✎ ${a.command} (by hand, not a command)` : ` ${withProjectInvocation(a.command, projectRoot)}`,
21
+ ` ${a.reason}`,
22
+ ]), ...(heldBy ? [` More appear once "${heldBy}" clears.`] : [])];
23
+ }
@@ -22,6 +22,7 @@ import { scrubKnownSecrets, scrubMachinePaths, secretScanHits, SecretHit } from
22
22
  import { idOfTitle, splitFeatureBlocks } from './free-feature-blocks';
23
23
  import { sensitiveLiteralHits } from './lock-secret-guard';
24
24
  import { scenariosOfFeatureFile } from './normalize-feature-steps';
25
+ import { featureFilesFor } from '../harness/unit-paths';
25
26
  import { CONTROL_CHARS, freeFeatureText, renderTraceSteps, scenarioBlockText } from './render-free-gherkin';
26
27
  import { collectRuns, TraceRun } from './trace/collect-trace-evidence';
27
28
  import { readIdentityCache } from './trace/identity-cache';
@@ -39,6 +40,8 @@ export interface DeriveResult {
39
40
  skipped: Array<{ id: string; reason: string; approved?: true }>;
40
41
  refused?: SecretHit[];
41
42
  note?: string;
43
+ /** A derived id another `.feature` of the unit already uses (#939) — `contract check` and approve would trip on it later. */
44
+ warnings?: string[];
42
45
  }
43
46
 
44
47
  const MACHINE_PATH = /(?:\/Users|\/home)\/[^/\s"'`]+\/|[A-Za-z]:\\Users\\/;
@@ -59,6 +62,22 @@ function semanticHits(text: string, label: string): SecretHit[] {
59
62
  } finally { fs.rmSync(dir, { recursive: true, force: true }); }
60
63
  }
61
64
 
65
+ /**
66
+ * The derived ids another `.feature` of the unit already carries (#939): `contract check` reports an id
67
+ * used twice and approve refuses it, so say it now — when it is still one rename away — not at check.
68
+ */
69
+ function reusedIdWarnings(unit: { name: string; dir: string }, freeFile: string, ids: string[]): string[] {
70
+ const owners = new Map<string, string>();
71
+ for (const f of featureFilesFor(unit.dir, unit.name)) {
72
+ if (path.resolve(f) === path.resolve(freeFile) || !fs.existsSync(f)) continue;
73
+ try {
74
+ for (const sc of scenariosOfFeatureFile(f)) if (!owners.has(sc.id)) owners.set(sc.id, path.basename(f));
75
+ } catch { /* an unparseable sibling is `contract check`'s to report */ }
76
+ }
77
+ return ids.filter((id) => owners.has(id)).map((id) =>
78
+ `${id} is already the ID of a scenario in ${owners.get(id)} — the derived scenario reuses it; give the free test (its title's leading ID) or that scenario its own ID, or \`sungen contract check\` reports it twice and approval refuses it`);
79
+ }
80
+
62
81
  /**
63
82
  * Nothing was derived and nothing explains it away: no test of the unit left an admissible trace
64
83
  * (#932). Not when the scenarios are approved (derive leaves those alone by design), up to date,
@@ -108,6 +127,8 @@ export async function deriveFreeFeature(i: DeriveInput): Promise<DeriveResult> {
108
127
  result.scenarios.push({ id, title, steps: rendered[0].length, low: rendered[0].filter((s) => s.low).length, opaque: rendered[0].filter((s) => s.opaque).length });
109
128
  }
110
129
  if (!result.scenarios.length) return result;
130
+ const reused = reusedIdWarnings(i.unit, file, result.scenarios.map((x) => x.id));
131
+ if (reused.length) result.warnings = reused;
111
132
 
112
133
  const text = freeFeatureText(unitBase, [...blocks.values()]);
113
134
  const label = path.basename(file);
@@ -80,9 +80,13 @@ export function planWaiverSync(projectRoot: string, unit: string, slug: string,
80
80
  return { scope, waivers, untrusted, stateWaivers, imports, markDone: !!scope && untrusted.length === 0 };
81
81
  }
82
82
 
83
+ /** The command a human types to give (or confirm) a waiver — the board's line and `sungen next`'s "Waiting for you" (#954). */
84
+ export const waiveCommand = (unit: string, obligation: string, reason: string): string =>
85
+ formatCommand(['sungen', 'journey', '--screen', unit, '--waive', obligation, '--reason', reason]);
86
+
83
87
  /** The needs-you line for a waiver that is not in git HEAD: not counted, and how a human confirms it. */
84
88
  export function untrustedWaiverText(unit: string, slug: string, title: string, u: UntrustedWaiver): string {
85
- const confirm = formatCommand(['sungen', 'journey', '--screen', unit, '--waive', u.obligation, '--reason', u.waiver.reason]);
89
+ const confirm = waiveCommand(unit, u.obligation, u.waiver.reason);
86
90
  return `⚠️ Waiver on "${title}" is UNTRUSTED — not counted (decision.in-run: it is in .sungen/journey/${slug}.state.json `
87
91
  + `but not in git HEAD). If it is yours, confirm it on a terminal: ${confirm}`;
88
92
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The human-only actions the graph does not hold ("Waiting for you", #954) — asked by the journey
3
+ * board and the delivery's decision check, read through THEIR functions (never restated here):
4
+ *
5
+ * waive a waiver the board re-surfaces: stale (the audit changed since) or untrusted (not
6
+ * in git HEAD) — readJourneyHumanAsks, the reconcile `runJourney` commits
7
+ * decide a gate-override that relaxes nothing because no human confirmed it (ineffective) —
8
+ * re-recorded with `journey --decide`, superseding it (loadUnitGateOverrides)
9
+ * signoff every other obligation holds — assertCanSignOff, as `journey --signoff` checks it
10
+ * accept-in-run a decision recorded during the run — evaluateDeliveryDecisions, as `delivery` runs it
11
+ * The sign-off and accept-in-run only once the frontier was not cut (the unit has run and nothing
12
+ * blocks before it). Read-only and advisory: whatever cannot be read adds nothing.
13
+ */
14
+ import { formatCommand } from '../decisions/human-confirmation';
15
+ import { waiveCommand } from '../decisions/legacy-waiver-migration';
16
+ import { evaluateDeliveryDecisions } from '../harness/gates/delivery-decision-gate';
17
+ import { IN_RUN_RULE } from '../harness/gates/in-run-decision-check';
18
+ import { loadUnitGateOverrides, unitScopeOfDir } from '../harness/gates/unit-gate-overrides';
19
+ import { readJourneyHumanAsks, signoffCommand } from '../harness/journey';
20
+ import type { UnitState } from '../harness/next-step';
21
+ import type { HumanAction } from './human-action';
22
+ import { unitResultFiles } from './unit-artifact-probes';
23
+
24
+ function journeyActions(projectRoot: string, s: UnitState, cut: boolean): HumanAction[] {
25
+ const asks = readJourneyHumanAsks(projectRoot, s.unit);
26
+ const out: HumanAction[] = asks.redecide.map((w) => ({
27
+ kind: 'waive',
28
+ command: waiveCommand(s.unit, w.obligation, w.reason),
29
+ reason: w.why === 'stale'
30
+ ? `the waiver on "${w.title}" is stale — the evidence changed since it was given, so it no longer counts; if it still holds, a human gives it again on a terminal (or repair the obligation)`
31
+ : `the waiver on "${w.title}" is not in git HEAD (decision.in-run), so it is not counted — if it is yours, confirm it on a terminal`,
32
+ }));
33
+ if (!cut && asks.canSignOff && asks.signoff.status !== 'given') {
34
+ out.push({
35
+ kind: 'signoff',
36
+ command: signoffCommand(s.unit),
37
+ reason: asks.signoff.status === 'stale'
38
+ ? `the sign-off of ${asks.signoff.at} is stale — the suite changed since; review the queue (\`sungen journey --screen ${s.unit}\`) and sign off again`
39
+ : `every other obligation is satisfied or waived — the human sign-off is the one gate \`sungen gate --phase deliver\` still waits on; review the queue (\`sungen journey --screen ${s.unit}\`, ${asks.report.needsYou.length} item(s)), then sign`,
40
+ });
41
+ }
42
+ return out;
43
+ }
44
+
45
+ function unconfirmedOverrides(projectRoot: string, unitDir: string, s: UnitState): HumanAction[] {
46
+ return loadUnitGateOverrides(projectRoot, unitScopeOfDir(projectRoot, unitDir)).ineffective.map((o) => {
47
+ const { humanAccepted: _ignored, ...payload } = o.entry.payload ?? {};
48
+ return {
49
+ kind: 'decide',
50
+ command: formatCommand(['sungen', 'journey', '--screen', s.unit, '--decide', 'gate-override', '--text', o.entry.text, '--reason', o.entry.reason,
51
+ '--payload', JSON.stringify(payload), '--supersedes', o.decisionId]),
52
+ reason: `the gate-override ${o.decisionId}${o.rule ? ` (${o.rule})` : ''} relaxes nothing: ${o.reason} — if it is meant, a human records it again on a terminal, superseding it`,
53
+ };
54
+ });
55
+ }
56
+
57
+ function inRunActions(projectRoot: string, unitDir: string, s: UnitState): HumanAction[] {
58
+ const files = unitResultFiles(projectRoot, s.unit);
59
+ const refs = (files.length ? files : [null]).map((resultsPath) => ({ label: s.unit, unitName: s.unit, unitDir, resultsPath }));
60
+ const inRun = evaluateDeliveryDecisions(projectRoot, refs).inRun;
61
+ if (!inRun.length) return [];
62
+ const which = inRun.slice(0, 3).map((d) => `${d.kind} ${d.id}`).join(', ');
63
+ return [{
64
+ kind: 'accept-in-run',
65
+ command: formatCommand(['sungen', 'delivery', s.unit, '--accept-in-run']),
66
+ reason: `${inRun.length} decision(s) recorded during the run (${IN_RUN_RULE}): ${which}${inRun.length > 3 ? ', …' : ''} — \`sungen delivery\` refuses them until they are committed through review and the tests re-run, or a human accepts them on a terminal`,
67
+ }];
68
+ }
69
+
70
+ /** The board's and the delivery's asks of a human for this unit. `cut`: the frontier stopped at a blocking node. */
71
+ export function boardHumanActions(projectRoot: string, unitDir: string, s: UnitState, cut: boolean): HumanAction[] {
72
+ const out: HumanAction[] = [];
73
+ const add = (read: () => HumanAction[]): void => {
74
+ try { out.push(...read()); } catch { /* advisory: an unreadable board or decision adds nothing */ }
75
+ };
76
+ add(() => journeyActions(projectRoot, s, cut));
77
+ add(() => unconfirmedOverrides(projectRoot, unitDir, s));
78
+ if (!cut) add(() => inRunActions(projectRoot, unitDir, s));
79
+ return out;
80
+ }
@@ -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
+ }
@@ -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;