@sun-asterisk/sungen 3.2.2-beta.1 → 3.2.2-beta.11

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 (45) hide show
  1. package/dist/cli/commands/depth-lint.d.ts +3 -0
  2. package/dist/cli/commands/depth-lint.d.ts.map +1 -0
  3. package/dist/cli/commands/depth-lint.js +88 -0
  4. package/dist/cli/commands/depth-lint.js.map +1 -0
  5. package/dist/cli/commands/gate.d.ts +3 -0
  6. package/dist/cli/commands/gate.d.ts.map +1 -0
  7. package/dist/cli/commands/gate.js +83 -0
  8. package/dist/cli/commands/gate.js.map +1 -0
  9. package/dist/cli/commands/journey.d.ts +3 -0
  10. package/dist/cli/commands/journey.d.ts.map +1 -0
  11. package/dist/cli/commands/journey.js +96 -0
  12. package/dist/cli/commands/journey.js.map +1 -0
  13. package/dist/cli/index.js +6 -0
  14. package/dist/cli/index.js.map +1 -1
  15. package/dist/harness/depth-lint.d.ts +25 -0
  16. package/dist/harness/depth-lint.d.ts.map +1 -0
  17. package/dist/harness/depth-lint.js +118 -0
  18. package/dist/harness/depth-lint.js.map +1 -0
  19. package/dist/harness/journey.d.ts +68 -0
  20. package/dist/harness/journey.d.ts.map +1 -0
  21. package/dist/harness/journey.js +328 -0
  22. package/dist/harness/journey.js.map +1 -0
  23. package/dist/orchestrator/templates/ai-instructions/claude-cmd-create-test.md +7 -0
  24. package/dist/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +3 -0
  25. package/dist/orchestrator/templates/ai-instructions/claude-skill-error-mapping.md +22 -3
  26. package/dist/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +1 -0
  27. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-create-test.md +3 -0
  28. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +3 -0
  29. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-error-mapping.md +22 -3
  30. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +1 -0
  31. package/package.json +3 -3
  32. package/src/cli/commands/depth-lint.ts +51 -0
  33. package/src/cli/commands/gate.ts +44 -0
  34. package/src/cli/commands/journey.ts +59 -0
  35. package/src/cli/index.ts +6 -0
  36. package/src/harness/depth-lint.ts +122 -0
  37. package/src/harness/journey.ts +333 -0
  38. package/src/orchestrator/templates/ai-instructions/claude-cmd-create-test.md +7 -0
  39. package/src/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +3 -0
  40. package/src/orchestrator/templates/ai-instructions/claude-skill-error-mapping.md +22 -3
  41. package/src/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +1 -0
  42. package/src/orchestrator/templates/ai-instructions/copilot-cmd-create-test.md +3 -0
  43. package/src/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +3 -0
  44. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-error-mapping.md +22 -3
  45. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +1 -0
@@ -0,0 +1,51 @@
1
+ import { Command } from 'commander';
2
+ import * as path from 'path';
3
+ import * as fs from 'fs';
4
+ import { runDepthLint, renderDepthLint } from '../../harness/depth-lint';
5
+ import { reportSlug } from '../../harness/unit-paths';
6
+
7
+ function findScreenDir(name: string): string | null {
8
+ const candidates = [
9
+ path.join(process.cwd(), 'qa', 'screens', name),
10
+ path.join(process.cwd(), 'qa', 'flows', name),
11
+ path.join(process.cwd(), 'qa', 'api', name),
12
+ ];
13
+ for (const c of candidates) if (fs.existsSync(c)) return c;
14
+ return null;
15
+ }
16
+
17
+ export function registerDepthLintCommand(program: Command): void {
18
+ program
19
+ .command('depth-lint')
20
+ .description('Harness: classify shallow business-critical scenarios → deepen-in-place (with the data-assertion template) vs cross-screen (route to a flow). Generation-time depth self-check (#384).')
21
+ .option('-s, --screen <name>', 'Screen or flow name to lint')
22
+ .option('--json', 'Output the raw JSON report')
23
+ .action((options) => {
24
+ try {
25
+ const name = options.screen;
26
+ if (!name) throw new Error('Provide --screen <name>');
27
+ const dir = findScreenDir(name);
28
+ if (!dir) throw new Error(`Not found: qa/screens/${name} or qa/flows/${name}`);
29
+
30
+ const report = runDepthLint(dir, name);
31
+
32
+ const outDir = path.join(process.cwd(), '.sungen', 'reports');
33
+ fs.mkdirSync(outDir, { recursive: true });
34
+ const outPath = path.join(outDir, `${reportSlug(name)}-depth-lint.json`);
35
+ fs.writeFileSync(outPath, JSON.stringify(report, null, 2), 'utf-8');
36
+
37
+ if (options.json) {
38
+ console.log(JSON.stringify(report, null, 2));
39
+ } else {
40
+ renderDepthLint(report);
41
+ console.log(` Report: ${path.relative(process.cwd(), outPath)}`);
42
+ console.log('');
43
+ }
44
+ // Non-zero when there are deepen-in-place candidates the generator should fix before audit.
45
+ process.exit(report.deepen.length > 0 ? 2 : 0);
46
+ } catch (error) {
47
+ console.error('Error:', error instanceof Error ? error.message : error);
48
+ process.exit(1);
49
+ }
50
+ });
51
+ }
@@ -0,0 +1,44 @@
1
+ import { Command } from 'commander';
2
+ import * as path from 'path';
3
+ import * as fs from 'fs';
4
+ import { runGate, renderGate, GatePhase } from '../../harness/journey';
5
+
6
+ function findScreenDir(name: string): string | null {
7
+ const candidates = [
8
+ path.join(process.cwd(), 'qa', 'screens', name),
9
+ path.join(process.cwd(), 'qa', 'flows', name),
10
+ path.join(process.cwd(), 'qa', 'api', name),
11
+ ];
12
+ for (const c of candidates) if (fs.existsSync(c)) return c;
13
+ return null;
14
+ }
15
+
16
+ const PHASES: GatePhase[] = ['create', 'run', 'deliver'];
17
+
18
+ export function registerGateCommand(program: Command): void {
19
+ program
20
+ .command('gate')
21
+ .description('Inter-phase HALT gate (#381): a phase boundary passes only when its required obligations are satisfied or explicitly waived. Exit 2 = HALT (no silent bad output crosses the boundary).')
22
+ .option('-s, --screen <name>', 'Screen / flow / api unit name')
23
+ .option('-p, --phase <phase>', `Phase boundary: ${PHASES.join(' | ')}`)
24
+ .option('--json', 'Output the raw verdict')
25
+ .action((options) => {
26
+ try {
27
+ const name = options.screen;
28
+ if (!name) throw new Error('Provide --screen <name>');
29
+ const phase = options.phase as GatePhase;
30
+ if (!PHASES.includes(phase)) throw new Error(`Provide --phase <${PHASES.join('|')}>`);
31
+ if (!findScreenDir(name)) throw new Error(`Not found: qa/screens/${name}, qa/flows/${name}, or qa/api/${name}`);
32
+
33
+ const verdict = runGate(process.cwd(), name, phase);
34
+ if (options.json) console.log(JSON.stringify(verdict, null, 2));
35
+ else console.log(renderGate(verdict));
36
+
37
+ // Exit 2 on HALT — usable in CI / the orchestration loop to block the next phase.
38
+ process.exit(verdict.status === 'halt' ? 2 : 0);
39
+ } catch (error) {
40
+ console.error('Error:', error instanceof Error ? error.message : error);
41
+ process.exit(1);
42
+ }
43
+ });
44
+ }
@@ -0,0 +1,59 @@
1
+ import { Command } from 'commander';
2
+ import * as path from 'path';
3
+ import * as fs from 'fs';
4
+ import { runJourney, waive, signoff, renderJourneyBoard } from '../../harness/journey';
5
+ import { reportSlug } from '../../harness/unit-paths';
6
+
7
+ function findScreenDir(name: string): string | null {
8
+ const candidates = [
9
+ path.join(process.cwd(), 'qa', 'screens', name),
10
+ path.join(process.cwd(), 'qa', 'flows', name),
11
+ path.join(process.cwd(), 'qa', 'api', name),
12
+ ];
13
+ for (const c of candidates) if (fs.existsSync(c)) return c;
14
+ return null;
15
+ }
16
+
17
+ export function registerJourneyCommand(program: Command): void {
18
+ program
19
+ .command('journey')
20
+ .description('Durable "you are here" board (#381): obligations + what-to-review + next, synthesised read-only from the audit report + ledger already on disk.')
21
+ .option('-s, --screen <name>', 'Screen / flow / api unit name')
22
+ .option('--waive <obligation>', 'Waive an obligation (e.g. OB-coverage) — requires --reason')
23
+ .option('--reason <text>', 'The reason a waived obligation is acceptable (mandatory with --waive)')
24
+ .option('--signoff', 'Sign off the review queue — the single human gate (allowed only when every other obligation is satisfied/waived)')
25
+ .option('--by <name>', 'Who is signing off (recorded with --signoff)')
26
+ .option('--json', 'Output the raw JSON report')
27
+ .action((options) => {
28
+ try {
29
+ const name = options.screen;
30
+ if (!name) throw new Error('Provide --screen <name>');
31
+ if (!findScreenDir(name)) throw new Error(`Not found: qa/screens/${name}, qa/flows/${name}, or qa/api/${name}`);
32
+
33
+ const report = options.waive
34
+ ? waive(process.cwd(), name, options.waive, options.reason || '')
35
+ : options.signoff
36
+ ? signoff(process.cwd(), name, options.by)
37
+ : runJourney(process.cwd(), name);
38
+
39
+ const outDir = path.join(process.cwd(), '.sungen', 'journey');
40
+ fs.mkdirSync(outDir, { recursive: true });
41
+ const slug = reportSlug(name);
42
+ fs.writeFileSync(path.join(outDir, `${slug}.json`), JSON.stringify(report, null, 2), 'utf-8');
43
+ const board = renderJourneyBoard(report);
44
+ fs.writeFileSync(path.join(outDir, `${slug}.board.md`), board, 'utf-8');
45
+
46
+ if (options.json) {
47
+ console.log(JSON.stringify(report, null, 2));
48
+ } else {
49
+ console.log('');
50
+ console.log(board);
51
+ console.log(` Board: ${path.relative(process.cwd(), path.join(outDir, `${slug}.board.md`))}`);
52
+ console.log('');
53
+ }
54
+ } catch (error) {
55
+ console.error('Error:', error instanceof Error ? error.message : error);
56
+ process.exit(1);
57
+ }
58
+ });
59
+ }
package/src/cli/index.ts CHANGED
@@ -15,6 +15,9 @@ import { registerFigmaCommand } from './commands/figma';
15
15
  import { registerAddFlowCommand } from './commands/add-flow';
16
16
  import { registerDashboardCommand } from './commands/dashboard';
17
17
  import { registerAuditCommand } from './commands/audit';
18
+ import { registerDepthLintCommand } from './commands/depth-lint';
19
+ import { registerJourneyCommand } from './commands/journey';
20
+ import { registerGateCommand } from './commands/gate';
18
21
  import { registerIngestCommand } from './commands/ingest';
19
22
  import { registerEvalCommand } from './commands/eval';
20
23
  import { registerManifestCommand } from './commands/manifest';
@@ -57,6 +60,9 @@ async function main() {
57
60
  registerAddFlowCommand(program);
58
61
  registerDashboardCommand(program);
59
62
  registerAuditCommand(program);
63
+ registerDepthLintCommand(program);
64
+ registerJourneyCommand(program);
65
+ registerGateCommand(program);
60
66
  registerManifestCommand(program);
61
67
  registerLedgerCommand(program);
62
68
  registerFeedbackCommand(program);
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Depth lint (issue #384) — a deterministic, generation-time depth self-check.
3
+ *
4
+ * The audit's `assertionDepth` sensor decides WHICH business-critical scenarios are shallow
5
+ * (the authoritative set). This lint adds the missing half: for each shallow business-critical
6
+ * scenario it classifies the *fix* using the catalog's per-theme `depth` metadata —
7
+ * • cross_screen:false → DEEPEN in place (emit the theme's `depth.template` value assertion)
8
+ * • cross_screen:true → DEFER (flow-own, or @manual:Mx with a reason) — leaves the depth denominator
9
+ * so a generator (or the create-test repair step) can act mechanically BEFORE the first audit,
10
+ * instead of churning the 3-round repair budget on scenarios that can't be deepened on-screen.
11
+ *
12
+ * Reuses the audit plumbing verbatim (parse + catalog + assertionDepth) → same verdict as `sungen audit`.
13
+ */
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+ import { loadScenarios, parseViewpointOverview, ScenarioInfo, ViewpointEntry } from './parse';
17
+ import { loadCatalog, viewpointGate, assertionDepth, dataThemesFor, CatalogTheme } from './sensors';
18
+
19
+ export type DepthAction = 'deepen' | 'defer';
20
+
21
+ export interface DepthLintItem {
22
+ scenario: string;
23
+ theme: string | null;
24
+ action: DepthAction;
25
+ /** the exact deep step to emit (deepen) or the routing hint (defer) */
26
+ fix: string;
27
+ }
28
+
29
+ export interface DepthLintReport {
30
+ screen: string;
31
+ pageType: string | null;
32
+ focus: string;
33
+ threshold: number;
34
+ bcDepthRatio: number;
35
+ verdict: 'pass' | 'warn' | 'fail';
36
+ businessCriticalTotal: number;
37
+ shallowTotal: number;
38
+ /** shallow business-critical scenarios that CAN be deepened on-screen (actionable now) */
39
+ deepen: DepthLintItem[];
40
+ /** shallow business-critical scenarios that are cross-screen → route to a flow / @manual */
41
+ defer: DepthLintItem[];
42
+ }
43
+
44
+ /** Find the data-theme a scenario belongs to (precise depth.keywords, fallback theme.keywords). */
45
+ function matchTheme(s: ScenarioInfo, dataThemes: CatalogTheme[]): CatalogTheme | undefined {
46
+ return dataThemes.find((t) => {
47
+ const kws = t.depth?.keywords?.length ? t.depth.keywords : t.keywords;
48
+ return kws.some((k) => s.haystack.includes(k.toLowerCase()));
49
+ });
50
+ }
51
+
52
+ export function runDepthLint(screenDir: string, screenName: string, focus = 'functional'): DepthLintReport {
53
+ const last = screenName.split('/').pop() || screenName;
54
+ const featurePath = path.join(screenDir, 'features', `${last}.feature`);
55
+ const viewpointPath = path.join(screenDir, 'requirements', 'test-viewpoint.md');
56
+
57
+ const scenarios: ScenarioInfo[] = loadScenarios(featurePath);
58
+ const viewpoints: ViewpointEntry[] = parseViewpointOverview(viewpointPath);
59
+ const catalog = loadCatalog();
60
+ const gate = viewpointGate(scenarios, viewpoints, catalog);
61
+ const dataThemes = dataThemesFor(catalog, gate.pageType);
62
+ const depth = assertionDepth(scenarios, dataThemes, focus);
63
+
64
+ const byName = new Map(scenarios.map((s) => [s.name, s]));
65
+ const deepen: DepthLintItem[] = [];
66
+ const defer: DepthLintItem[] = [];
67
+
68
+ for (const sb of depth.shallowBusinessCritical) {
69
+ const s = byName.get(sb.name);
70
+ const theme = s ? matchTheme(s, dataThemes) : undefined;
71
+ const crossScreen = theme?.depth?.cross_screen ?? false;
72
+ if (crossScreen) {
73
+ defer.push({
74
+ scenario: sb.name,
75
+ theme: theme?.theme ?? null,
76
+ action: 'defer',
77
+ fix: `cross-screen — own it in a flow (sungen add-flow) or tag @manual:Mx with a reason; do not fake an on-screen data assertion`,
78
+ });
79
+ } else {
80
+ deepen.push({
81
+ scenario: sb.name,
82
+ theme: theme?.theme ?? null,
83
+ action: 'deepen',
84
+ fix: theme?.depth?.template ?? `add a data assertion (\`... with {{value}}\` or \`see all ... contain {{v}}\`)`,
85
+ });
86
+ }
87
+ }
88
+
89
+ return {
90
+ screen: screenName,
91
+ pageType: gate.pageType,
92
+ focus,
93
+ threshold: depth.threshold,
94
+ bcDepthRatio: depth.bcDepthRatio,
95
+ verdict: depth.verdict,
96
+ businessCriticalTotal: depth.businessCriticalTotal,
97
+ shallowTotal: depth.businessCriticalShallow,
98
+ deepen,
99
+ defer,
100
+ };
101
+ }
102
+
103
+ export function renderDepthLint(r: DepthLintReport): void {
104
+ const pct = (n: number) => `${Math.round(n * 100)}%`;
105
+ console.log('');
106
+ console.log(`━━━ Depth lint: ${r.screen} (page-type ${r.pageType ?? 'unknown'}) ━━━`);
107
+ console.log('');
108
+ console.log(` businessDepth ${pct(r.bcDepthRatio)} (threshold ${pct(r.threshold)} · focus ${r.focus}) → ${r.verdict.toUpperCase()}`);
109
+ console.log(` ${r.businessCriticalTotal} business-critical · ${r.shallowTotal} shallow → ${r.deepen.length} deepen-in-place · ${r.defer.length} cross-screen`);
110
+ if (r.deepen.length) {
111
+ console.log('');
112
+ console.log(' ── DEEPEN IN PLACE (fix before audit) ──');
113
+ for (const d of r.deepen) console.log(` • ${d.scenario}\n [${d.theme}] → ${d.fix}`);
114
+ }
115
+ if (r.defer.length) {
116
+ console.log('');
117
+ console.log(' ── CROSS-SCREEN (route to flow / @manual:Mx) ──');
118
+ for (const d of r.defer) console.log(` • ${d.scenario} [${d.theme}]`);
119
+ }
120
+ if (!r.deepen.length && !r.defer.length) console.log(' ✓ no shallow business-critical scenarios');
121
+ console.log('');
122
+ }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Journey board (epic #381, story S1) — the durable, read-only "you are here" view.
3
+ *
4
+ * Reconstructs the QA's position from artifacts ALREADY on disk (no recomputation, no context
5
+ * needed): the audit report (`.sungen/reports/<slug>-audit.json`) supplies the obligation status
6
+ * via its calibration axes + gate + findings; the ledger (`.sungen/ledger/<slug>.jsonl`) supplies
7
+ * the phase history ("you are here"). The output answers the three QA questions — what's next /
8
+ * what to review / what's doubtful — and persists `.sungen/journey/<slug>.{json,board.md}`.
9
+ *
10
+ * S1 = the read-only synthesis. S2 (this file) adds the **writable lifecycle**: persisted
11
+ * waivers (reason-required, anti-amnesia), reconcile (auto-close satisfied; re-surface a waiver
12
+ * when its evidence changed), via `runJourney` + `waive`. Gate-bound predicates + inter-phase
13
+ * gates are S3. Pure-deterministic, no AI.
14
+ */
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import * as crypto from 'crypto';
18
+ import { reportSlug } from './unit-paths';
19
+
20
+ export type ObStatus = 'satisfied' | 'needs-work' | 'pending' | 'waived';
21
+
22
+ export interface Obligation {
23
+ id: string;
24
+ title: string;
25
+ status: ObStatus;
26
+ detail: string;
27
+ waivedReason?: string; // S2 — set when the QA explicitly waived this obligation
28
+ }
29
+
30
+ /** S5 — a parsed anchor so an IDE can jump straight to what needs review. */
31
+ export interface Anchor { vpId?: string; file?: string; line?: number; }
32
+ export interface ReviewItem { text: string; anchor: Anchor; }
33
+
34
+ export interface JourneyReport {
35
+ unit: string;
36
+ generatedFrom: string[]; // which artifacts were available
37
+ youAreHere: string; // last ledger phase, or 'not started'
38
+ phasesDone: string[];
39
+ gateStatus: string | null;
40
+ score: number | null;
41
+ obligations: Obligation[];
42
+ needsYou: ReviewItem[]; // S5 — the review queue: human-judgment findings, each with an anchor
43
+ nextSuggested: string;
44
+ }
45
+
46
+ function readJSON(p: string): any {
47
+ try { return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf-8')) : null; } catch { return null; }
48
+ }
49
+
50
+ function readLedgerPhases(p: string): string[] {
51
+ if (!fs.existsSync(p)) return [];
52
+ const out: string[] = [];
53
+ for (const line of fs.readFileSync(p, 'utf-8').split('\n')) {
54
+ if (!line.trim()) continue;
55
+ try { const d = JSON.parse(line); if (d.step) out.push(String(d.step)); } catch { /* skip */ }
56
+ }
57
+ return out;
58
+ }
59
+
60
+ /** Findings that ask for HUMAN judgment (the review-queue seed), vs pure machine-repair targets. */
61
+ function isHumanFinding(f: string): boolean {
62
+ return /@manual|MANUAL-|DEPTH-DEFERRED|UNSOURCEABLE|CAPABILITY-SUGGESTION|judgment|oracle|review/i.test(f);
63
+ }
64
+
65
+ /** S5 — pull an IDE-jumpable anchor out of a finding (VP id, scenario, or `file.ext:line`). */
66
+ function extractAnchor(text: string): Anchor {
67
+ const vp = text.match(/\bVP-[A-Z0-9-]+\b/);
68
+ const fl = text.match(/([\w./-]+\.(?:md|feature|ts|yaml)):(\d+)/);
69
+ return { vpId: vp ? vp[0] : undefined, file: fl ? fl[1] : undefined, line: fl ? Number(fl[2]) : undefined };
70
+ }
71
+
72
+ const SAT = 0.8; // axis at/above this = satisfied (below = needs-work)
73
+
74
+ function computeFresh(projectRoot: string, unit: string): JourneyReport {
75
+ const slug = reportSlug(unit);
76
+ const audit = readJSON(path.join(projectRoot, '.sungen', 'reports', `${slug}-audit.json`));
77
+ const phases = readLedgerPhases(path.join(projectRoot, '.sungen', 'ledger', `${slug}.jsonl`));
78
+
79
+ const from: string[] = [];
80
+ if (audit) from.push('audit');
81
+ if (phases.length) from.push('ledger');
82
+
83
+ const youAreHere = phases.length ? phases[phases.length - 1] : 'not started';
84
+ const ran = (p: string) => phases.some((s) => s === p || s.startsWith(p));
85
+
86
+ const obligations: Obligation[] = [];
87
+ const needsYou: ReviewItem[] = [];
88
+
89
+ if (!audit) {
90
+ // Nothing measured yet — the journey hasn't really begun.
91
+ obligations.push({ id: 'OB-create', title: 'Generate test cases', status: 'pending', detail: 'No audit report yet — run /sungen:create-test.' });
92
+ return {
93
+ unit, generatedFrom: from, youAreHere, phasesDone: phases, gateStatus: null, score: null,
94
+ obligations, needsYou, nextSuggested: 'Run `/sungen:create-test ' + unit + '` to begin.',
95
+ };
96
+ }
97
+
98
+ const ax = (audit.calibration && audit.calibration.axes) || {};
99
+ const depthThreshold = (audit.depth && audit.depth.threshold) || SAT;
100
+ const ob = (id: string, title: string, val: number | undefined, thr: number, detail: string): Obligation => ({
101
+ id, title,
102
+ status: val === undefined ? 'pending' : val >= thr ? 'satisfied' : 'needs-work',
103
+ detail: val === undefined ? detail : `${Math.round(val * 100)}% (need ${Math.round(thr * 100)}%) — ${detail}`,
104
+ });
105
+
106
+ obligations.push(ob('OB-spec', 'Spec readiness', ax.specFR, 1, 'FR/sections sufficient to design from'));
107
+ obligations.push(ob('OB-coverage', 'Viewpoint coverage', ax.coverage, SAT, 'critical themes covered'));
108
+ obligations.push(ob('OB-depth', 'Assertion depth', ax.businessDepth, depthThreshold, 'business-critical scenarios assert data'));
109
+ obligations.push(ob('OB-trace', 'Traceability', ax.taxonomy ?? ax.traceability, SAT, 'scenarios trace to viewpoints'));
110
+
111
+ // Automation obligation: automatable @manual still pending a driver = needs-work.
112
+ const autoPending = audit.automatableManual && audit.automatableManual.automatable > 0;
113
+ obligations.push({
114
+ id: 'OB-automation', title: 'Automation coverage',
115
+ status: autoPending ? 'needs-work' : 'satisfied',
116
+ detail: autoPending ? `${audit.automatableManual.automatable} @manual scenario(s) a driver could automate` : 'no capability-pending automatable cases',
117
+ });
118
+
119
+ // Human sign-off: always pending until S5 explicit signoff; carries the review-queue count.
120
+ const manualCount = (audit.manualOracle && audit.manualOracle.total) || 0;
121
+ obligations.push({
122
+ id: 'OB-signoff', title: 'Human sign-off', status: 'pending',
123
+ detail: `${needsYou.length} item(s) queued for your review (single sign-off at the end)`,
124
+ });
125
+
126
+ // Needs-you: human-judgment findings (the review-queue seed), with their anchors (titles) intact.
127
+ for (const f of (audit.findings || [])) if (isHumanFinding(f)) needsYou.push({ text: f, anchor: extractAnchor(f) });
128
+ // Re-stamp the signoff detail now that needsYou is filled.
129
+ const signoff = obligations.find((o) => o.id === 'OB-signoff');
130
+ if (signoff) signoff.detail = `${needsYou.length} item(s) queued for your review · ${manualCount} @manual scenario(s)`;
131
+
132
+ // Next suggested = first non-satisfied obligation; if all satisfied but run-test not done → run.
133
+ const firstGap = obligations.find((o) => o.status !== 'satisfied' && o.id !== 'OB-signoff');
134
+ let nextSuggested: string;
135
+ if (firstGap) nextSuggested = `Repair "${firstGap.title}" (${firstGap.detail}).`;
136
+ else if (!ran('run')) nextSuggested = `Quality satisfied — run \`/sungen:run-test ${unit}\`.`;
137
+ else nextSuggested = `All obligations satisfied — review the ${needsYou.length} queued item(s), then sign off & deliver.`;
138
+
139
+ return {
140
+ unit, generatedFrom: from, youAreHere, phasesDone: phases,
141
+ gateStatus: audit.gateStatus ?? null, score: (audit.score && audit.score.overall) ?? null,
142
+ obligations, needsYou, nextSuggested,
143
+ };
144
+ }
145
+
146
+ // ---------------- S2: writable lifecycle — persisted waivers + reconcile ----------------
147
+
148
+ interface Waiver { reason: string; at: string; auditHashAtWaive: string; }
149
+ interface Signoff { by?: string; at: string; auditHashAtSignoff: string; } // S5
150
+ interface JourneyState { unit: string; auditHash: string; waivers: Record<string, Waiver>; signoff?: Signoff; }
151
+
152
+ function statePath(projectRoot: string, slug: string): string {
153
+ return path.join(projectRoot, '.sungen', 'journey', `${slug}.state.json`);
154
+ }
155
+ /** Evidence cursor: the audit report's content hash. A waiver is invalidated when this changes. */
156
+ function auditHashOf(projectRoot: string, slug: string): string {
157
+ const p = path.join(projectRoot, '.sungen', 'reports', `${slug}-audit.json`);
158
+ return fs.existsSync(p) ? crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex') : '';
159
+ }
160
+ function loadState(p: string): JourneyState | null { return readJSON(p); }
161
+ function saveState(p: string, s: JourneyState): void {
162
+ fs.mkdirSync(path.dirname(p), { recursive: true });
163
+ fs.writeFileSync(p, JSON.stringify(s, null, 2), 'utf-8');
164
+ }
165
+
166
+ /** Recompute nextSuggested AFTER waivers are applied (a waived obligation is not a gap). */
167
+ function computeNext(r: JourneyReport, unit: string): string {
168
+ const gap = r.obligations.find((o) => o.status !== 'satisfied' && o.status !== 'waived' && o.id !== 'OB-signoff');
169
+ if (gap) return `Repair "${gap.title}" (${gap.detail}).`;
170
+ if (!r.phasesDone.some((s) => s === 'run' || s.startsWith('run'))) return `Quality satisfied — run \`/sungen:run-test ${unit}\`.`;
171
+ return `All obligations satisfied/waived — review the ${r.needsYou.length} queued item(s), then sign off & deliver.`;
172
+ }
173
+
174
+ /**
175
+ * The public entry: compute fresh, then RECONCILE with the persisted state —
176
+ * - auto-close is automatic (fresh recompute reflects the current artifacts);
177
+ * - an active waiver (evidence unchanged) sets status='waived' (carries the reason);
178
+ * - a STALE waiver (audit changed since it was waived) is re-surfaced for re-decision (anti-amnesia).
179
+ * Then persist the current evidence cursor.
180
+ */
181
+ export function runJourney(projectRoot: string, unit: string): JourneyReport {
182
+ const slug = reportSlug(unit);
183
+ const report = computeFresh(projectRoot, unit);
184
+ const sp = statePath(projectRoot, slug);
185
+ const state = loadState(sp) || { unit, auditHash: '', waivers: {} };
186
+ const curHash = auditHashOf(projectRoot, slug);
187
+
188
+ for (const ob of report.obligations) {
189
+ const w = state.waivers[ob.id];
190
+ if (!w) continue;
191
+ if (w.auditHashAtWaive === curHash) {
192
+ ob.status = 'waived';
193
+ ob.waivedReason = w.reason;
194
+ ob.detail = `waived — ${w.reason}`;
195
+ } else {
196
+ report.needsYou.unshift({ text: `⚠️ Waiver on "${ob.title}" is STALE (evidence changed since ${w.at}) — re-decide. Was: ${w.reason}`, anchor: { vpId: ob.id } });
197
+ }
198
+ }
199
+ // S5 — sign-off reconcile: a recorded sign-off satisfies OB-signoff while its evidence holds;
200
+ // if the audit changed since, the sign-off is STALE → re-surface for re-review (anti-amnesia).
201
+ const signoffOb = report.obligations.find((o) => o.id === 'OB-signoff');
202
+ if (signoffOb && state.signoff) {
203
+ if (state.signoff.auditHashAtSignoff === curHash) {
204
+ signoffOb.status = 'satisfied';
205
+ signoffOb.detail = `signed off${state.signoff.by ? ' by ' + state.signoff.by : ''} at ${state.signoff.at}`;
206
+ } else {
207
+ report.needsYou.unshift({ text: `⚠️ Sign-off is STALE (suite changed since ${state.signoff.at}) — re-review and sign off again.`, anchor: { vpId: 'OB-signoff' } });
208
+ }
209
+ }
210
+ report.nextSuggested = computeNext(report, unit);
211
+ saveState(sp, { unit, auditHash: curHash, waivers: state.waivers, signoff: state.signoff });
212
+ return report;
213
+ }
214
+
215
+ /**
216
+ * Waive an obligation — REQUIRES a reason (anti-amnesia: a waiver leaves a recorded "why").
217
+ * Records the current evidence cursor so reconcile can invalidate it if the audit changes.
218
+ */
219
+ export function waive(projectRoot: string, unit: string, obId: string, reason: string): JourneyReport {
220
+ if (!reason || !reason.trim()) {
221
+ throw new Error('A reason is required to waive (anti-amnesia: a waiver must record WHY). Use --reason "...".');
222
+ }
223
+ const slug = reportSlug(unit);
224
+ const fresh = computeFresh(projectRoot, unit);
225
+ const valid = fresh.obligations.map((o) => o.id);
226
+ if (!valid.includes(obId)) {
227
+ throw new Error(`Unknown obligation "${obId}". Valid: ${valid.join(', ')}`);
228
+ }
229
+ const sp = statePath(projectRoot, slug);
230
+ const state = loadState(sp) || { unit, auditHash: '', waivers: {} };
231
+ state.waivers[obId] = { reason: reason.trim(), at: new Date().toISOString(), auditHashAtWaive: auditHashOf(projectRoot, slug) };
232
+ saveState(sp, state);
233
+ return runJourney(projectRoot, unit);
234
+ }
235
+
236
+ /**
237
+ * Sign off — the single human gate (S5). Allowed ONLY when every other deliver-required
238
+ * obligation is satisfied or waived (you cannot sign off an unclean suite). Records who/when +
239
+ * the evidence cursor; reconcile invalidates it (stale → re-review) if the suite later changes.
240
+ */
241
+ export function signoff(projectRoot: string, unit: string, by?: string): JourneyReport {
242
+ const before = runJourney(projectRoot, unit);
243
+ const blockers = before.obligations.filter((o) => o.id !== 'OB-signoff' && o.status !== 'satisfied' && o.status !== 'waived');
244
+ if (blockers.length) {
245
+ throw new Error(`Cannot sign off — still open: ${blockers.map((b) => b.id).join(', ')}. Fix, or waive each with a reason, first.`);
246
+ }
247
+ const slug = reportSlug(unit);
248
+ const sp = statePath(projectRoot, slug);
249
+ const state = loadState(sp) || { unit, auditHash: '', waivers: {} };
250
+ state.signoff = { by, at: new Date().toISOString(), auditHashAtSignoff: auditHashOf(projectRoot, slug) };
251
+ saveState(sp, state);
252
+ return runJourney(projectRoot, unit);
253
+ }
254
+
255
+ const ICON: Record<ObStatus, string> = { satisfied: '✅', 'needs-work': '⚠️ ', pending: '⏳', waived: '🚫' };
256
+
257
+ export function renderJourneyBoard(r: JourneyReport): string {
258
+ const L: string[] = [];
259
+ L.push(`# Journey — ${r.unit}`);
260
+ L.push('');
261
+ L.push(`📍 **You are here:** ${r.youAreHere}` + (r.phasesDone.length ? ` (phases: ${r.phasesDone.join(' → ')})` : ''));
262
+ if (r.gateStatus) L.push(` gate: **${r.gateStatus}** · score: ${r.score ?? '—'}/10`);
263
+ L.push('');
264
+ L.push('## What must still be true');
265
+ for (const o of r.obligations) L.push(`- ${ICON[o.status]} **${o.title}** — ${o.detail}`);
266
+ L.push('');
267
+ L.push(`## 🔎 Review queue — needs your judgment (${r.needsYou.length})`);
268
+ if (r.needsYou.length) {
269
+ for (const it of r.needsYou.slice(0, 20)) {
270
+ const a = it.anchor;
271
+ const tag = [a.vpId, a.file && `${a.file}${a.line ? ':' + a.line : ''}`].filter(Boolean).join(' · ');
272
+ L.push(`- ${it.text}${tag ? ` 〔${tag}〕` : ''}`);
273
+ }
274
+ } else L.push('- (nothing queued)');
275
+ L.push('');
276
+ L.push(`## → Next`);
277
+ L.push(`${r.nextSuggested}`);
278
+ L.push('');
279
+ return L.join('\n');
280
+ }
281
+
282
+ // ---------------- S3: inter-phase gate — obligations as HALT predicates (#398) ----------------
283
+ //
284
+ // A phase boundary is a deterministic gate: before the next phase may run, this phase's required
285
+ // obligations must each be `satisfied` OR `waived` (S2 — an explicit, reasoned human acceptance).
286
+ // A required obligation still `needs-work`/`pending` is a BLOCKER → HALT (no silent bad output
287
+ // crosses the boundary, §9). Reuses runJourney → obligations already reflect waivers/reconcile.
288
+
289
+ export type GatePhase = 'create' | 'run' | 'deliver';
290
+
291
+ export interface GateVerdict {
292
+ unit: string;
293
+ phase: GatePhase;
294
+ status: 'pass' | 'halt';
295
+ required: string[];
296
+ blockers: { id: string; title: string; detail: string }[];
297
+ waivedCredit: { id: string; title: string }[]; // required obligations accepted via an explicit waiver
298
+ }
299
+
300
+ const PHASE_REQUIRED: Record<GatePhase, string[]> = {
301
+ // post-create (design quality): spec + coverage + depth + traceability must hold.
302
+ create: ['OB-spec', 'OB-coverage', 'OB-depth', 'OB-trace'],
303
+ // post-run: the design gates + automation coverage.
304
+ run: ['OB-spec', 'OB-coverage', 'OB-depth', 'OB-trace', 'OB-automation'],
305
+ // pre-delivery: everything automated PLUS the single human sign-off (S5).
306
+ deliver: ['OB-spec', 'OB-coverage', 'OB-depth', 'OB-trace', 'OB-automation', 'OB-signoff'],
307
+ };
308
+
309
+ export function runGate(projectRoot: string, unit: string, phase: GatePhase): GateVerdict {
310
+ const r = runJourney(projectRoot, unit);
311
+ const required = PHASE_REQUIRED[phase];
312
+ const reqObs = r.obligations.filter((o) => required.includes(o.id));
313
+ const blockers = reqObs
314
+ .filter((o) => o.status !== 'satisfied' && o.status !== 'waived')
315
+ .map((o) => ({ id: o.id, title: o.title, detail: o.detail }));
316
+ const waivedCredit = reqObs.filter((o) => o.status === 'waived').map((o) => ({ id: o.id, title: o.title }));
317
+ return { unit, phase, status: blockers.length ? 'halt' : 'pass', required, blockers, waivedCredit };
318
+ }
319
+
320
+ export function renderGate(v: GateVerdict): string {
321
+ const L: string[] = [];
322
+ L.push('');
323
+ L.push(`━━━ Gate: ${v.unit} @ phase "${v.phase}" → ${v.status === 'pass' ? '✅ PASS' : '⛔ HALT'} ━━━`);
324
+ if (v.blockers.length) {
325
+ L.push(' Blocking obligations (must be satisfied or explicitly waived):');
326
+ for (const b of v.blockers) L.push(` • ${b.id} ${b.title} — ${b.detail}`);
327
+ L.push(' → Self-correct (repair / run-test), or `sungen journey --screen ' + v.unit + ' --waive <OB> --reason "..."` if accepted.');
328
+ } else {
329
+ L.push(' All required obligations satisfied' + (v.waivedCredit.length ? ` (${v.waivedCredit.length} accepted via waiver)` : '') + '.');
330
+ }
331
+ L.push('');
332
+ return L.join('\n');
333
+ }
@@ -77,6 +77,7 @@ If the unit is **api-first** (`qa/api/<name>/` or `qa/api/flows/<name>/`), the d
77
77
  - one **viewpoint theme** per shard — a `VP-` prefix from the viewpoint overview (`VP-SEC`, `VP-ERROR-EMPTY-STATE`, `VP-CAROUSEL`, …) — preferred when the viewpoint overview is rich (test-2/home had 47 items across many themes); **or**
78
78
  - one **`spec.md` section** per shard (the Mapping Contract walk, Table 1) — preferred when generating from spec.
79
79
  Each shard owns a disjoint `VP-` prefix ⇒ ids never collide. One shard → skip to 5c (no fan-out gain).
80
+ - **Budget-adaptive shard size (S4).** Size the fan-out to your context budget: `N = clamp(ceil(viewpoint_items / items_per_shard), 1, min(16, cores-2))`, where `items_per_shard` is **larger on a ~1M budget** (fewer, bigger shards; more held inline) and **smaller on a ~200k "Claude Standards" budget** (more, tighter shards + aggressive offload). The orchestrator keeps **only the compact summary each generator returns** (pointers to its fragment files) — never the raw fragments in-context. Each generator sees **only its slice** — its theme/section + the **one** matching `sungen-viewpoint` group + the relevant `spec.md` section(s); never load the other groups or the whole spec (lazy = context-cheap). If the budget is too tight even for one shard, **fall back to the sequential path (5d)** — same output, just slower; never fail for lack of budget.
80
81
 
81
82
  **5b. Parallel fan-out (Claude Code).** Spawn one **`sungen-generator`** sub-agent **per shard** (Task tool, `subagent_type: sungen-generator`) — issue all the Task calls **in a single message** so they run concurrently. Pass each: its shard (theme/section) + viewpoint slice, the **`sungen-discovery` report** (Step 3), only the `spec.md` section(s) it maps to, which one `sungen-viewpoint` group file holds its patterns, the unit (screen/flow) + name + tier, and its fragment paths `.sungen/fragments/<name>/<shard>.{feature,test-data.yaml}`. Each writes a **headerless** fragment + a test-data fragment and returns a compact summary. Small fragments also keep every generator under the output-token cap (the reason the single-pass path writes incrementally).
82
83
 
@@ -89,6 +90,11 @@ If the unit is **api-first** (`qa/api/<name>/` or `qa/api/flows/<name>/`), the d
89
90
 
90
91
  **5d. Sequential fallback.** Use the single-context incremental path (Step 2: tier-by-tier `Write`/`Edit` batches) when: only **one** shard applies, **Copilot / no sub-agents**, or a constrained setup. Same output, just no speedup. **For flows**: `[Screen:Element]` namespace refs, test-data namespaced by phase, `@flow` tag.
91
92
 
93
+ 5.4. **Depth self-check (deterministic — run BEFORE the audit).** Run `sungen depth-lint --screen <name>` (Bash). It reuses the audit's businessDepth classifier and splits every shallow business-critical scenario into two actionable buckets — act on them now so the audit/repair loop doesn't burn rounds on depth:
94
+ - **DEEPEN IN PLACE** — add a real value assertion to each (`User see all [X] contain {{v}}`, `User remember [X] as {{v}}` + `… with {{v}}`). The printed `template` is a **hint** keyed off the theme — apply judgment to the scenario's actual claim; do NOT paste a value assertion that doesn't fit (e.g. a carousel-visibility scenario should assert the product SET, not a price). If a flagged scenario is genuinely visibility/behavior (not data-correctness), that's an over-count — leave it and note it, never fake an assertion.
95
+ - **CROSS-SCREEN** — route to a flow (`/sungen:add-flow`) or tag `@manual:Mx` + reason; do NOT fake an on-screen data assertion. This removes it from the screen's depth denominator honestly.
96
+ Re-run `sungen depth-lint` until `deepen` is empty (or only honest over-counts remain), THEN proceed to the gate. This lifts first-pass `businessDepth` mechanically instead of via 2–3 organic repair rounds.
97
+
92
98
  5.5. **Quality gate & repair (harness — always run, do NOT skip).** Follow the `sungen-harness-audit` skill:
93
99
  - Run `sungen audit --screen <name>` (Bash) and read `gateStatus` + `findings` (deterministic, structural).
94
100
  - **Independent semantic review.** **Claude Code:** spawn the **`sungen-reviewer`** sub-agent (Task tool, `subagent_type: sungen-reviewer`) — it judges what the gate can't (does each scenario's steps PROVE its title/viewpoint, observable Thens, business-critical assertion depth) and returns `VERDICT` + `ISSUES` with concrete fixes. **Merge its NEEDS-REPAIR issues with the audit findings.** (Copilot / no sub-agents: run the same review inline using the `sungen-reviewer` criteria.)
@@ -100,6 +106,7 @@ If the unit is **api-first** (`qa/api/<name>/` or `qa/api/flows/<name>/`), the d
100
106
  - **BALANCE** → stop expanding secondary viewpoints; add business-core scenarios first.
101
107
  - **TRACE** → align `VP-` ids with the viewpoint-overview.
102
108
  - Stop when the gate PASSes and findings clear, **or** the budget is exhausted → report residual gaps honestly (never fake a pass).
109
+ - **Phase gate (boundary — do NOT skip).** Run `sungen gate --screen <name> --phase create` (Bash, exit 2 = HALT). It is the deterministic create-boundary: every required obligation (spec · coverage · depth · trace) must be **satisfied or explicitly waived**. On **HALT**, you have not cleared the phase — keep repairing the blocking obligation(s) within budget; if a blocker is a genuinely-accepted gap (e.g. cross-screen depth owned by a flow), record it with `sungen journey --screen <name> --waive <OB> --reason "..."` (reason mandatory). **Do not converge (step 6) past a HALT** without a fix or a reasoned waiver — no bad output crosses the boundary.
103
110
 
104
111
  5.6. **Record (reuse + observability).** Build the manifest and report usage:
105
112
  - `sungen manifest --screen <name>` — fingerprints for next-run change detection. On a **re-run**, start the whole command by `sungen manifest --screen <name> --diff` and only regenerate scenarios whose spec section changed (keep/regenerate/retire).