@sun-asterisk/sungen 3.3.0-alpha.1 → 3.3.0-alpha.2
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.
- package/dist/capabilities/discover.d.ts +4 -0
- package/dist/capabilities/discover.d.ts.map +1 -1
- package/dist/capabilities/discover.js +16 -2
- package/dist/capabilities/discover.js.map +1 -1
- package/dist/cli/commands/audit.js +1 -1
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/cli/commands/contract-derive-command.d.ts +3 -2
- package/dist/cli/commands/contract-derive-command.d.ts.map +1 -1
- package/dist/cli/commands/contract-derive-command.js +3 -3
- package/dist/cli/commands/contract-derive-command.js.map +1 -1
- package/dist/cli/commands/contract.js +1 -1
- package/dist/cli/commands/contract.js.map +1 -1
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +9 -2
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/cli/commands/journey.js +2 -2
- package/dist/cli/commands/journey.js.map +1 -1
- package/dist/cli/commands/next.d.ts.map +1 -1
- package/dist/cli/commands/next.js +8 -6
- package/dist/cli/commands/next.js.map +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/project-sungen-invocation.d.ts +7 -0
- package/dist/cli/project-sungen-invocation.d.ts.map +1 -0
- package/dist/cli/project-sungen-invocation.js +76 -0
- package/dist/cli/project-sungen-invocation.js.map +1 -0
- package/dist/contract/derive-free-feature.d.ts +8 -0
- package/dist/contract/derive-free-feature.d.ts.map +1 -1
- package/dist/contract/derive-free-feature.js +10 -1
- package/dist/contract/derive-free-feature.js.map +1 -1
- package/dist/contract/trace/collect-trace-evidence.js +1 -1
- package/dist/contract/trace/collect-trace-evidence.js.map +1 -1
- package/dist/contract/trace/test-step-join.d.ts +3 -3
- package/dist/contract/trace/test-step-join.d.ts.map +1 -1
- package/dist/contract/trace/test-step-join.js +2 -2
- package/dist/contract/trace/test-step-join.js.map +1 -1
- package/dist/contract/trace/trace-file-reader.d.ts +7 -1
- package/dist/contract/trace/trace-file-reader.d.ts.map +1 -1
- package/dist/contract/trace/trace-file-reader.js +10 -4
- package/dist/contract/trace/trace-file-reader.js.map +1 -1
- package/dist/contract/trace/trace-run-record.d.ts +4 -1
- package/dist/contract/trace/trace-run-record.d.ts.map +1 -1
- package/dist/contract/trace/trace-run-record.js +20 -14
- package/dist/contract/trace/trace-run-record.js.map +1 -1
- package/dist/contract/trace/trace-steps.js +1 -1
- package/dist/contract/trace/trace-steps.js.map +1 -1
- package/dist/contract/trace/trace-zip-limits.d.ts +5 -1
- package/dist/contract/trace/trace-zip-limits.d.ts.map +1 -1
- package/dist/contract/trace/trace-zip-limits.js +6 -2
- package/dist/contract/trace/trace-zip-limits.js.map +1 -1
- package/dist/decisions/confirmed-decision-commands.d.ts +11 -0
- package/dist/decisions/confirmed-decision-commands.d.ts.map +1 -1
- package/dist/decisions/confirmed-decision-commands.js +21 -3
- package/dist/decisions/confirmed-decision-commands.js.map +1 -1
- package/dist/decisions/human-confirmation.d.ts +2 -0
- package/dist/decisions/human-confirmation.d.ts.map +1 -1
- package/dist/decisions/human-confirmation.js +6 -0
- package/dist/decisions/human-confirmation.js.map +1 -1
- package/dist/exporters/matrix/xlsx-cells.js +1 -1
- package/dist/exporters/matrix/xlsx-cells.js.map +1 -1
- package/dist/graph/evaluate-graph.d.ts +3 -1
- package/dist/graph/evaluate-graph.d.ts.map +1 -1
- package/dist/graph/evaluate-graph.js +8 -5
- package/dist/graph/evaluate-graph.js.map +1 -1
- package/dist/graph/node-decl.d.ts +7 -0
- package/dist/graph/node-decl.d.ts.map +1 -1
- package/dist/graph/node-decl.js.map +1 -1
- package/dist/graph/nodes/test-case-contract-nodes.d.ts +3 -1
- package/dist/graph/nodes/test-case-contract-nodes.d.ts.map +1 -1
- package/dist/graph/nodes/test-case-contract-nodes.js +7 -4
- package/dist/graph/nodes/test-case-contract-nodes.js.map +1 -1
- package/dist/graph/nodes/unit-contract-node.d.ts +2 -1
- package/dist/graph/nodes/unit-contract-node.d.ts.map +1 -1
- package/dist/graph/nodes/unit-contract-node.js +8 -1
- package/dist/graph/nodes/unit-contract-node.js.map +1 -1
- package/dist/graph/nodes/unit-design-nodes.d.ts.map +1 -1
- package/dist/graph/nodes/unit-design-nodes.js +2 -1
- package/dist/graph/nodes/unit-design-nodes.js.map +1 -1
- package/dist/harness/catalog/gate-rules.yaml +2 -2
- package/dist/harness/gates/audit-report-verification.d.ts +15 -6
- package/dist/harness/gates/audit-report-verification.d.ts.map +1 -1
- package/dist/harness/gates/audit-report-verification.js +49 -20
- package/dist/harness/gates/audit-report-verification.js.map +1 -1
- package/dist/harness/gates/delivery-checklist.js +2 -2
- package/dist/harness/gates/delivery-checklist.js.map +1 -1
- package/dist/harness/gates/delivery-decision-gate.d.ts +4 -0
- package/dist/harness/gates/delivery-decision-gate.d.ts.map +1 -1
- package/dist/harness/gates/delivery-decision-gate.js +26 -1
- package/dist/harness/gates/delivery-decision-gate.js.map +1 -1
- package/dist/harness/gates/delivery-decision-step.d.ts +4 -1
- package/dist/harness/gates/delivery-decision-step.d.ts.map +1 -1
- package/dist/harness/gates/delivery-decision-step.js +17 -2
- package/dist/harness/gates/delivery-decision-step.js.map +1 -1
- package/dist/harness/journey.d.ts +19 -0
- package/dist/harness/journey.d.ts.map +1 -1
- package/dist/harness/journey.js +94 -11
- package/dist/harness/journey.js.map +1 -1
- package/dist/harness/provenance.d.ts +29 -0
- package/dist/harness/provenance.d.ts.map +1 -1
- package/dist/harness/provenance.js +109 -9
- package/dist/harness/provenance.js.map +1 -1
- package/dist/hooks/guard-protected-paths.d.ts.map +1 -1
- package/dist/hooks/guard-protected-paths.js +3 -2
- package/dist/hooks/guard-protected-paths.js.map +1 -1
- package/dist/hooks/guard-rules.d.ts +1 -1
- package/dist/hooks/guard-tty-spoof-rule.d.ts +1 -1
- package/dist/hooks/guard-tty-spoof-rule.js +2 -2
- package/dist/hooks/guard-tty-spoof-rule.js.map +1 -1
- package/dist/hooks/hand-back-core.d.ts +5 -3
- package/dist/hooks/hand-back-core.d.ts.map +1 -1
- package/dist/hooks/hand-back-core.js +17 -10
- package/dist/hooks/hand-back-core.js.map +1 -1
- package/dist/hooks/session-start-unit-state-hook.d.ts.map +1 -1
- package/dist/hooks/session-start-unit-state-hook.js +5 -3
- package/dist/hooks/session-start-unit-state-hook.js.map +1 -1
- package/dist/orchestrator/assets-drift.d.ts +5 -0
- package/dist/orchestrator/assets-drift.d.ts.map +1 -1
- package/dist/orchestrator/assets-drift.js +12 -3
- package/dist/orchestrator/assets-drift.js.map +1 -1
- package/dist/orchestrator/project-initializer.d.ts.map +1 -1
- package/dist/orchestrator/project-initializer.js +1 -0
- package/dist/orchestrator/project-initializer.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +2 -2
- package/dist/orchestrator/templates/ai-src/commands/run-test.md +1 -1
- package/package.json +3 -3
- package/src/capabilities/discover.ts +15 -2
- package/src/cli/commands/audit.ts +1 -1
- package/src/cli/commands/contract-derive-command.ts +7 -6
- package/src/cli/commands/contract.ts +1 -1
- package/src/cli/commands/delivery.ts +7 -2
- package/src/cli/commands/journey.ts +4 -4
- package/src/cli/commands/next.ts +8 -6
- package/src/cli/index.ts +1 -1
- package/src/cli/project-sungen-invocation.ts +39 -0
- package/src/contract/derive-free-feature.ts +12 -2
- package/src/contract/trace/collect-trace-evidence.ts +1 -1
- package/src/contract/trace/test-step-join.ts +6 -5
- package/src/contract/trace/trace-file-reader.ts +17 -7
- package/src/contract/trace/trace-run-record.ts +14 -8
- package/src/contract/trace/trace-steps.ts +1 -1
- package/src/contract/trace/trace-zip-limits.ts +6 -2
- package/src/decisions/confirmed-decision-commands.ts +25 -4
- package/src/decisions/human-confirmation.ts +5 -0
- package/src/exporters/matrix/xlsx-cells.ts +1 -1
- package/src/graph/evaluate-graph.ts +9 -5
- package/src/graph/node-decl.ts +7 -0
- package/src/graph/nodes/test-case-contract-nodes.ts +9 -4
- package/src/graph/nodes/unit-contract-node.ts +8 -1
- package/src/graph/nodes/unit-design-nodes.ts +2 -1
- package/src/harness/catalog/gate-rules.yaml +2 -2
- package/src/harness/gates/audit-report-verification.ts +51 -23
- package/src/harness/gates/delivery-checklist.ts +2 -2
- package/src/harness/gates/delivery-decision-gate.ts +29 -1
- package/src/harness/gates/delivery-decision-step.ts +19 -3
- package/src/harness/journey.ts +95 -13
- package/src/harness/provenance.ts +99 -11
- package/src/hooks/guard-protected-paths.ts +3 -2
- package/src/hooks/guard-rules.ts +1 -1
- package/src/hooks/guard-tty-spoof-rule.ts +3 -3
- package/src/hooks/hand-back-core.ts +17 -11
- package/src/hooks/session-start-unit-state-hook.ts +5 -3
- package/src/orchestrator/assets-drift.ts +12 -5
- package/src/orchestrator/project-initializer.ts +1 -0
- package/src/orchestrator/templates/ai-src/commands/create-test.md +2 -2
- package/src/orchestrator/templates/ai-src/commands/run-test.md +1 -1
|
@@ -6,50 +6,77 @@
|
|
|
6
6
|
* status `gateStatus` is PASS|FAIL and agrees with the integer `exitCode` (0 | 2)
|
|
7
7
|
* inputs `inputsHash` equals the hash of today's inputs + decisions (audit-input-hash.ts)
|
|
8
8
|
* decisions `decisionsHash` equals today's — otherwise the audit is stale: re-audit
|
|
9
|
+
* rules `provenance.catalogHash` and `provenance.rulesHash` are the running sungen's — other
|
|
10
|
+
* catalogs or another AUDIT_RULES_VERSION scored it (a 3.2 PASS can be a 3.3 FAIL on the
|
|
11
|
+
* same files): stale, re-audit. Missing → stale (fail closed). The package VERSION
|
|
12
|
+
* alone is not a reason: a newer sungen with the same rules gives the same verdict (W4)
|
|
9
13
|
* verdict gateStatus re-evaluated from the report's own terms (audit-gate-terms.ts) under the
|
|
10
14
|
* overrides in force — a flipped gateStatus + exitCode is `verdict-mismatch` (tester m1)
|
|
11
15
|
* Anything else counts as NEVER AUDITED, not as clean; only an explicit `gateStatus: PASS` passes.
|
|
12
16
|
* These checks are anti-STALENESS and anti-casual-tamper, NOT anti-forgery: a writer of audit.json can
|
|
13
17
|
* recompute the hashes, and one who rewrites every finding, gap and verdict is out of scope here.
|
|
14
18
|
*
|
|
15
|
-
* Legacy (DOS-11.4): a report written before these fields existed carries none of exitCode /
|
|
16
|
-
* inputsHash / decisionsHash / gateOverrides
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
+
* Legacy (DOS-11.4, #933): a report written before these fields existed carries none of exitCode /
|
|
20
|
+
* inputsHash / decisionsHash / gateOverrides, so nothing in it can vouch for today's inputs. It reads
|
|
21
|
+
* STALE — one `sungen audit` records a current one. (It used to count while no input file was newer:
|
|
22
|
+
* a fresh checkout gives every file the checkout's mtime, so that proved nothing and an old PASS was
|
|
23
|
+
* trusted.) A legacy report claiming a PASS its own fields contradict is invalid. A report carrying
|
|
24
|
+
* SOME of the fields is malformed.
|
|
19
25
|
*/
|
|
20
26
|
import * as fs from 'fs';
|
|
21
27
|
import * as path from 'path';
|
|
22
28
|
import { resolveUnitCandidates } from '../../cli/resolve-unit';
|
|
23
29
|
import { reportSlug } from '../unit-paths';
|
|
24
|
-
import {
|
|
30
|
+
import { getProvenance } from '../provenance';
|
|
31
|
+
import { auditInputsHash } from './audit-input-hash';
|
|
25
32
|
import { AUDIT_EXIT_CODE } from './audit-gate-verdict';
|
|
26
33
|
import { recomputeAuditGateStatus, termSourcesOf } from './audit-gate-terms';
|
|
27
34
|
import { EMPTY_DECISIONS_HASH } from './decisions-hash';
|
|
28
35
|
import { loadUnitGateOverrides, unitScopeOfDir, UnitGateOverrides } from './unit-gate-overrides';
|
|
29
36
|
|
|
30
|
-
export type AuditState = 'absent' | 'verified' | '
|
|
37
|
+
export type AuditState = 'absent' | 'verified' | 'stale' | 'invalid';
|
|
31
38
|
export type AuditProblemCode = 'unreadable' | 'unknown-key' | 'bad-type' | 'partial-fields' | 'status-exit-mismatch'
|
|
32
|
-
| 'unit-unresolved' | 'decisions-changed' | 'inputs-changed' | 'legacy-with-overrides' | 'legacy-
|
|
39
|
+
| 'unit-unresolved' | 'decisions-changed' | 'inputs-changed' | 'rules-changed' | 'legacy-with-overrides' | 'legacy-unverified' | 'verdict-mismatch';
|
|
33
40
|
|
|
34
41
|
export interface AuditVerification {
|
|
35
42
|
state: AuditState;
|
|
36
43
|
problems: Array<{ code: AuditProblemCode; message: string }>;
|
|
37
|
-
/** Caveats on a report that counts
|
|
44
|
+
/** Caveats on a report that counts. */
|
|
38
45
|
notes: string[];
|
|
39
|
-
/** The parsed report — only when it counts (verified
|
|
46
|
+
/** The parsed report — only when it counts (verified). */
|
|
40
47
|
report: Record<string, unknown> | null;
|
|
41
48
|
path: string;
|
|
42
49
|
/** Today's decisionsHash for the unit (what a fresh audit would record). */
|
|
43
50
|
currentDecisionsHash: string;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
|
|
53
|
+
/** A string field of the report's provenance, or null when it does not say. */
|
|
54
|
+
function provenanceField(r: Record<string, unknown>, key: 'sungenVersion' | 'catalogHash' | 'rulesHash'): string | null {
|
|
55
|
+
const v = r.provenance && typeof r.provenance === 'object' ? (r.provenance as Record<string, unknown>)[key] : undefined;
|
|
56
|
+
return typeof v === 'string' && v ? v : null;
|
|
57
|
+
}
|
|
58
|
+
const writtenBy = (r: Record<string, unknown>): string | null => provenanceField(r, 'sungenVersion');
|
|
59
|
+
|
|
60
|
+
export interface RulesStamp { catalogHash: string | null; rulesHash: string | null }
|
|
61
|
+
/** What getProvenance writes when it could not read a catalog — never a match, on either side. */
|
|
62
|
+
const UNKNOWN = 'unknown';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Why the rules a report was scored by (`was`) cannot be taken as this sungen's (`now`), or null when
|
|
66
|
+
* they are the same KNOWN rules. Missing or `unknown` on either side is a problem (fail closed):
|
|
67
|
+
* unknown == unknown proves nothing.
|
|
68
|
+
*/
|
|
69
|
+
export function rulesProblem(was: RulesStamp, now: RulesStamp): string | null {
|
|
70
|
+
if (!was.catalogHash || !was.rulesHash) return 'it does not say which rules scored it (no provenance.catalogHash / rulesHash)';
|
|
71
|
+
if ([was.catalogHash, was.rulesHash].includes(UNKNOWN)) return 'the rules that scored it are unknown (provenance says "unknown")';
|
|
72
|
+
if (!now.catalogHash || !now.rulesHash || [now.catalogHash, now.rulesHash].includes(UNKNOWN)) return 'this sungen cannot read its own rules (catalog or rules hash unknown), so it trusts no report';
|
|
73
|
+
if (was.catalogHash === now.catalogHash && was.rulesHash === now.rulesHash) return null;
|
|
74
|
+
return `scored by other rules (catalog ${was.catalogHash} · rules ${was.rulesHash}) than this sungen's (catalog ${now.catalogHash} · rules ${now.rulesHash})`;
|
|
75
|
+
}
|
|
47
76
|
|
|
48
|
-
/**
|
|
49
|
-
function
|
|
50
|
-
|
|
51
|
-
const at = fs.statSync(reportFile).mtimeMs;
|
|
52
|
-
return auditInputFiles(unitDir, unit).find((f) => fs.statSync(f).mtimeMs > at) ?? null;
|
|
77
|
+
/** Why a current-format report was judged by other rules than this sungen's, or null when the same. */
|
|
78
|
+
function otherRules(r: Record<string, unknown>): string | null {
|
|
79
|
+
return rulesProblem({ catalogHash: provenanceField(r, 'catalogHash'), rulesHash: provenanceField(r, 'rulesHash') }, getProvenance());
|
|
53
80
|
}
|
|
54
81
|
|
|
55
82
|
type JsonKind = 'string' | 'number' | 'boolean' | 'object' | 'array';
|
|
@@ -124,17 +151,18 @@ export function verifyAuditReport(projectRoot: string, unit: string): AuditVerif
|
|
|
124
151
|
if (schema.length) return result('invalid', schema);
|
|
125
152
|
|
|
126
153
|
if (!P0_KEYS.some((k) => k in r)) {
|
|
127
|
-
if (current !== EMPTY_DECISIONS_HASH) {
|
|
128
|
-
return result('stale', [{ code: 'legacy-with-overrides', message: 'written by an earlier sungen (no decisionsHash) and a gate-override now applies to this unit' }]);
|
|
129
|
-
}
|
|
130
|
-
const newer = newerLegacyInput(file, unitDir, unit);
|
|
131
|
-
if (newer) return result('stale', [{ code: 'legacy-inputs-newer', message: `written by an earlier sungen and ${path.basename(newer)} changed after it` }]);
|
|
132
154
|
// A legacy report predates gateSensorError, so only a stored PASS its own fields contradict is caught.
|
|
133
155
|
const sources = termSourcesOf(r, false);
|
|
134
156
|
if (r.gateStatus === 'PASS' && sources && recomputeAuditGateStatus(sources, overrides) === 'FAIL') return result('invalid', [verdictMismatch(r)]);
|
|
135
|
-
|
|
157
|
+
const by = `written by ${writtenBy(r) ? `sungen ${writtenBy(r)}` : 'an earlier sungen'}`;
|
|
158
|
+
if (current !== EMPTY_DECISIONS_HASH) {
|
|
159
|
+
return result('stale', [{ code: 'legacy-with-overrides', message: `${by} (no decisionsHash) and a gate-override now applies to this unit` }]);
|
|
160
|
+
}
|
|
161
|
+
return result('stale', [{ code: 'legacy-unverified', message: `${by}, before inputsHash existed — its inputs cannot be verified` }]);
|
|
136
162
|
}
|
|
137
163
|
const stale: AuditVerification['problems'] = [];
|
|
164
|
+
const rules = otherRules(r);
|
|
165
|
+
if (rules) stale.push({ code: 'rules-changed', message: `${writtenBy(r) ? `written by sungen ${writtenBy(r)}, ` : ''}${rules}` });
|
|
138
166
|
if (r.decisionsHash !== current) stale.push({ code: 'decisions-changed', message: 'the gate-override decisions changed since this audit' });
|
|
139
167
|
if (!unitDir) return result('invalid', [{ code: 'unit-unresolved', message: `"${unit}" does not name exactly one unit, so the audit's inputs cannot be checked` }]);
|
|
140
168
|
if (r.inputsHash !== auditInputsHash(unitDir, unit, r.decisionsHash as string)) {
|
|
@@ -157,8 +185,8 @@ function verdictProblem(r: Record<string, unknown>, overrides: UnitGateOverrides
|
|
|
157
185
|
return recomputeAuditGateStatus(sources, overrides) === r.gateStatus ? null : verdictMismatch(r);
|
|
158
186
|
}
|
|
159
187
|
|
|
160
|
-
/** Whether the report may be used at all
|
|
161
|
-
export const auditCounts = (v: AuditVerification): boolean => v.state === 'verified'
|
|
188
|
+
/** Whether the report may be used at all: only a verified one. */
|
|
189
|
+
export const auditCounts = (v: AuditVerification): boolean => v.state === 'verified';
|
|
162
190
|
|
|
163
191
|
/** Only an explicit `gateStatus: PASS` on a report that counts passes; a score never does. */
|
|
164
192
|
export const auditPassed = (v: AuditVerification): boolean => auditCounts(v) && v.report?.gateStatus === 'PASS';
|
|
@@ -33,10 +33,10 @@ function auditBox(projectRoot: string, u: ChecklistUnit): ChecklistBox {
|
|
|
33
33
|
const v = verifyAuditReport(projectRoot, u.unitName);
|
|
34
34
|
if (!auditCounts(v)) return box('audit', 'fail', auditNotCountedReason(v, u.unitName));
|
|
35
35
|
const overall = (v.report?.score as { overall?: number } | undefined)?.overall;
|
|
36
|
-
const how =
|
|
36
|
+
const how = "verified against today's inputs";
|
|
37
37
|
return v.report?.gateStatus === 'PASS'
|
|
38
38
|
? box('audit', 'pass', `PASS ${overall ?? '?'}/10 — ${how}`)
|
|
39
|
-
: box('audit', 'fail', `FAIL ${overall ?? '?'}/10 — \`sungen audit\` exits 2 (${how})`);
|
|
39
|
+
: box('audit', 'fail', `FAIL ${overall ?? '?'}/10 — \`sungen audit\` exits 2 (${how}); an official delivery is blocked until it passes — --preview renders a draft`);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/** The compiler prints progress on stdout; the checklist keeps the delivery output to itself. */
|
|
@@ -8,12 +8,16 @@
|
|
|
8
8
|
* no trusted no audit report, or one that does not count (stale, invalid, verdict-mismatch):
|
|
9
9
|
* audit a real delivery stops and asks for `sungen audit` (owner decision 2026-10-22);
|
|
10
10
|
* `--preview` / `--check` go on with a warning — the caller decides (`untrustedAudits`)
|
|
11
|
+
* failing a trusted audit whose verdict is FAIL (`sungen audit` exits 2): the same — a real
|
|
12
|
+
* audit delivery stops, naming the failing gate rules (#935, owner decision); `--preview`
|
|
13
|
+
* renders a DRAFT, `--check` reports it (`failingAudits`)
|
|
11
14
|
* It also hands each target its gate rules (catalog + overrides) for the matrix gates, and the
|
|
12
15
|
* override rows the XLSX may show. With no decision in reach nothing here changes the delivery.
|
|
13
16
|
*/
|
|
14
17
|
import { checkDecisionIntegrity, DecisionFinding } from '../../decisions/decision-integrity-check';
|
|
15
18
|
import { PROJECT_SCOPE } from '../../decisions/decision-file';
|
|
16
19
|
import { auditCounts, verifyAuditReport } from './audit-report-verification';
|
|
20
|
+
import { auditGateTerms, termSourcesOf } from './audit-gate-terms';
|
|
17
21
|
import { GateOverrideReport, gateOverrideReports, gateOverrideSummaryLine, InRunAcceptance } from './gate-override-report';
|
|
18
22
|
import { unitsCoveredByScope } from './gate-override-scope';
|
|
19
23
|
import { committedDecisionPaths, hasInRunKindDecisions, inRunDecisions, InRunDecision, IN_RUN_RULE, runStartOf } from './in-run-decision-check';
|
|
@@ -35,6 +39,10 @@ export interface DeliveryDecisionGate {
|
|
|
35
39
|
blocking: string[];
|
|
36
40
|
/** Units with no trusted audit — a real delivery stops on them, --preview / --check warn. */
|
|
37
41
|
untrustedAudits: string[];
|
|
42
|
+
/** Units whose trusted audit FAILs (#935) — a real delivery stops on them, --preview / --check warn. */
|
|
43
|
+
failingAudits: string[];
|
|
44
|
+
/** The unit's audit does not let it be delivered officially (untrusted or FAIL): its render is a DRAFT. */
|
|
45
|
+
auditDraft(unitDir: string): boolean;
|
|
38
46
|
notes: string[];
|
|
39
47
|
inRun: Array<InRunDecision & { unit: string }>;
|
|
40
48
|
/** The unit's tracked gate rules, for the matrix gates of its targets. */
|
|
@@ -48,9 +56,22 @@ export interface DeliveryDecisionGate {
|
|
|
48
56
|
|
|
49
57
|
const reachOf = (scope: string | null): string[] => [`qa/decisions/`, ...(scope ? [`qa/${scope}/decisions/`] : [])];
|
|
50
58
|
|
|
59
|
+
/** The audit gate rules a FAIL report's own terms violate under the unit's overrides, gaps named. */
|
|
60
|
+
function failingGateRules(report: Record<string, unknown>, overrides: UnitGateOverrides): string {
|
|
61
|
+
const sources = termSourcesOf(report, true);
|
|
62
|
+
if (!sources) return 'its gate terms';
|
|
63
|
+
const gaps = (sources.gate.gaps as Array<{ theme?: unknown; status?: unknown }>)
|
|
64
|
+
.map((g) => `${g.status === 'shallow' ? 'SHALLOW' : 'MISSING'}: ${String(g.theme)}`);
|
|
65
|
+
const rules = auditGateTerms(sources).filter((t) => !t.holds && resolveRule(t.rule, overrides.context).blocking)
|
|
66
|
+
.map((t) => (t.rule === 'audit.viewpoint-coverage' && gaps.length ? `${t.rule} (${gaps.join(', ')})` : t.rule));
|
|
67
|
+
return rules.join(', ') || 'its gate terms';
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
export function evaluateDeliveryDecisions(projectRoot: string, refs: DeliveryUnitRef[]): DeliveryDecisionGate {
|
|
52
71
|
const blocking: string[] = [];
|
|
53
72
|
const untrustedAudits: string[] = [];
|
|
73
|
+
const failingAudits: string[] = [];
|
|
74
|
+
const draftDirs = new Set<string>();
|
|
54
75
|
const notes: string[] = [];
|
|
55
76
|
const inRun: DeliveryDecisionGate['inRun'] = [];
|
|
56
77
|
const units = new Map<string, UnitState>();
|
|
@@ -63,10 +84,15 @@ export function evaluateDeliveryDecisions(projectRoot: string, refs: DeliveryUni
|
|
|
63
84
|
? (audit.report!.gateOverrides as GateOverrideReport[]).filter((g) => g?.triggered).map((g) => g.rule) : []);
|
|
64
85
|
units.set(ref.unitDir, { ref, resultsPaths: [ref.resultsPath], overrides, rules: trackGateRules(overrides.context), auditTriggered });
|
|
65
86
|
for (const r of overrides.rejected) blocking.push(`${ref.label}: gate.override-rejected — ${r.decisionId} (${r.path}): ${r.reason}`);
|
|
87
|
+
const flag = /[\\/]qa[\\/]api[\\/]/.test(ref.unitDir) ? '--api' : '--screen';
|
|
66
88
|
if (!auditCounts(audit)) {
|
|
67
89
|
const why = audit.state === 'absent' ? 'no audit report' : `the audit is ${audit.state === 'stale' ? 'stale' : 'not trusted'}: ${audit.problems.map((p) => p.message).join('; ')}`;
|
|
68
|
-
const flag = /[\\/]qa[\\/]api[\\/]/.test(ref.unitDir) ? '--api' : '--screen';
|
|
69
90
|
untrustedAudits.push(`${ref.label}: ${why} — re-run \`sungen audit ${flag} ${ref.unitName}\``);
|
|
91
|
+
draftDirs.add(ref.unitDir);
|
|
92
|
+
} else if (audit.report?.gateStatus !== 'PASS') {
|
|
93
|
+
const overall = (audit.report?.score as { overall?: number } | undefined)?.overall;
|
|
94
|
+
failingAudits.push(`${ref.label}: FAIL ${overall ?? '?'}/10 on ${failingGateRules(audit.report!, overrides)} — fix the suite, then re-run \`sungen audit ${flag} ${ref.unitName}\``);
|
|
95
|
+
draftDirs.add(ref.unitDir);
|
|
70
96
|
}
|
|
71
97
|
for (const n of audit.notes) notes.push(`${ref.label}: ${n}`);
|
|
72
98
|
}
|
|
@@ -95,6 +121,8 @@ export function evaluateDeliveryDecisions(projectRoot: string, refs: DeliveryUni
|
|
|
95
121
|
return {
|
|
96
122
|
blocking,
|
|
97
123
|
untrustedAudits,
|
|
124
|
+
failingAudits,
|
|
125
|
+
auditDraft: (dir) => draftDirs.has(dir),
|
|
98
126
|
notes,
|
|
99
127
|
inRun,
|
|
100
128
|
rulesFor: (dir) => units.get(dir)?.rules,
|
|
@@ -16,7 +16,10 @@ export interface DeliveryDecisionStepInput {
|
|
|
16
16
|
argv: string[];
|
|
17
17
|
io: ConfirmationIO;
|
|
18
18
|
log: (line: string) => void;
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* A real export needs, per unit, a trusted audit (owner decision 2026-10-22) that PASSes (#935);
|
|
21
|
+
* false for --preview / --check, which go on with a warning (a DRAFT render / a report).
|
|
22
|
+
*/
|
|
20
23
|
requireTrustedAudit: boolean;
|
|
21
24
|
}
|
|
22
25
|
|
|
@@ -29,6 +32,8 @@ export async function runDeliveryDecisionStep(i: DeliveryDecisionStepInput): Pro
|
|
|
29
32
|
for (const b of gate.blocking) i.log(` - ${b}`);
|
|
30
33
|
return null;
|
|
31
34
|
}
|
|
35
|
+
// Every reason to stop is listed before stopping, so one run names all of them (audit and in-run).
|
|
36
|
+
let blocked = false;
|
|
32
37
|
if (gate.untrustedAudits.length) {
|
|
33
38
|
i.log(i.requireTrustedAudit
|
|
34
39
|
? `✗ Delivery blocked — ${gate.untrustedAudits.length} unit(s) without a trusted audit (a delivery stands on an audit that matches today's suite):`
|
|
@@ -36,10 +41,20 @@ export async function runDeliveryDecisionStep(i: DeliveryDecisionStepInput): Pro
|
|
|
36
41
|
for (const u of gate.untrustedAudits) i.log(` - ${u}`);
|
|
37
42
|
if (i.requireTrustedAudit) {
|
|
38
43
|
i.log(' Or render a watermarked draft with --preview.');
|
|
39
|
-
|
|
44
|
+
blocked = true;
|
|
40
45
|
}
|
|
41
46
|
}
|
|
42
|
-
if (gate.
|
|
47
|
+
if (gate.failingAudits.length) {
|
|
48
|
+
i.log(i.requireTrustedAudit
|
|
49
|
+
? `✗ Delivery blocked — ${gate.failingAudits.length} unit(s) whose audit FAILs (\`sungen audit\` exits 2; an official delivery stands on a passing audit):`
|
|
50
|
+
: `⚠ Rendering ${gate.failingAudits.length} unit(s) whose audit FAILs — a draft, not an official delivery:`);
|
|
51
|
+
for (const u of gate.failingAudits) i.log(` - ${u}`);
|
|
52
|
+
if (i.requireTrustedAudit) {
|
|
53
|
+
i.log(' Fix the suite and re-run the audit, or render a watermarked draft with --preview.');
|
|
54
|
+
blocked = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (gate.inRun.length === 0) return blocked ? null : gate;
|
|
43
58
|
const acceptCommand = formatCommand([...i.argv, '--accept-in-run']);
|
|
44
59
|
if (!i.acceptInRun) {
|
|
45
60
|
i.log(`✗ Delivery blocked — ${gate.inRun.length} decision(s) recorded during the run (${IN_RUN_RULE}, hard):`);
|
|
@@ -48,6 +63,7 @@ export async function runDeliveryDecisionStep(i: DeliveryDecisionStepInput): Pro
|
|
|
48
63
|
i.log(` ${acceptCommand}`);
|
|
49
64
|
return null;
|
|
50
65
|
}
|
|
66
|
+
if (blocked) return null; // nothing to accept on top of a delivery the audit already stops
|
|
51
67
|
try {
|
|
52
68
|
await requireTypedConfirmation({
|
|
53
69
|
what: `deliver with ${gate.inRun.length} decision(s) recorded during the run (${IN_RUN_RULE})`,
|
package/src/harness/journey.ts
CHANGED
|
@@ -20,8 +20,9 @@ import { readTextFile } from './read-text';
|
|
|
20
20
|
import { assertNoPendingMigration } from '../basis/migration-sentinel';
|
|
21
21
|
import { commitWaiverSync, planWaiverSync, recordWaiver, untrustedWaiverText } from '../decisions/legacy-waiver-migration';
|
|
22
22
|
import { DecisionConflictError } from '../decisions/append-decision';
|
|
23
|
+
import { gitUserName } from '../decisions/decision-source';
|
|
23
24
|
import { resolveRule } from './gates/resolve-rule';
|
|
24
|
-
import { applyAuditOverrides, decisionFindingText, DecisionGateFinding, ignoredOverrideText, journeyDecisionFindings, readJourneyAudit, unitOverrides } from './gates/journey-gate-inputs';
|
|
25
|
+
import { applyAuditOverrides, decisionFindingText, DecisionGateFinding, ignoredOverrideText, journeyDecisionFindings, OBLIGATION_RULE, readJourneyAudit, unitOverrides } from './gates/journey-gate-inputs';
|
|
25
26
|
import { IN_RUN_RULE, uncommittedReleaseDecisions } from './gates/in-run-decision-check';
|
|
26
27
|
import { PROJECT_SCOPE } from '../decisions/decision-file';
|
|
27
28
|
|
|
@@ -85,6 +86,26 @@ function extractAnchor(text: string): Anchor {
|
|
|
85
86
|
|
|
86
87
|
const SAT = 0.8; // axis at/above this = satisfied (below = needs-work)
|
|
87
88
|
|
|
89
|
+
/**
|
|
90
|
+
* OB-coverage IS the audit's viewpoint gate (#934): the registry maps it to `audit.viewpoint-coverage`,
|
|
91
|
+
* whose condition is "no critical theme missing or shallow" (audit-gate-terms.ts). The coverage
|
|
92
|
+
* percentage alone can read 83% with a critical theme missing, so a gap the rule blocks on keeps the
|
|
93
|
+
* obligation open and names the themes. An override the audit applied is credited later by
|
|
94
|
+
* applyAuditOverrides — journey never re-applies one, so the rule is resolved from the catalog here.
|
|
95
|
+
*/
|
|
96
|
+
function coverageObligation(coverage: number | undefined, audit: any): Obligation {
|
|
97
|
+
const id = 'OB-coverage';
|
|
98
|
+
const title = 'Viewpoint coverage';
|
|
99
|
+
const pct = coverage === undefined ? '' : `${Math.round(coverage * 100)}% (need ${Math.round(SAT * 100)}%) — `;
|
|
100
|
+
const gaps: Array<{ theme?: unknown; status?: unknown }> = Array.isArray(audit.gate?.gaps) ? audit.gate.gaps : [];
|
|
101
|
+
if (gaps.length && resolveRule(OBLIGATION_RULE[id]).blocking) {
|
|
102
|
+
const themes = gaps.map((g) => `${String(g.theme)} (${g.status === 'shallow' ? 'shallow' : 'missing'})`).join(', ');
|
|
103
|
+
return { id, title, status: 'needs-work', detail: `${pct}critical theme(s) not covered: ${themes} — the audit's viewpoint gate fails on them` };
|
|
104
|
+
}
|
|
105
|
+
const status: ObStatus = coverage === undefined ? 'pending' : coverage >= SAT ? 'satisfied' : 'needs-work';
|
|
106
|
+
return { id, title, status, detail: `${pct}critical themes covered` };
|
|
107
|
+
}
|
|
108
|
+
|
|
88
109
|
function computeFresh(projectRoot: string, unit: string): JourneyReport {
|
|
89
110
|
const slug = reportSlug(unit);
|
|
90
111
|
// T3: the report is an output — read through the checks; stale or untrusted counts as never audited.
|
|
@@ -123,7 +144,7 @@ function computeFresh(projectRoot: string, unit: string): JourneyReport {
|
|
|
123
144
|
});
|
|
124
145
|
|
|
125
146
|
obligations.push(ob('OB-spec', 'Spec readiness', ax.specFR, 1, 'FR/sections sufficient to design from'));
|
|
126
|
-
obligations.push(
|
|
147
|
+
obligations.push(coverageObligation(ax.coverage, audit));
|
|
127
148
|
obligations.push(ob('OB-depth', 'Assertion depth', ax.businessDepth, depthThreshold, 'business-critical scenarios assert data'));
|
|
128
149
|
obligations.push(ob('OB-trace', 'Traceability', ax.taxonomy ?? ax.traceability, SAT, 'scenarios trace to viewpoints'));
|
|
129
150
|
|
|
@@ -180,7 +201,8 @@ function computeFresh(projectRoot: string, unit: string): JourneyReport {
|
|
|
180
201
|
// ---------------- S2: writable lifecycle — persisted waivers + reconcile ----------------
|
|
181
202
|
|
|
182
203
|
// Waivers live in the decision layer (DOS-13.5); this file keeps a dual-written copy for one release.
|
|
183
|
-
|
|
204
|
+
/** S5 — stays here in P0. `by` is who signs (--by, else the git user); `recordedBy` the git user when --by differs. */
|
|
205
|
+
interface Signoff { by?: string; recordedBy?: string; at: string; auditHashAtSignoff: string; }
|
|
184
206
|
interface JourneyState { unit: string; auditHash: string; waivers: Record<string, unknown>; signoff?: Signoff; }
|
|
185
207
|
|
|
186
208
|
function statePath(projectRoot: string, slug: string): string {
|
|
@@ -230,12 +252,16 @@ export function runJourney(projectRoot: string, unit: string): JourneyReport {
|
|
|
230
252
|
const sync = planWaiverSync(projectRoot, unit, slug, sp, state.waivers);
|
|
231
253
|
for (const u of sync.untrusted) {
|
|
232
254
|
const title = report.obligations.find((o) => o.id === u.obligation)?.title ?? u.obligation;
|
|
233
|
-
|
|
255
|
+
const ignored = unwaivableWhy(u.obligation, unit);
|
|
256
|
+
report.needsYou.unshift({ text: ignored ? ignoredWaiverText(u.obligation, ignored, u.waiver.reason) : untrustedWaiverText(unit, slug, title, u), anchor: { vpId: u.obligation } });
|
|
234
257
|
}
|
|
235
258
|
|
|
236
259
|
for (const ob of report.obligations) {
|
|
237
260
|
const w = sync.waivers[ob.id];
|
|
238
261
|
if (!w) continue;
|
|
262
|
+
// A waiver recorded before `--waive` refused this obligation never opens it (review W1).
|
|
263
|
+
const ignored = unwaivableWhy(ob.id, unit);
|
|
264
|
+
if (ignored) { report.needsYou.unshift({ text: ignoredWaiverText(ob.id, ignored, w.reason), anchor: { vpId: ob.id } }); continue; }
|
|
239
265
|
if (w.auditHashAtWaive === curHash) {
|
|
240
266
|
ob.status = 'waived';
|
|
241
267
|
ob.waivedReason = w.reason;
|
|
@@ -250,7 +276,7 @@ export function runJourney(projectRoot: string, unit: string): JourneyReport {
|
|
|
250
276
|
if (signoffOb && state.signoff) {
|
|
251
277
|
if (state.signoff.auditHashAtSignoff === curHash) {
|
|
252
278
|
signoffOb.status = 'satisfied';
|
|
253
|
-
signoffOb.detail = `signed off${state.signoff.by ? ' by ' + state.signoff.by : ''} at ${state.signoff.at}`;
|
|
279
|
+
signoffOb.detail = `signed off${state.signoff.by ? ' by ' + state.signoff.by : ''}${state.signoff.recordedBy ? ` (recorded by ${state.signoff.recordedBy})` : ''} at ${state.signoff.at}`;
|
|
254
280
|
} else {
|
|
255
281
|
report.needsYou.unshift({ text: `⚠️ Sign-off is STALE (suite changed since ${state.signoff.at}) — re-review and sign off again.`, anchor: { vpId: 'OB-signoff' } });
|
|
256
282
|
}
|
|
@@ -273,6 +299,42 @@ export function runJourney(projectRoot: string, unit: string): JourneyReport {
|
|
|
273
299
|
return report;
|
|
274
300
|
}
|
|
275
301
|
|
|
302
|
+
/** The single human gate (DOS §3.4, S5): given with `--signoff`, never waived (#936). */
|
|
303
|
+
export const SIGNOFF_OBLIGATION = 'OB-signoff';
|
|
304
|
+
export const signoffCommand = (unit: string): string => `sungen journey --screen ${unit} --signoff --by "<name>"`;
|
|
305
|
+
|
|
306
|
+
/** "Never audited" (T3): a unit is measured by `sungen audit`, never waived through (review W2). */
|
|
307
|
+
export const CREATE_OBLIGATION = 'OB-create';
|
|
308
|
+
|
|
309
|
+
/** Why a waiver can never accept `obId` (and what does instead), or null when it can. */
|
|
310
|
+
function unwaivableWhy(obId: string, unit: string): string | null {
|
|
311
|
+
if (obId === SIGNOFF_OBLIGATION) return `sign-off is given with --signoff, never waived (${signoffCommand(unit)})`;
|
|
312
|
+
if (obId === CREATE_OBLIGATION) return `an unaudited unit cannot be waived through — re-run \`sungen audit --screen ${unit}\``;
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The review-queue line for a recorded waiver the board does not honour. */
|
|
317
|
+
const ignoredWaiverText = (obId: string, why: string, reason: string): string => `⚠️ waiver on ${obId} ignored: ${why}. Was: ${reason}`;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Throws unless `obId` is an obligation of the board that a waiver can accept. Refused in every state,
|
|
321
|
+
* each with what does it instead: OB-signoff (before any audit it is not on the board, after one a
|
|
322
|
+
* waiver would let the human gate be waived away) and OB-create (an unaudited unit would pass every
|
|
323
|
+
* gate on a waiver; the audit measures it).
|
|
324
|
+
*/
|
|
325
|
+
export function assertWaivable(report: JourneyReport, unit: string, obId: string): void {
|
|
326
|
+
if (obId === SIGNOFF_OBLIGATION) {
|
|
327
|
+
throw new Error(`${SIGNOFF_OBLIGATION} is the human sign-off — it is given, not waived. Once every other obligation is satisfied or waived, a human runs: ${signoffCommand(unit)}`);
|
|
328
|
+
}
|
|
329
|
+
if (obId === CREATE_OBLIGATION) {
|
|
330
|
+
throw new Error(`${CREATE_OBLIGATION} means the unit has no audit that counts — an unaudited unit cannot be waived through. Run: sungen audit --screen ${unit}`);
|
|
331
|
+
}
|
|
332
|
+
const valid = report.obligations.map((o) => o.id).filter((id) => id !== SIGNOFF_OBLIGATION && id !== CREATE_OBLIGATION);
|
|
333
|
+
if (!valid.includes(obId)) {
|
|
334
|
+
throw new Error(`Unknown obligation "${obId}". Valid: ${valid.join(', ') || `none — the unit has no audit that counts; run sungen audit --screen ${unit}`}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
276
338
|
/**
|
|
277
339
|
* Waive an obligation — REQUIRES a reason (anti-amnesia: a waiver leaves a recorded "why").
|
|
278
340
|
* Records the current evidence cursor so reconcile can invalidate it if the audit changes.
|
|
@@ -283,10 +345,7 @@ export function waive(projectRoot: string, unit: string, obId: string, reason: s
|
|
|
283
345
|
}
|
|
284
346
|
const slug = reportSlug(unit);
|
|
285
347
|
const fresh = runJourney(projectRoot, unit); // stops on a pending migration; imports legacy waivers first
|
|
286
|
-
|
|
287
|
-
if (!valid.includes(obId)) {
|
|
288
|
-
throw new Error(`Unknown obligation "${obId}". Valid: ${valid.join(', ')}`);
|
|
289
|
-
}
|
|
348
|
+
assertWaivable(fresh, unit, obId);
|
|
290
349
|
const given = { reason: reason.trim(), at: new Date().toISOString(), auditHashAtWaive: auditHashOf(projectRoot, slug) };
|
|
291
350
|
recordWaiver(projectRoot, unit, obId, given); // DOS-13.5: the decision layer is the record …
|
|
292
351
|
return runJourney(projectRoot, unit); // … and runJourney dual-writes it to the legacy file
|
|
@@ -297,8 +356,8 @@ export function waive(projectRoot: string, unit: string, obId: string, reason: s
|
|
|
297
356
|
* obligation is satisfied or waived (you cannot sign off an unclean suite). Records who/when +
|
|
298
357
|
* the evidence cursor; reconcile invalidates it (stale → re-review) if the suite later changes.
|
|
299
358
|
*/
|
|
300
|
-
|
|
301
|
-
|
|
359
|
+
/** Throws unless the board may be signed off: no decision problem, every other obligation accepted. */
|
|
360
|
+
export function assertCanSignOff(before: JourneyReport): void {
|
|
302
361
|
const blockers = before.obligations.filter((o) => o.id !== 'OB-signoff' && !accepted(o.status));
|
|
303
362
|
if (before.decisionFindings?.length) {
|
|
304
363
|
throw new Error(`Cannot sign off — ${before.decisionFindings.length} decision problem(s): ${before.decisionFindings.map((f) => `${f.rule} ${f.path}`).join(', ')}.`);
|
|
@@ -306,10 +365,26 @@ export function signoff(projectRoot: string, unit: string, by?: string): Journey
|
|
|
306
365
|
if (blockers.length) {
|
|
307
366
|
throw new Error(`Cannot sign off — still open: ${blockers.map((b) => b.id).join(', ')}. Fix, or waive each with a reason, first.`);
|
|
308
367
|
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Who signs off: `--by`, else the git user; throws when neither names anyone (never `by: unknown`). */
|
|
371
|
+
export function signoffSigner(projectRoot: string, unit: string, by?: string): { signer: string; gitUser: string } {
|
|
372
|
+
const gitUser = gitUserName(projectRoot);
|
|
373
|
+
const signer = by?.trim() || gitUser;
|
|
374
|
+
if (signer === 'unknown') {
|
|
375
|
+
throw new Error(`Cannot sign off — no git user.name is set and no --by was given, so the sign-off would name nobody. Say who signs: ${signoffCommand(unit)}`);
|
|
376
|
+
}
|
|
377
|
+
return { signer, gitUser };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function signoff(projectRoot: string, unit: string, by?: string): JourneyReport {
|
|
381
|
+
// Attribution is not self-declared (review item 4): the git user is always on the record.
|
|
382
|
+
const { signer, gitUser } = signoffSigner(projectRoot, unit, by);
|
|
383
|
+
assertCanSignOff(runJourney(projectRoot, unit));
|
|
309
384
|
const slug = reportSlug(unit);
|
|
310
385
|
const sp = statePath(projectRoot, slug);
|
|
311
386
|
const state = loadState(sp) || { unit, auditHash: '', waivers: {} };
|
|
312
|
-
state.signoff = { by, at: new Date().toISOString(), auditHashAtSignoff: auditHashOf(projectRoot, slug) };
|
|
387
|
+
state.signoff = { by: signer, ...(signer !== gitUser ? { recordedBy: gitUser } : {}), at: new Date().toISOString(), auditHashAtSignoff: auditHashOf(projectRoot, slug) };
|
|
313
388
|
saveState(sp, state);
|
|
314
389
|
return runJourney(projectRoot, unit);
|
|
315
390
|
}
|
|
@@ -432,7 +507,14 @@ export function renderGate(v: GateVerdict): string {
|
|
|
432
507
|
if (v.blockers.length) {
|
|
433
508
|
L.push(v.phaseOverriddenBy ? ' Open obligations:' : ' Blocking obligations (must be satisfied or explicitly waived):');
|
|
434
509
|
for (const b of v.blockers) L.push(` • ${b.id} ${b.title} — ${b.detail}`);
|
|
435
|
-
|
|
510
|
+
// Name what each kind of blocker takes (#936): never offer to waive the sign-off or "never audited".
|
|
511
|
+
const waivable = v.blockers.map((b) => b.id).filter((id) => id.startsWith('OB-') && id !== SIGNOFF_OBLIGATION && id !== 'OB-create');
|
|
512
|
+
if (waivable.length) {
|
|
513
|
+
L.push(` → Self-correct (repair / run-test), or, if accepted, waive with a reason: \`sungen journey --screen ${v.unit} --waive ${waivable.length === 1 ? waivable[0] : `<${waivable.join('|')}>`} --reason "..."\`.`);
|
|
514
|
+
}
|
|
515
|
+
if (v.blockers.some((b) => b.id === SIGNOFF_OBLIGATION)) {
|
|
516
|
+
L.push(` → Human sign-off: once everything else is satisfied or waived, a human runs \`${signoffCommand(v.unit)}\`.`);
|
|
517
|
+
}
|
|
436
518
|
} else {
|
|
437
519
|
L.push(' All required obligations satisfied' + (v.waivedCredit.length ? ` (${v.waivedCredit.length} accepted via waiver)` : '') + '.');
|
|
438
520
|
}
|
|
@@ -1,22 +1,110 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Provenance stamp for deterministic reports (score-determinism D1)
|
|
2
|
+
* Provenance stamp for deterministic reports (score-determinism D1), and the RULES the audit's
|
|
3
|
+
* verdict was judged by (#933, owner decision W4).
|
|
3
4
|
*
|
|
4
|
-
* The audit score is a pure function of the project files + the bundled
|
|
5
|
-
* Stamping
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* The audit score is a pure function of the project files + the bundled catalogs + the scoring
|
|
6
|
+
* code. Stamping them into the report lets two users who get different scores see IMMEDIATELY
|
|
7
|
+
* whether the cause is a rules mismatch rather than a real difference in their test design — and
|
|
8
|
+
* lets `verifyAuditReport` tell a report judged by today's rules from one judged by other rules:
|
|
9
|
+
*
|
|
10
|
+
* sungenVersion which package wrote it — informational: a newer sungen with the same rules
|
|
11
|
+
* gives the same verdict, so the version alone never makes a report stale
|
|
12
|
+
* catalogHash sha256(universal-viewpoints.yaml), first 12 hex — the viewpoint catalog
|
|
13
|
+
* rulesHash sha256(AUDIT_RULES_VERSION + every catalog file under harness/catalog/ + each
|
|
14
|
+
* bundled driver's sources/catalogs), first 12 hex — everything the verdict
|
|
15
|
+
* depends on that is not the project's own files. No package VERSION is hashed:
|
|
16
|
+
* packages are lockstep-versioned, so a release would stale every report
|
|
9
17
|
*/
|
|
10
18
|
import * as fs from 'fs';
|
|
11
19
|
import * as path from 'path';
|
|
12
20
|
import * as crypto from 'crypto';
|
|
13
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The version of the audit's SCORING CODE. Bump it (by one) in the same change whenever what
|
|
24
|
+
* `runAudit` computes for the same project can change: a sensor, a threshold, a gate term
|
|
25
|
+
* (audit-gate-terms.ts), the score model or weights, a finding that feeds the verdict, or the
|
|
26
|
+
* audit sensor of a bundled driver. Every report scored before the bump then reads stale and asks
|
|
27
|
+
* for one `sungen audit`. Catalog edits (harness/catalog/*.yaml) and bundled-driver changes (their
|
|
28
|
+
* sources and catalogs are hashed) need no bump.
|
|
29
|
+
* A release that changes none of these keeps every report trusted across the upgrade.
|
|
30
|
+
* Enforced by tests/audit/run.ts: its rules lock (tests/audit/audit-rules.lock.json) fails when the
|
|
31
|
+
* fixtures' audit output changes under an unchanged rules hash. After the bump, re-record it:
|
|
32
|
+
* npx tsx tests/audit/run.ts --record-rules-lock
|
|
33
|
+
*/
|
|
34
|
+
export const AUDIT_RULES_VERSION = 1;
|
|
35
|
+
|
|
14
36
|
export interface Provenance {
|
|
15
37
|
sungenVersion: string;
|
|
16
38
|
catalogHash: string; // sha256(universal-viewpoints.yaml), first 12 hex — the catalog that drives the score
|
|
39
|
+
rulesHash: string; // sha256(AUDIT_RULES_VERSION + harness/catalog/*.yaml + bundled drivers), first 12 hex — the verdict's rules
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A bundled driver as an input of the rules: its package dir, or null when it is not installed. */
|
|
43
|
+
export interface DriverRulesInput { name: string; version?: string; dir: string | null }
|
|
44
|
+
|
|
45
|
+
/** What is not a driver's rule: dependencies, build output, tests, docs, editor and lock files. */
|
|
46
|
+
const SKIP_DIR = new Set(['node_modules', 'dist', 'tests', 'test', 'coverage']);
|
|
47
|
+
const SKIP_FILE = /^(?:\.|package(?:-lock)?\.json$|tsconfig.*\.json$|.*\.(?:md|tsbuildinfo)$)/;
|
|
48
|
+
|
|
49
|
+
function driverFiles(root: string, rel = ''): string[] {
|
|
50
|
+
const out: string[] = [];
|
|
51
|
+
for (const e of fs.readdirSync(path.join(root, rel), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, 'en'))) {
|
|
52
|
+
if (e.name.startsWith('.')) continue;
|
|
53
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
54
|
+
if (e.isDirectory()) { if (!SKIP_DIR.has(e.name)) out.push(...driverFiles(root, r)); }
|
|
55
|
+
else if (e.isFile() && !SKIP_FILE.test(e.name)) out.push(r);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The package root above a driver's resolved entry file (the dir whose package.json names it). */
|
|
61
|
+
function packageRootOf(entry: string, name: string): string | null {
|
|
62
|
+
let dir = path.dirname(entry);
|
|
63
|
+
for (let i = 0; i < 8; i++) {
|
|
64
|
+
try { if (JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))?.name === name) return dir; } catch { /* keep walking */ }
|
|
65
|
+
const parent = path.dirname(dir);
|
|
66
|
+
if (parent === dir) break;
|
|
67
|
+
dir = parent;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
17
70
|
}
|
|
18
71
|
|
|
72
|
+
/** Each bundled driver as discovery would load it (project anchor, then core's), or `absent`. */
|
|
73
|
+
export function bundledDriverRulesInputs(): DriverRulesInput[] {
|
|
74
|
+
// Lazy: discovery pulls the capability registry, which nothing else here needs.
|
|
75
|
+
const { bundledDriverNames, resolveDriverCopy } = require('../capabilities/discover') as typeof import('../capabilities/discover');
|
|
76
|
+
return bundledDriverNames().map((name) => {
|
|
77
|
+
const copy = resolveDriverCopy(name);
|
|
78
|
+
const dir = copy ? packageRootOf(copy.resolved, name) : null;
|
|
79
|
+
return { name, version: copy?.version, dir };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const CATALOG_DIR = path.join(__dirname, 'catalog');
|
|
84
|
+
const sha12 = (s: string | Buffer): string => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The rules hash: the scoring version, every *.yaml of the catalog dir (by name), and each bundled
|
|
88
|
+
* driver — every source / catalog file of its package (by relative path; build output, tests and
|
|
89
|
+
* dependencies excluded), or a stable `absent` marker when it is not installed. A driver's version is
|
|
90
|
+
* deliberately left out: releases bump every package in lockstep, and only content changes the rules.
|
|
91
|
+
*/
|
|
92
|
+
export function rulesHashOf(rulesVersion: number, catalogDir: string = CATALOG_DIR, drivers: DriverRulesInput[] = bundledDriverRulesInputs()): string {
|
|
93
|
+
const files = fs.readdirSync(catalogDir).filter((f) => f.endsWith('.yaml')).sort();
|
|
94
|
+
const h = crypto.createHash('sha256').update(`audit-rules:${rulesVersion}\n`);
|
|
95
|
+
for (const f of files) h.update(`${f}\n`).update(fs.readFileSync(path.join(catalogDir, f))).update('\n');
|
|
96
|
+
for (const d of [...drivers].sort((a, b) => a.name.localeCompare(b.name, 'en'))) {
|
|
97
|
+
h.update(`driver:${d.name}\n`);
|
|
98
|
+
if (!d.dir) { h.update('absent\n'); continue; }
|
|
99
|
+
for (const f of driverFiles(d.dir)) h.update(`${f}\n`).update(fs.readFileSync(path.join(d.dir, f))).update('\n');
|
|
100
|
+
}
|
|
101
|
+
return h.digest('hex').slice(0, 12);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let cached: Provenance | null = null;
|
|
105
|
+
|
|
19
106
|
export function getProvenance(): Provenance {
|
|
107
|
+
if (cached) return cached;
|
|
20
108
|
let sungenVersion = 'unknown';
|
|
21
109
|
try {
|
|
22
110
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf-8'));
|
|
@@ -24,10 +112,10 @@ export function getProvenance(): Provenance {
|
|
|
24
112
|
} catch { /* version stays 'unknown' */ }
|
|
25
113
|
|
|
26
114
|
let catalogHash = 'unknown';
|
|
27
|
-
try {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
} catch { /* hash stays 'unknown' */ }
|
|
115
|
+
try { catalogHash = sha12(fs.readFileSync(path.join(CATALOG_DIR, 'universal-viewpoints.yaml'), 'utf-8')); } catch { /* hash stays 'unknown' */ }
|
|
116
|
+
let rulesHash = 'unknown';
|
|
117
|
+
try { rulesHash = rulesHashOf(AUDIT_RULES_VERSION); } catch { /* hash stays 'unknown' */ }
|
|
31
118
|
|
|
32
|
-
|
|
119
|
+
cached = { sungenVersion, catalogHash, rulesHash };
|
|
120
|
+
return cached;
|
|
33
121
|
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* — the CLI still writes the last two itself; the guard only judges an agent's tool calls
|
|
11
11
|
* trace evidence — DENY an agent write (phase 08; the verdict trusts it only as far as this guard and git review):
|
|
12
12
|
* .sungen/trace-identity/** (the driver's identity answers — written by the CLI)
|
|
13
|
-
* test-results/sungen-run-record*.json
|
|
13
|
+
* test-results[-free]/sungen-run-record*.json (run id + test-file hashes — written by the Playwright reporter;
|
|
14
|
+
* test-results-free/ is the free run's own outputDir, #941)
|
|
14
15
|
* hook control — ASK before an agent write (the agent must not switch its own guard off):
|
|
15
16
|
* qa/sungen.config.yaml · .claude/settings.json · .claude/settings.local.json ·
|
|
16
17
|
* .claude/hooks/sungen-hook.cjs
|
|
@@ -25,7 +26,7 @@ const LOCK = /^qa\/(?:.+\/)?contracts\.lock\.yaml$/i;
|
|
|
25
26
|
const DECISION = /^qa\/(?:(?:screens|flows|api(?:\/flows)?)\/[^/]+\/)?decisions\/[^/]+/i;
|
|
26
27
|
const APPROVAL_STATE = /^\.sungen\/(?:journey\/[^/]+\.state\.json|viewpoint-baseline\.json)$/i;
|
|
27
28
|
/** Trace evidence (phase 08, DOS-14.6): the driver's identity answers and the run records. */
|
|
28
|
-
const TRACE_EVIDENCE = /^(?:\.sungen\/trace-identity\/.+|test-results
|
|
29
|
+
const TRACE_EVIDENCE = /^(?:\.sungen\/trace-identity\/.+|test-results(?:-free)?\/sungen-run-record(?:\.[A-Za-z0-9._-]+)?\.json)$/i;
|
|
29
30
|
const HOOK_CONTROL = ['qa/sungen.config.yaml', '.claude/settings.json', '.claude/settings.local.json', '.claude/hooks/sungen-hook.cjs'];
|
|
30
31
|
|
|
31
32
|
/** The same paths as they appear INSIDE a command string (e.g. in `bash -c "…"` or `python -c "…"`). */
|
package/src/hooks/guard-rules.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* protected-approval-paths DENY a precise write (Edit/Write/MultiEdit, or Bash `>`, `tee`,
|
|
6
6
|
* `sed -i`) to a contract lock, a decision record, approval state or
|
|
7
|
-
* trace evidence (.sungen/trace-identity, test-results/sungen-run-record*)
|
|
7
|
+
* trace evidence (.sungen/trace-identity, test-results[-free]/sungen-run-record*)
|
|
8
8
|
* protected-hook-control ASK the same writes to the files that control the hooks, so the
|
|
9
9
|
* agent cannot switch its own guard off unasked
|
|
10
10
|
* bash-protected-mention ASK any other Bash segment that names a protected path and does not
|