@sun-asterisk/sungen 3.2.24-beta.1 → 3.2.24-beta.3

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 (90) hide show
  1. package/dist/cli/commands/audit.d.ts.map +1 -1
  2. package/dist/cli/commands/audit.js +22 -16
  3. package/dist/cli/commands/audit.js.map +1 -1
  4. package/dist/cli/commands/capability.d.ts.map +1 -1
  5. package/dist/cli/commands/capability.js +11 -12
  6. package/dist/cli/commands/capability.js.map +1 -1
  7. package/dist/cli/commands/challenge.d.ts.map +1 -1
  8. package/dist/cli/commands/challenge.js +2 -10
  9. package/dist/cli/commands/challenge.js.map +1 -1
  10. package/dist/cli/commands/depth-lint.d.ts.map +1 -1
  11. package/dist/cli/commands/depth-lint.js +2 -12
  12. package/dist/cli/commands/depth-lint.js.map +1 -1
  13. package/dist/cli/commands/gate.d.ts.map +1 -1
  14. package/dist/cli/commands/gate.js +2 -47
  15. package/dist/cli/commands/gate.js.map +1 -1
  16. package/dist/cli/commands/journey.d.ts.map +1 -1
  17. package/dist/cli/commands/journey.js +2 -12
  18. package/dist/cli/commands/journey.js.map +1 -1
  19. package/dist/cli/commands/manifest.d.ts.map +1 -1
  20. package/dist/cli/commands/manifest.js +2 -10
  21. package/dist/cli/commands/manifest.js.map +1 -1
  22. package/dist/cli/commands/next.d.ts +14 -0
  23. package/dist/cli/commands/next.d.ts.map +1 -0
  24. package/dist/cli/commands/next.js +130 -0
  25. package/dist/cli/commands/next.js.map +1 -0
  26. package/dist/cli/index.js +34 -0
  27. package/dist/cli/index.js.map +1 -1
  28. package/dist/cli/resolve-unit.d.ts +22 -0
  29. package/dist/cli/resolve-unit.d.ts.map +1 -0
  30. package/dist/cli/resolve-unit.js +101 -0
  31. package/dist/cli/resolve-unit.js.map +1 -0
  32. package/dist/exporters/feature-parser.d.ts +4 -3
  33. package/dist/exporters/feature-parser.d.ts.map +1 -1
  34. package/dist/exporters/feature-parser.js +10 -4
  35. package/dist/exporters/feature-parser.js.map +1 -1
  36. package/dist/harness/audit.d.ts +9 -1
  37. package/dist/harness/audit.d.ts.map +1 -1
  38. package/dist/harness/audit.js +92 -6
  39. package/dist/harness/audit.js.map +1 -1
  40. package/dist/harness/capability-plan.d.ts +12 -1
  41. package/dist/harness/capability-plan.d.ts.map +1 -1
  42. package/dist/harness/capability-plan.js +16 -2
  43. package/dist/harness/capability-plan.js.map +1 -1
  44. package/dist/harness/capability.d.ts +12 -0
  45. package/dist/harness/capability.d.ts.map +1 -1
  46. package/dist/harness/capability.js +16 -0
  47. package/dist/harness/capability.js.map +1 -1
  48. package/dist/harness/catalog/drivers.yaml +5 -0
  49. package/dist/harness/flow-contract.d.ts +75 -0
  50. package/dist/harness/flow-contract.d.ts.map +1 -1
  51. package/dist/harness/flow-contract.js +122 -1
  52. package/dist/harness/flow-contract.js.map +1 -1
  53. package/dist/harness/next-step.d.ts +40 -0
  54. package/dist/harness/next-step.d.ts.map +1 -0
  55. package/dist/harness/next-step.js +242 -0
  56. package/dist/harness/next-step.js.map +1 -0
  57. package/dist/harness/spec-coverage.d.ts +20 -0
  58. package/dist/harness/spec-coverage.d.ts.map +1 -1
  59. package/dist/harness/spec-coverage.js +35 -0
  60. package/dist/harness/spec-coverage.js.map +1 -1
  61. package/dist/harness/viewpoint-ledger.d.ts +4 -0
  62. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  63. package/dist/harness/viewpoint-ledger.js +42 -0
  64. package/dist/harness/viewpoint-ledger.js.map +1 -1
  65. package/dist/orchestrator/templates/ai-src/commands/create-test.md +27 -8
  66. package/dist/orchestrator/templates/ai-src/commands/run-test.md +6 -0
  67. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +36 -0
  68. package/package.json +3 -3
  69. package/src/cli/commands/audit.ts +22 -14
  70. package/src/cli/commands/capability.ts +11 -10
  71. package/src/cli/commands/challenge.ts +2 -8
  72. package/src/cli/commands/depth-lint.ts +2 -10
  73. package/src/cli/commands/gate.ts +2 -10
  74. package/src/cli/commands/journey.ts +2 -10
  75. package/src/cli/commands/manifest.ts +2 -8
  76. package/src/cli/commands/next.ts +96 -0
  77. package/src/cli/index.ts +31 -0
  78. package/src/cli/resolve-unit.ts +72 -0
  79. package/src/exporters/feature-parser.ts +10 -4
  80. package/src/harness/audit.ts +95 -11
  81. package/src/harness/capability-plan.ts +23 -3
  82. package/src/harness/capability.ts +22 -0
  83. package/src/harness/catalog/drivers.yaml +5 -0
  84. package/src/harness/flow-contract.ts +158 -1
  85. package/src/harness/next-step.ts +237 -0
  86. package/src/harness/spec-coverage.ts +38 -0
  87. package/src/harness/viewpoint-ledger.ts +42 -0
  88. package/src/orchestrator/templates/ai-src/commands/create-test.md +27 -8
  89. package/src/orchestrator/templates/ai-src/commands/run-test.md +6 -0
  90. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +36 -0
@@ -27,6 +27,21 @@ import { parse as parseYaml } from 'yaml';
27
27
  import { ScenarioInfo } from './parse';
28
28
  import { readTextFile } from './read-text';
29
29
 
30
+ /** One declared flow of the use case: a branch point, its own steps, its own outcome. */
31
+ export interface FlowDecl {
32
+ /** The flow id as the use-case document numbers it — `BF`, `AF01`, `EF03`. */
33
+ id: string;
34
+ /** Where it leaves the basic flow (`BF step 2`). Omitted for the basic flow itself. */
35
+ branchFrom?: string;
36
+ /** Its OWN outcome/postcondition — what is different about where this flow ends. */
37
+ outcome?: string;
38
+ status: 'covered' | 'deferred' | 'pending-clarification' | 'out-of-scope';
39
+ /** Why, for anything other than `covered` — a deferral with no reason is a silent gap. */
40
+ reason?: string;
41
+ }
42
+
43
+ const FLOW_STATUSES = new Set(['covered', 'deferred', 'pending-clarification', 'out-of-scope']);
44
+
30
45
  export interface FlowContract {
31
46
  goal: string;
32
47
  actor?: string;
@@ -43,6 +58,18 @@ export interface FlowContract {
43
58
  /** Journey phases this flow declares. Default [HP, ER, EH]; UI is allowed but
44
59
  * never demanded (presentation is the balance axis's business, not coverage's). */
45
60
  phases: string[];
61
+ /**
62
+ * The use case's declared flow inventory — the answer to "how many flows does this use
63
+ * case HAVE?". `phases:` alone cannot answer it: a phase is present as soon as ONE
64
+ * scenario carries it, so a fifteen-flow use case with three scenarios reported full
65
+ * phase coverage. With an inventory, coverage is measured per DECLARED FLOW and a flow
66
+ * nobody wrote is a named gap instead of a silent absence (#595).
67
+ *
68
+ * `status` is what keeps a gap honest: every flow ends up `covered`, `deferred`,
69
+ * `pending-clarification` (an open question for the BA — the behaviour is not agreed yet,
70
+ * so no scenario can be right) or `out-of-scope`. Absent → `covered` is expected.
71
+ */
72
+ flows?: FlowDecl[];
46
73
  /** The mutated collection (cart, order, application …) — enables regression dims. */
47
74
  stateful?: string;
48
75
  budgets?: Record<string, number>;
@@ -117,6 +144,23 @@ export function loadFlowContract(unitDir: string): { contract: FlowContract | nu
117
144
  successGuarantee: raw.successGuarantee !== undefined ? String(raw.successGuarantee) : (raw.success_guarantee !== undefined ? String(raw.success_guarantee) : undefined),
118
145
  minimalGuarantee: raw.minimalGuarantee !== undefined ? String(raw.minimalGuarantee) : (raw.minimal_guarantee !== undefined ? String(raw.minimal_guarantee) : undefined),
119
146
  phases,
147
+ flows: Array.isArray(raw.flows)
148
+ ? (raw.flows as Array<Record<string, unknown>>)
149
+ .filter((f) => f && typeof f === 'object' && f.id)
150
+ .map((f) => {
151
+ const status = String(f.status ?? 'covered').toLowerCase();
152
+ if (!FLOW_STATUSES.has(status)) {
153
+ errors.push(`flows[${String(f.id)}].status "${status}" is not one of covered|deferred|pending-clarification|out-of-scope`);
154
+ }
155
+ return {
156
+ id: String(f.id).toUpperCase(),
157
+ branchFrom: f.branchFrom !== undefined ? String(f.branchFrom) : (f.branch_from !== undefined ? String(f.branch_from) : undefined),
158
+ outcome: f.outcome !== undefined ? String(f.outcome) : undefined,
159
+ status: (FLOW_STATUSES.has(status) ? status : 'covered') as FlowDecl['status'],
160
+ reason: f.reason !== undefined ? String(f.reason) : undefined,
161
+ };
162
+ })
163
+ : undefined,
120
164
  stateful: raw.stateful !== undefined ? String(raw.stateful).toLowerCase() : undefined,
121
165
  budgets: (raw.budgets && typeof raw.budgets === 'object') ? raw.budgets as Record<string, number> : undefined,
122
166
  external: Array.isArray(raw.external)
@@ -130,7 +174,9 @@ export function loadFlowContract(unitDir: string): { contract: FlowContract | nu
130
174
  : undefined,
131
175
  golden: raw.golden === true,
132
176
  },
133
- errors: [],
177
+ // Shape errors found while reading the inventory (a bad `status:`) are REPORTED with the
178
+ // contract, not swallowed — the contract is still usable, the typo is not silently ignored.
179
+ errors,
134
180
  };
135
181
  }
136
182
 
@@ -145,6 +191,22 @@ function touchesOutcome(s: ScenarioInfo, outcomeScreen: string): boolean {
145
191
  return namespacesInOrder(s).includes(outcomeScreen);
146
192
  }
147
193
 
194
+ /**
195
+ * Does the scenario ARRIVE at the outcome, as opposed to merely naming it?
196
+ *
197
+ * A guard scenario asserts the outcome screen is *absent* (`[Complete:Title] header is hidden`)
198
+ * — the strongest thing it can say — so "the outcome namespace appears somewhere in the steps"
199
+ * counts it as reaching a screen it exists to prove unreachable.
200
+ */
201
+ function reachesOutcome(s: ScenarioInfo, outcomeScreen: string): boolean {
202
+ // Per STEP, from the structured list — `stepsText` is one space-joined blob, so any negation
203
+ // anywhere in the scenario would suppress every positive assertion in it.
204
+ const steps = (s.steps ?? []).map((st) => st.text.toLowerCase());
205
+ if (steps.length === 0) return touchesOutcome(s, outcomeScreen);
206
+ return steps.some((t) => t.includes(`[${outcomeScreen}`)
207
+ && !/\b(is hidden|is not visible|does not exist|is absent|no longer)\b/.test(t));
208
+ }
209
+
148
210
  /** Phase of a scenario: its declared phase token (FL-HP-001 / VP-FLOW-ER-02 / MS-EH-005)
149
211
  * when present, else vocabulary detection. */
150
212
  export function phaseOf(s: ScenarioInfo, declared: string[]): string | null {
@@ -163,6 +225,101 @@ export function phaseOf(s: ScenarioInfo, declared: string[]): string | null {
163
225
  return null;
164
226
  }
165
227
 
228
+ /** The flow id a scenario claims: the `AF02`/`EF11`/`BF` segment of its viewpoint id. */
229
+ export function flowIdOf(s: ScenarioInfo, declaredPhases: string[]): string | null {
230
+ const tokens = new Set(declaredPhases.map(phaseToken));
231
+ for (const seg of (s.vpId ?? '').toUpperCase().split('-')) {
232
+ if (tokens.has(phaseToken(seg))) return seg;
233
+ }
234
+ return null;
235
+ }
236
+
237
+ export interface InventoryResult {
238
+ /** Declared → the scenarios claiming it. A declared flow with none is a NAMED gap. */
239
+ covered: Array<{ id: string; scenarios: string[] }>;
240
+ /** Declared `covered` but nothing written — the silent-absence case, now named. */
241
+ uncovered: FlowDecl[];
242
+ /** Declared with a non-covered status, carried into the report so it stays visible. */
243
+ accounted: FlowDecl[];
244
+ /** A scenario's flow id that the inventory never declares — an id invented for one
245
+ * assertion (an "EF" that is really a success postcondition, an "AF" that is really a
246
+ * content check on the basic path). */
247
+ undeclared: Array<{ id: string; scenario: string }>;
248
+ /** Covered declared flows / declared flows that OUGHT to be covered. */
249
+ ratio: number;
250
+ }
251
+
252
+ /**
253
+ * Coverage per DECLARED FLOW, which is not the same question as coverage per phase.
254
+ *
255
+ * A phase is "covered" the moment one scenario carries it, so a use case decomposed into
256
+ * fifteen flows reported `BF=✓ AF=✓ EF=✓ → 100%` on three scenarios. Reviewers reading the
257
+ * suite counted the flows instead and got a very different number (#595). When the contract
258
+ * declares its inventory, this measures the thing the reviewer measures — and every gap is
259
+ * named, which is what makes "no silent missing flow" checkable rather than aspirational.
260
+ */
261
+ export function flowInventory(contract: FlowContract, scenarios: ScenarioInfo[]): InventoryResult | null {
262
+ if (!contract.flows || contract.flows.length === 0) return null;
263
+ const byId = new Map<string, string[]>();
264
+ const undeclared: Array<{ id: string; scenario: string }> = [];
265
+ const declaredIds = new Set(contract.flows.map((f) => f.id));
266
+ for (const s of scenarios) {
267
+ const id = flowIdOf(s, contract.phases);
268
+ if (!id) continue;
269
+ if (!declaredIds.has(id)) { undeclared.push({ id, scenario: s.name }); continue; }
270
+ byId.set(id, [...(byId.get(id) ?? []), s.name]);
271
+ }
272
+ const expected = contract.flows.filter((f) => f.status === 'covered');
273
+ const covered = expected.filter((f) => (byId.get(f.id) ?? []).length > 0)
274
+ .map((f) => ({ id: f.id, scenarios: byId.get(f.id)! }));
275
+ return {
276
+ covered,
277
+ uncovered: expected.filter((f) => (byId.get(f.id) ?? []).length === 0),
278
+ accounted: contract.flows.filter((f) => f.status !== 'covered'),
279
+ undeclared,
280
+ ratio: expected.length ? covered.length / expected.length : 1,
281
+ };
282
+ }
283
+
284
+ /**
285
+ * A phase id used for something that is not that kind of flow.
286
+ *
287
+ * Two shapes, both seen on a real suite that the phase check scored 100%:
288
+ * - an `EF` scenario that REACHES the contract outcome. An exception flow is blocked by
289
+ * definition; one that completes the journey is a success-path postcondition wearing an
290
+ * exception's id ("the buffer does not survive a finalized registration").
291
+ * - an `AF` scenario with no branch: it walks the basic path and asserts extra content on
292
+ * it. An alternate flow needs a point where the actor does something else; without one it
293
+ * is an assertion belonging to the basic flow ("the shared component renders the new-email
294
+ * copy").
295
+ * Both inflate the flow count while adding no branch coverage, which is exactly what makes a
296
+ * suite look complete to the harness and thin to a reviewer.
297
+ */
298
+ export function misfiledPhases(
299
+ contract: FlowContract, scenarios: ScenarioInfo[], basicPhase: string,
300
+ ): Array<{ scenario: string; id: string; why: string }> {
301
+ const out: Array<{ scenario: string; id: string; why: string }> = [];
302
+ const outcome = contract.outcome.screen;
303
+ for (const s of scenarios) {
304
+ const id = flowIdOf(s, contract.phases);
305
+ if (!id || phaseToken(id) === phaseToken(basicPhase)) continue;
306
+ const ph = phaseToken(id);
307
+ if (ph === 'EF' && reachesOutcome(s, outcome)) {
308
+ // An error-then-recover flow legitimately ends at the outcome — it is the recovery that
309
+ // is being proven, and a guard flow is blocked rather than "failed". Only a scenario with
310
+ // no failure and no block at all is misfiled.
311
+ const failed = /\b(error|invalid|duplicate|reject|fail|denied|blocked|expired|refuse|unauthenticat|unauthoriz|redirect|guard|without (?:a |an |the )?\w+|not skippable|forces? \w+ to restart)\w*/i.test(s.haystack);
312
+ if (!failed) {
313
+ out.push({ scenario: s.name, id, why: `reaches the outcome screen [${outcome}] and nothing in it fails — this is a success-path postcondition, not an exception flow` });
314
+ }
315
+ }
316
+ if (ph === 'AF' && !/\b(back|cancel|return|instead|abandon|second tab|another tab|skip|leave|exit|retry|edit)\w*/i.test(s.haystack)) {
317
+ out.push({ scenario: s.name, id, why: 'no branch point — it walks the basic path and asserts extra content on it, so the assertion belongs to the basic flow' });
318
+ }
319
+ }
320
+ return out;
321
+ }
322
+
166
323
  /**
167
324
  * Verify the suite against the contract. Deterministic; a flow without a contract
168
325
  * returns hasContract:false and neutral values (the audit reports the checklist).
@@ -0,0 +1,237 @@
1
+ /**
2
+ * "What now?" — one deterministic resolver over the project's own state.
3
+ *
4
+ * The next step used to be hardcoded per command, and only two of twenty-nine printed one at
5
+ * all. So a run that ended anywhere else ended in silence: `sungen capability add mock`
6
+ * finished with a blank line, the operator's agent had nothing telling it what remained, and the
7
+ * whole session stopped mid-workflow (#597).
8
+ *
9
+ * Hardcoding it in the other twenty-seven would repeat the mistake in bulk. What decides the
10
+ * next step is not which command just ran — it is what the project now HOLDS: the artifacts on
11
+ * disk, the audit report in `.sungen/reports/`, the accepted viewpoint baseline, the compiled
12
+ * spec, the run results. So this reads that state and derives the answer, and every command ends
13
+ * by pointing here. One place to be right, one place to test.
14
+ *
15
+ * Deterministic and read-only: same project state → same answer, no network, no writes.
16
+ */
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import { reportSlug, featureFilesFor } from './unit-paths';
20
+ import { readTextFile } from './read-text';
21
+
22
+ export interface NextStep {
23
+ /** The command to run, as the operator would type it. */
24
+ command: string;
25
+ /** Why it is next — stated so an agent can weigh it, not just obey it. */
26
+ because: string;
27
+ /** `blocked` steps are not runnable yet; they name what to resolve first. */
28
+ kind: 'do' | 'blocked' | 'optional';
29
+ }
30
+
31
+ export interface UnitState {
32
+ unit: string;
33
+ kind: 'screen' | 'flow' | 'api';
34
+ hasSpec: boolean;
35
+ hasViewpoint: boolean;
36
+ hasContract: boolean;
37
+ hasFlowInventory: boolean;
38
+ scenarioCount: number;
39
+ hasSelectors: boolean;
40
+ selectorsArePlaceholder: boolean;
41
+ compiled: boolean;
42
+ hasResults: boolean;
43
+ hasDeliverable: boolean;
44
+ audit?: {
45
+ overall: number;
46
+ gateStatus: string;
47
+ findings: string[];
48
+ viewpointBaselineStatus?: string;
49
+ };
50
+ steps: NextStep[];
51
+ }
52
+
53
+ const exists = (p: string): boolean => fs.existsSync(p);
54
+
55
+ function readJson(p: string): Record<string, unknown> | null {
56
+ try {
57
+ return JSON.parse(readTextFile(p)) as Record<string, unknown>;
58
+ } catch {
59
+ return null; // absent or unreadable — treated as "not run"
60
+ }
61
+ }
62
+
63
+ function countScenarios(unitDir: string, unit: string): number {
64
+ let n = 0;
65
+ for (const f of featureFilesFor(unitDir, unit)) {
66
+ const text = readTextFile(f) ?? '';
67
+ n += (text.match(/^\s*Scenario(?: Outline)?:/gm) ?? []).length;
68
+ }
69
+ return n;
70
+ }
71
+
72
+ /**
73
+ * Everything the resolver needs, read from disk. `unitId` is the catalog id
74
+ * (`<screen>` · `flows/<f>` · `api/<a>`); `unitDir` is its directory.
75
+ */
76
+ export function readUnitState(
77
+ projectRoot: string, unitDir: string, unit: string, kind: UnitState['kind'],
78
+ ): UnitState {
79
+ const req = path.join(unitDir, 'requirements');
80
+ const slug = reportSlug(unit);
81
+ const audit = readJson(path.join(projectRoot, '.sungen', 'reports', `${slug}-audit.json`));
82
+ const selectorsDir = path.join(unitDir, 'selectors');
83
+ let selectorFiles: string[] = [];
84
+ try {
85
+ selectorFiles = fs.readdirSync(selectorsDir).filter((f) => f.endsWith('.yaml'));
86
+ } catch { /* api units carry no selectors */ }
87
+ const selectorText = selectorFiles.map((f) => readTextFile(path.join(selectorsDir, f)) ?? '').join('\n');
88
+ const contractPath = path.join(req, 'flow-contract.yaml');
89
+ const contractText = readTextFile(contractPath) ?? '';
90
+
91
+ const specDirs = [
92
+ path.join(projectRoot, 'specs', 'generated', 'flows', unit),
93
+ path.join(projectRoot, 'specs', 'generated', unit),
94
+ ];
95
+ const compiled = specDirs.some((d) => {
96
+ try {
97
+ return fs.readdirSync(d).some((f) => f.endsWith('.spec.ts'));
98
+ } catch { return false; }
99
+ });
100
+ const hasResults = specDirs.some((d) => {
101
+ try {
102
+ return fs.readdirSync(d).some((f) => f.endsWith('-test-result.json'));
103
+ } catch { return false; }
104
+ });
105
+ let hasDeliverable = false;
106
+ try {
107
+ hasDeliverable = fs.readdirSync(path.join(projectRoot, 'qa', 'deliverables'))
108
+ .some((f) => f.startsWith(`${slug}-`));
109
+ } catch { /* nothing exported yet */ }
110
+
111
+ const score = (audit?.score ?? {}) as { overall?: number };
112
+ const baseline = (audit?.viewpointBaseline ?? {}) as { status?: string };
113
+ const state: UnitState = {
114
+ unit, kind,
115
+ hasSpec: exists(path.join(req, 'spec.md')),
116
+ hasViewpoint: exists(path.join(req, 'test-viewpoint.md')),
117
+ hasContract: exists(contractPath),
118
+ // A contract WITHOUT `flows:` can only be scored per phase, which is a materially weaker
119
+ // claim — so it is part of the state, not a detail.
120
+ hasFlowInventory: /^\s*flows:\s*$/m.test(contractText),
121
+ scenarioCount: countScenarios(unitDir, unit),
122
+ hasSelectors: selectorFiles.length > 0,
123
+ // A scaffolded selector file exists but resolves nothing; treating it as "done" is what
124
+ // makes a run look further along than it is.
125
+ selectorsArePlaceholder: selectorFiles.length > 0
126
+ && (/TODO|PLACEHOLDER|REPLACE ME/i.test(selectorText) || selectorText.trim().length < 40),
127
+ compiled, hasResults, hasDeliverable,
128
+ audit: audit
129
+ ? {
130
+ overall: typeof score.overall === 'number' ? score.overall : 0,
131
+ gateStatus: String(audit.gateStatus ?? 'UNKNOWN'),
132
+ findings: Array.isArray(audit.findings) ? (audit.findings as string[]) : [],
133
+ viewpointBaselineStatus: baseline.status,
134
+ }
135
+ : undefined,
136
+ steps: [],
137
+ };
138
+ state.steps = deriveSteps(state);
139
+ return state;
140
+ }
141
+
142
+ /** The finding codes worth naming in a next step — each maps to a specific repair. */
143
+ const REPAIRABLE = [
144
+ 'FLOW-CONTRACT-MISSING', 'FLOW-INVENTORY-MISSING', 'FLOW-UNCOVERED', 'FLOW-UNDECLARED',
145
+ 'FLOW-PHASE-MISFILED', 'FLOW-GUARANTEE-MISSING', 'FLOW-OUTCOME-UNPROVEN', 'CONTINUITY-ONE-SIDED',
146
+ 'SPEC-RESTATED-UNVERIFIED', 'VIEWPOINT-GESTURE-SUBSTITUTED', 'SPEC-UNCOVERED', 'TRIGGER-UNCOVERED',
147
+ 'VIEWPOINT-ITEM-MISSING', 'MANUAL-CODE-MISSING', 'MANUAL-AUTOMATABLE', 'DEPTH-DEFERRED',
148
+ ];
149
+
150
+ /**
151
+ * The ordered answer. Earliest unmet precondition first, so the list reads as a path rather than
152
+ * a menu — and a `blocked` step names what to resolve instead of pretending it can run.
153
+ */
154
+ export function deriveSteps(s: UnitState): NextStep[] {
155
+ const out: NextStep[] = [];
156
+ const design = s.kind === 'api' ? `/sungen:create-test ${s.unit}` : `/sungen:create-test ${s.unit}`;
157
+
158
+ if (!s.hasSpec && !s.hasViewpoint) {
159
+ out.push({
160
+ kind: 'blocked',
161
+ command: `write qa/${s.kind === 'screen' ? 'screens' : s.kind === 'flow' ? 'flows' : 'api'}/${s.unit}/requirements/spec.md`,
162
+ because: 'the unit has neither a spec nor a test-viewpoint, so there is nothing to generate FROM — generation without a source invents behaviour',
163
+ });
164
+ return out;
165
+ }
166
+ if (s.kind === 'flow' && !s.hasContract) {
167
+ out.push({
168
+ kind: 'blocked',
169
+ command: `author requirements/flow-contract.yaml, then ${design}`,
170
+ because: 'a flow with no contract is scored as a generic screen against page-type themes a journey cannot have (FLOW-CONTRACT-MISSING)',
171
+ });
172
+ return out;
173
+ }
174
+ if (s.scenarioCount === 0) {
175
+ out.push({ kind: 'do', command: design, because: 'no scenarios exist yet' });
176
+ return out;
177
+ }
178
+ if (s.kind === 'flow' && !s.hasFlowInventory) {
179
+ out.push({
180
+ kind: 'do',
181
+ command: `add \`flows:\` to requirements/flow-contract.yaml, then sungen audit --screen ${s.unit}`,
182
+ because: 'without the inventory, coverage is measured per PHASE — and a phase counts as covered as soon as one scenario carries it, so the number cannot tell you a flow is missing',
183
+ });
184
+ }
185
+ if (!s.audit) {
186
+ out.push({ kind: 'do', command: `sungen audit --screen ${s.unit}`, because: 'the suite has never been measured' });
187
+ return out;
188
+ }
189
+ if (s.audit.viewpointBaselineStatus === 'changed') {
190
+ out.push({
191
+ kind: 'do',
192
+ command: `sungen audit --screen ${s.unit} --accept-viewpoint`,
193
+ because: 'test-viewpoint.md changed since the accepted baseline, so the ledger and traceability axes are not evidence until you confirm the new declaration',
194
+ });
195
+ }
196
+ const repair = s.audit.findings
197
+ .map((f) => f.split(':')[0].trim())
198
+ .filter((code) => REPAIRABLE.includes(code));
199
+ if (repair.length > 0) {
200
+ out.push({
201
+ kind: 'do',
202
+ command: `repair, then sungen audit --screen ${s.unit}`,
203
+ because: `${repair.length} finding(s) name a specific repair: ${[...new Set(repair)].slice(0, 4).join(', ')}`,
204
+ });
205
+ }
206
+ if (s.audit.gateStatus === 'FAIL') {
207
+ out.push({
208
+ kind: 'blocked',
209
+ command: `repair the gate, then sungen audit --screen ${s.unit}`,
210
+ because: `the viewpoint gate is FAIL at ${s.audit.overall}/10 — running tests against a design that fails its own gate measures the wrong thing`,
211
+ });
212
+ return out;
213
+ }
214
+ if (s.kind !== 'api' && (!s.hasSelectors || s.selectorsArePlaceholder)) {
215
+ out.push({
216
+ kind: 'do',
217
+ command: `/sungen:run-test ${s.unit}`,
218
+ because: s.hasSelectors
219
+ ? 'the selector file is still a placeholder, so nothing resolves against a live page'
220
+ : 'no selectors exist yet — run-test generates them from the live page, compiles and auto-fixes',
221
+ });
222
+ return out;
223
+ }
224
+ if (!s.compiled) {
225
+ out.push({ kind: 'do', command: `sungen generate --${s.kind === 'flow' ? 'flow' : 'screen'} ${s.unit}`, because: 'the suite has not been compiled to a spec yet' });
226
+ return out;
227
+ }
228
+ if (!s.hasResults) {
229
+ out.push({ kind: 'do', command: `/sungen:run-test ${s.unit}`, because: 'the compiled spec has never been run, so every case is Pending' });
230
+ return out;
231
+ }
232
+ if (!s.hasDeliverable) {
233
+ out.push({ kind: 'do', command: `/sungen:delivery ${s.unit}`, because: 'results exist but have not been exported to the Test Case & Coverage Matrix' });
234
+ }
235
+ out.push({ kind: 'optional', command: `/sungen:create-test ${s.unit}`, because: 'extend coverage — the next tier, or the flows the inventory still lists as pending' });
236
+ return out;
237
+ }
@@ -202,3 +202,41 @@ export function specCoverage(specPath: string, scenarios: ScenarioInfo[], featur
202
202
 
203
203
  return { hasSpec: true, frTotal: frs.length, frCovered, uncoveredMust, inferredOnly, triggerGaps, verdict };
204
204
  }
205
+
206
+ /**
207
+ * A flow's requirement list that is a HAND RESTATEMENT of the screen specs it traverses.
208
+ *
209
+ * A flow spec typically cites its requirements as belonging elsewhere — "ST_AUTH_002 FR-001",
210
+ * "restated here in flow terms". That restatement is lossy by construction, and nothing checked
211
+ * it: on a real run the flow spec restated two of the screens' FRs, `specFR` read **2/2 = 100%**,
212
+ * and the guard clause the flow most needed (a double-submit rule in one of those screen specs)
213
+ * had never entered the system at all. The axis was certifying complete coverage of a
214
+ * hand-truncated universe — a worse failure than a missing scenario, because the number said the
215
+ * opposite.
216
+ *
217
+ * So: name the source documents the flow says its requirements come from, and say plainly which
218
+ * of them the project does not hold. Whether the restatement is complete is then a question
219
+ * someone can answer, instead of one nothing was asking.
220
+ */
221
+ export function restatedRequirementSources(specText: string, availableUnits: string[]): {
222
+ restated: boolean; sources: string[]; missing: string[];
223
+ } {
224
+ // "these ... originate in the SCREEN specs", "restated here", "per ST_AUTH_002 FR-001".
225
+ const restated = /\brestate[sd]?\b|\boriginate[sd]? in\b|\bderived from the (?:screen|per-screen) spec/i.test(specText);
226
+ // External document ids carrying their own requirement number: `ST_AUTH_002 FR-001`,
227
+ // `SCR-1-SYS-0001.FR-3`. Two+ segments and an uppercase head, so a bare `FR-001` (the flow's
228
+ // own) never matches.
229
+ // A citation may name SEVERAL documents at once — "ST_AUTH_002/ST_AUTH_004 FR-001" — so match
230
+ // the whole slash/comma-joined run and split it. Capturing only the token adjacent to the
231
+ // requirement number silently dropped every sibling.
232
+ const DOC = '[A-Z][A-Z0-9]*(?:[_-][A-Z0-9]+)+';
233
+ const cite = new RegExp(`\\b((?:${DOC})(?:\\s*[/,]\\s*(?:${DOC}))*)[\\s.]+(?:FR|NFR|BR)-\\d+`, 'g');
234
+ const sources = [...new Set(
235
+ [...specText.matchAll(cite)].flatMap((m) => m[1].split(/\s*[/,]\s*/)).map((x) => x.trim()).filter(Boolean),
236
+ )];
237
+ if (!restated && sources.length === 0) return { restated: false, sources: [], missing: [] };
238
+ const norm = (x: string): string => x.toLowerCase().replace(/[^a-z0-9]/g, '');
239
+ const have = availableUnits.map(norm);
240
+ const missing = sources.filter((src) => !have.some((u) => u.includes(norm(src)) || norm(src).includes(u)));
241
+ return { restated, sources, missing };
242
+ }
@@ -94,3 +94,45 @@ export function viewpointLedger(viewpointPath: string, scenarios: ScenarioInfo[]
94
94
 
95
95
  return { hasViewpoint: true, total: items.length, covered, ratio: items.length ? covered / items.length : 1, missing };
96
96
  }
97
+
98
+ /**
99
+ * A viewpoint item that names a BROWSER-LEVEL gesture, met by an in-app control instead.
100
+ *
101
+ * "Browser back button from Basic Info Confirmation to Basic Info — values must be restored"
102
+ * was answered with `User click [Confirmation:Back] button`. Those are different mechanisms: an
103
+ * app button runs the screen's own re-hydrate logic, browser history does not, and a defect
104
+ * that only appears on history navigation lives exactly in the gap. The ledger counted the item
105
+ * covered because the words matched, so the substitution was invisible (#595).
106
+ *
107
+ * The language has no reload/back step at all (#593), so the honest outcome is a `@manual`
108
+ * deferral naming the gesture — not a silent swap.
109
+ */
110
+ const BROWSER_GESTURES: Array<{ gesture: string; re: RegExp; appSubstitute: RegExp }> = [
111
+ { gesture: 'browser back', re: /\bbrowser(?:'s)?[ -]?(?:back|history)\b|\bhistory[ -]back\b/i,
112
+ appSubstitute: /\bclick \[[^\]]*back[^\]]*\]/i },
113
+ { gesture: 'browser reload/refresh', re: /\b(?:page |browser )?(?:reload|refresh)(?:ing|ed|es)?\b/i,
114
+ appSubstitute: /\bis on \[[^\]]+\] page\b/i },
115
+ { gesture: 'closing and reopening the tab', re: /\bclos(?:e|ing) (?:and reopen\w*\s*)?the tab\b|\breopen\w* the tab\b/i,
116
+ appSubstitute: /\bis on \[[^\]]+\] page\b/i },
117
+ ];
118
+
119
+ export function browserGestureSubstitutions(
120
+ viewpointPath: string, scenarios: ScenarioInfo[],
121
+ ): Array<{ gesture: string; item: string }> {
122
+ const out: Array<{ gesture: string; item: string }> = [];
123
+ // Judged PER SCENARIO, not over the whole file: a @manual scenario elsewhere that merely
124
+ // mentions the gesture in passing (explaining a tooling limit) silenced the check for an
125
+ // item that an automated scenario had quietly substituted.
126
+ const deferred = scenarios.filter((s) => s.manual).map((s) => s.haystack);
127
+ const automated = scenarios.filter((s) => !s.manual).map((s) => s.haystack);
128
+ for (const item of parseViewpointItems(viewpointPath)) {
129
+ for (const g of BROWSER_GESTURES) {
130
+ if (!g.re.test(item.text)) continue;
131
+ // A @manual scenario whose own subject IS the gesture is the honest answer.
132
+ if (deferred.some((h) => g.re.test(h))) break;
133
+ if (automated.some((h) => g.appSubstitute.test(h))) out.push({ gesture: g.gesture, item: item.text });
134
+ break;
135
+ }
136
+ }
137
+ return out;
138
+ }
@@ -9,12 +9,20 @@ codex-trigger: "Run when the user asks to CREATE, generate, write, or author tes
9
9
  ---
10
10
  ## ⛔ HARD RULE — the run's LAST action is the next-step hand-back
11
11
 
12
- A create-test run is NOT finished when the files are written or the audit prints. The final
13
- action of EVERY run — success, partial, or aborted — is the next-step hand-back
14
- ({{#cap parallel-subagents}}an `AskUserQuestion` offering the next actions{{/cap}}{{^cap parallel-subagents}}a numbered list of next-action choices{{/cap}};
15
- see "Finish always hand the next step back" at the end of this file). Ending with a prose
16
- summary and no choices is a broken run: the operator is left guessing. This holds no matter
17
- how long the generation/repair loop ran.
12
+ A run is NOT finished when its files are written or its output prints. The final action of EVERY
13
+ run — success, partial, or aborted — is the next-step hand-back.
14
+
15
+ **Ask `sungen next <unit>` rather than deciding from memory.** It derives the answer from what the
16
+ project actually holds (artifacts on disk, the audit report, the accepted viewpoint baseline, the
17
+ compiled spec, the run results), so the hand-back stays correct even when the run ended somewhere
18
+ unexpected — a failed install, a driver that turned out not to support this unit kind, an aborted
19
+ repair loop. Every `sungen` command also prints `Next: sungen next …` as its own last line.
20
+
21
+ This matters most exactly where it used to break. A run that ended on a command with no footer of
22
+ its own — `sungen capability add`, a dead-end install — left the session with nothing to follow and
23
+ it stopped mid-workflow. If a step turns out to be impossible, that is still a hand-back: say what
24
+ blocked it, run `sungen next <unit>`, and offer what remains. Ending with a prose summary and no
25
+ choices is a broken run.
18
26
 
19
27
  ---
20
28
  {{#cap parallel-subagents}}
@@ -42,8 +50,19 @@ plus **both** postconditions: `successGuarantee` (true when the goal is reached)
42
50
  `minimalGuarantee` (what must still hold when the journey FAILS). Exception flows assert against
43
51
  the second one; with no `minimalGuarantee` they can only prove "an error appeared"
44
52
  (`FLOW-GUARANTEE-MISSING`). Then declare `phases:` — `[BF, AF, EF]` for a use-case decomposition
45
- (the first phase is the Basic Flow) and enumerate flows with the step × risk matrix in the
46
- `sungen-tc-generation` skill.
53
+ (the first phase is the Basic Flow). Then enumerate the use case's flows with the step × risk
54
+ matrix in the `sungen-tc-generation` skill and **write them into the contract's `flows:`
55
+ inventory**, each with a `status:` (`covered` / `deferred` / `pending-clarification` /
56
+ `out-of-scope`) and a `reason:` for anything but `covered`. Without the inventory, coverage can
57
+ only be measured per phase — and a phase counts as covered as soon as ONE scenario carries it, so
58
+ a fifteen-flow use case scores 100% on three scenarios (`FLOW-INVENTORY-MISSING`, and the top mark
59
+ is withheld).
60
+
61
+ A risk the specs do NOT settle is `pending-clarification` with the question written down — never a
62
+ scenario asserting invented behaviour, and never a silent omission. If the flow's requirements are
63
+ restated from screen specs the project does not hold, say so: the audit reports
64
+ `SPEC-RESTATED-UNVERIFIED` because `specFR 100%` over a hand-copied list certifies the copy, not
65
+ the source.
47
66
 
48
67
  ---
49
68
 
@@ -27,6 +27,12 @@ inside the run, not reasons to stop and hand the work back.
27
27
  install command as the hand-back.
28
28
  - Ending this run follows the same law as create-test: the LAST action is the next-step
29
29
  hand-back (the AskUserQuestion in "After showing results"), no matter how the run went.
30
+ **Ask `sungen next <unit>` rather than deciding from memory** — it derives the answer from what
31
+ the project now holds, so the hand-back stays correct even when the run ended somewhere
32
+ unexpected (a failed install, a driver that does not support this unit kind, an aborted repair
33
+ loop). Every `sungen` command also prints `Next: sungen next …` as its own last line. A step
34
+ that turns out to be impossible is still a hand-back: say what blocked it, run
35
+ `sungen next <unit>`, and offer what remains.
30
36
 
31
37
  ---
32
38
 
@@ -690,6 +690,42 @@ core, AF→behaviour, EF→validation/security) and traces them to the viewpoint
690
690
  `FL-EF` priority rows. Declare `phases: [BF, AF, EF]` in the contract; the FIRST phase is the
691
691
  Basic Flow and is the one that must reach `outcome`.
692
692
 
693
+ **Declare the flow INVENTORY, not just the phases.** `phases: [BF, AF, EF]` says which
694
+ vocabulary the suite uses; it cannot say how many flows the use case has — a phase counts as
695
+ covered the moment ONE scenario carries it, so a fifteen-flow use case with three scenarios
696
+ reported full phase coverage while a reviewer counting flows read it as a third done. List every
697
+ flow in the contract, and give each one a status:
698
+
699
+ ```yaml
700
+ flows:
701
+ - id: BF
702
+ outcome: "The successGuarantee — verified, recorded, signed in"
703
+ status: covered
704
+ - id: AF02
705
+ branchFrom: "BF step 4, before submitting"
706
+ outcome: "Re-enters BF at step 4 with the buffer restored"
707
+ status: covered
708
+ - id: EF08
709
+ branchFrom: "BF step 1, the control tapped twice"
710
+ outcome: "One request, one record"
711
+ status: pending-clarification # the spec is silent — ASK, never assume a guard
712
+ reason: "The guard lives in ST_AUTH_001's spec, which this project does not hold."
713
+ ```
714
+
715
+ Four statuses, and **silence is not one of them**: `covered` · `deferred` · `pending-clarification`
716
+ (the behaviour is not agreed yet, so no scenario can be right) · `out-of-scope` (another suite owns
717
+ it). Anything but `covered` needs a `reason:` — a deferral nobody can audit is the same as a
718
+ missing flow. `sungen audit` then measures `flowCoverage` per DECLARED FLOW and reports
719
+ `FLOW-UNCOVERED` (declared, nobody wrote it), `FLOW-UNDECLARED` (a scenario claiming a flow id the
720
+ inventory never declares), `FLOW-STATUS-UNREASONED` and `FLOW-INVENTORY-MISSING`.
721
+
722
+ **One flow, one id — a viewpoint is not a flow.** Several scenarios may prove one flow: give them
723
+ the same flow id and different sequence numbers (`VP-VAL-EF01-001`, `VP-VAL-EF01-002`), never a
724
+ fresh flow id per assertion. Two shapes the audit reports as `FLOW-PHASE-MISFILED`: an `EF` that
725
+ reaches the outcome with nothing failing (that is a success-path postcondition, so it belongs to
726
+ the BF), and an `AF` with no branch point (it walks the basic path asserting extra content — also
727
+ the BF's). Both inflate the flow count while adding no branch coverage.
728
+
693
729
  **Declare once, then declare only the differences.** Actor · Trigger · Goal · Precondition ·
694
730
  `successGuarantee` · `minimalGuarantee` are use-case-level: they live in `flow-contract.yaml`
695
731
  and are never repeated per flow. Each flow in `test-viewpoint.md` then states only three things: