@sun-asterisk/sungen 3.3.0-alpha.6 → 3.3.0-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/contract-derive-command.d.ts.map +1 -1
- package/dist/cli/commands/contract-derive-command.js +2 -0
- package/dist/cli/commands/contract-derive-command.js.map +1 -1
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +21 -3
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/cli/commands/generate.d.ts.map +1 -1
- package/dist/cli/commands/generate.js +3 -1
- package/dist/cli/commands/generate.js.map +1 -1
- package/dist/cli/commands/next.d.ts +4 -2
- package/dist/cli/commands/next.d.ts.map +1 -1
- package/dist/cli/commands/next.js +7 -0
- package/dist/cli/commands/next.js.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +6 -2
- package/dist/cli/commands/update.js.map +1 -1
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/json-error-on-exit.d.ts +34 -0
- package/dist/cli/json-error-on-exit.d.ts.map +1 -0
- package/dist/cli/json-error-on-exit.js +63 -0
- package/dist/cli/json-error-on-exit.js.map +1 -0
- package/dist/cli/waiting-for-you-block.d.ts +15 -0
- package/dist/cli/waiting-for-you-block.d.ts.map +1 -0
- package/dist/cli/waiting-for-you-block.js +19 -0
- package/dist/cli/waiting-for-you-block.js.map +1 -0
- package/dist/contract/derive-free-feature.d.ts +2 -0
- package/dist/contract/derive-free-feature.d.ts.map +1 -1
- package/dist/contract/derive-free-feature.js +22 -0
- package/dist/contract/derive-free-feature.js.map +1 -1
- package/dist/decisions/legacy-waiver-migration.d.ts +2 -0
- package/dist/decisions/legacy-waiver-migration.d.ts.map +1 -1
- package/dist/decisions/legacy-waiver-migration.js +5 -2
- package/dist/decisions/legacy-waiver-migration.js.map +1 -1
- package/dist/graph/board-human-actions.d.ts +5 -0
- package/dist/graph/board-human-actions.d.ts.map +1 -0
- package/dist/graph/board-human-actions.js +83 -0
- package/dist/graph/board-human-actions.js.map +1 -0
- package/dist/graph/evaluate-graph.d.ts +16 -0
- package/dist/graph/evaluate-graph.d.ts.map +1 -1
- package/dist/graph/evaluate-graph.js +29 -6
- package/dist/graph/evaluate-graph.js.map +1 -1
- package/dist/graph/human-action.d.ts +21 -0
- package/dist/graph/human-action.d.ts.map +1 -0
- package/dist/graph/human-action.js +3 -0
- package/dist/graph/human-action.js.map +1 -0
- package/dist/graph/node-decl.d.ts +9 -0
- package/dist/graph/node-decl.d.ts.map +1 -1
- package/dist/graph/node-decl.js +4 -1
- package/dist/graph/node-decl.js.map +1 -1
- package/dist/graph/nodes/test-case-contract-nodes.d.ts.map +1 -1
- package/dist/graph/nodes/test-case-contract-nodes.js +16 -3
- package/dist/graph/nodes/test-case-contract-nodes.js.map +1 -1
- package/dist/graph/nodes/test-case-nodes.d.ts.map +1 -1
- package/dist/graph/nodes/test-case-nodes.js +16 -4
- package/dist/graph/nodes/test-case-nodes.js.map +1 -1
- package/dist/graph/nodes/unit-audit-nodes.d.ts.map +1 -1
- package/dist/graph/nodes/unit-audit-nodes.js +1 -0
- package/dist/graph/nodes/unit-audit-nodes.js.map +1 -1
- package/dist/graph/nodes/unit-contract-node.d.ts.map +1 -1
- package/dist/graph/nodes/unit-contract-node.js +8 -0
- package/dist/graph/nodes/unit-contract-node.js.map +1 -1
- package/dist/harness/catalog/gate-rules.yaml +1 -1
- package/dist/harness/gates/delivery-checklist.d.ts.map +1 -1
- package/dist/harness/gates/delivery-checklist.js +9 -1
- package/dist/harness/gates/delivery-checklist.js.map +1 -1
- package/dist/harness/gates/in-run-decision-check.d.ts.map +1 -1
- package/dist/harness/gates/in-run-decision-check.js +10 -3
- package/dist/harness/gates/in-run-decision-check.js.map +1 -1
- package/dist/harness/journey.d.ts +21 -0
- package/dist/harness/journey.d.ts.map +1 -1
- package/dist/harness/journey.js +61 -11
- package/dist/harness/journey.js.map +1 -1
- package/dist/harness/next-step.d.ts +14 -0
- package/dist/harness/next-step.d.ts.map +1 -1
- package/dist/harness/next-step.js +12 -0
- package/dist/harness/next-step.js.map +1 -1
- package/dist/hooks/guard-tty-spoof-rule.d.ts +3 -2
- package/dist/hooks/guard-tty-spoof-rule.d.ts.map +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 +1 -1
- package/dist/hooks/hand-back-core.d.ts.map +1 -1
- package/dist/hooks/hand-back-core.js +4 -2
- package/dist/hooks/hand-back-core.js.map +1 -1
- package/dist/hooks/session-start-unit-state-hook.d.ts +2 -1
- package/dist/hooks/session-start-unit-state-hook.d.ts.map +1 -1
- package/dist/hooks/session-start-unit-state-hook.js +4 -1
- package/dist/hooks/session-start-unit-state-hook.js.map +1 -1
- package/dist/hooks/stop-next-step-hook.d.ts.map +1 -1
- package/dist/hooks/stop-next-step-hook.js +1 -0
- package/dist/hooks/stop-next-step-hook.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/delivery.md +2 -0
- package/dist/orchestrator/templates/ai-src/commands/review.md +2 -0
- package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.claude.md +11 -0
- package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.codex.md +11 -0
- package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.copilot.md +11 -0
- package/package.json +3 -3
- package/src/cli/commands/contract-derive-command.ts +1 -0
- package/src/cli/commands/delivery.ts +20 -3
- package/src/cli/commands/generate.ts +3 -1
- package/src/cli/commands/next.ts +7 -2
- package/src/cli/commands/update.ts +6 -2
- package/src/cli/index.ts +4 -0
- package/src/cli/json-error-on-exit.ts +72 -0
- package/src/cli/waiting-for-you-block.ts +23 -0
- package/src/contract/derive-free-feature.ts +21 -0
- package/src/decisions/legacy-waiver-migration.ts +5 -1
- package/src/graph/board-human-actions.ts +80 -0
- package/src/graph/evaluate-graph.ts +37 -8
- package/src/graph/human-action.ts +33 -0
- package/src/graph/node-decl.ts +11 -0
- package/src/graph/nodes/test-case-contract-nodes.ts +20 -4
- package/src/graph/nodes/test-case-nodes.ts +19 -5
- package/src/graph/nodes/unit-audit-nodes.ts +2 -1
- package/src/graph/nodes/unit-contract-node.ts +12 -1
- package/src/harness/catalog/gate-rules.yaml +1 -1
- package/src/harness/gates/delivery-checklist.ts +11 -1
- package/src/harness/gates/in-run-decision-check.ts +11 -4
- package/src/harness/journey.ts +79 -8
- package/src/harness/next-step.ts +24 -1
- package/src/hooks/guard-tty-spoof-rule.ts +5 -4
- package/src/hooks/hand-back-core.ts +4 -2
- package/src/hooks/session-start-unit-state-hook.ts +4 -1
- package/src/hooks/stop-next-step-hook.ts +1 -0
- package/src/orchestrator/templates/ai-src/commands/delivery.md +2 -0
- package/src/orchestrator/templates/ai-src/commands/review.md +2 -0
- package/src/orchestrator/templates/ai-src/partials/waiting-for-you.claude.md +11 -0
- package/src/orchestrator/templates/ai-src/partials/waiting-for-you.codex.md +11 -0
- package/src/orchestrator/templates/ai-src/partials/waiting-for-you.copilot.md +11 -0
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* unit.gate-fail), #940; tc.contract-approve (changed, not weaker) is review and waits behind it. Exempt cases (DOS-11.2) open nothing. A proposed case (never approved) asks nothing of its own: a
|
|
17
17
|
* compiled test without a lock is never blocked, and the unit view offers the approval.
|
|
18
18
|
*/
|
|
19
|
-
import { ALL_KINDS, StepCore, TestCaseNodeDecl } from '../node-decl';
|
|
19
|
+
import { ALL_KINDS, humanStep, StepCore, TestCaseNodeDecl } from '../node-decl';
|
|
20
20
|
import type { UnitState } from '../../harness/next-step';
|
|
21
|
+
import type { HumanAction } from '../human-action';
|
|
21
22
|
import type { TestCaseState } from '../test-case-state-shape';
|
|
22
23
|
import { exemptionOf } from '../test-case-satisfied';
|
|
23
24
|
import { idsArg, listed } from './test-case-nodes';
|
|
@@ -31,7 +32,7 @@ const withDetail = (cases: TestCaseState[]): string =>
|
|
|
31
32
|
const check = (s: UnitState, trace = false): string => `sungen contract check --screen ${s.unit}${trace ? ' --trace' : ''}`;
|
|
32
33
|
|
|
33
34
|
function contractNode(id: string, rank: number, title: string, actor: TestCaseNodeDecl['actor'], gates: string[], caseOnly: boolean,
|
|
34
|
-
active: (tc: TestCaseState) => boolean, step: Step, hard?: (tc: TestCaseState) => boolean): TestCaseNodeDecl {
|
|
35
|
+
active: (tc: TestCaseState) => boolean, step: Step, hard?: (tc: TestCaseState) => boolean, human?: TestCaseNodeDecl['human']): TestCaseNodeDecl {
|
|
35
36
|
return {
|
|
36
37
|
id, title, actor, granularity: 'test-case', appliesTo: ALL_KINDS, blocking: false, rank, gates, step,
|
|
37
38
|
requires: caseOnly ? ['unit.gate-fail'] : ['unit.run'], produces: ['contracts.lock.yaml', 'decisions/<id>.yaml'],
|
|
@@ -39,9 +40,23 @@ function contractNode(id: string, rank: number, title: string, actor: TestCaseNo
|
|
|
39
40
|
active: (_s, tc) => active(tc),
|
|
40
41
|
satisfied: (_s, tc) => exemptionOf(tc) !== null,
|
|
41
42
|
...(hard ? { surfacesPastCut: (_s: UnitState, tc: TestCaseState) => hard(tc) } : {}),
|
|
43
|
+
...(human ? { human } : {}),
|
|
42
44
|
};
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
/**
|
|
48
|
+
* An opaque step is cleared by a QA accepting it as it reads now, never by re-approving (#923): one
|
|
49
|
+
* `--accept-opaque` per case whose only hold is the opaque step. A case held by a `low` identity is
|
|
50
|
+
* not acceptable yet (`contract check --trace` resolves it first), so it is not offered (#954).
|
|
51
|
+
*/
|
|
52
|
+
function acceptOpaque(s: UnitState, _step: StepCore, cases: TestCaseState[]): HumanAction[] {
|
|
53
|
+
return cases.filter((c) => classOf(c) === 'opaque' && !c.contract?.lowIdentity).map((c) => ({
|
|
54
|
+
kind: 'accept-opaque',
|
|
55
|
+
command: `sungen contract approve --screen ${s.unit} --scenario ${c.id} --accept-opaque`,
|
|
56
|
+
reason: `${c.id} carries a step sungen cannot compare${c.contract?.detail ? ` (${c.contract.detail})` : ''} — a QA reviews it verbatim with its file:line in \`${check(s)}\`, then accepts it as it reads now; until then the contract is in review and \`sungen delivery\` waits`,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
45
60
|
const conformanceStep: Step = (s, cases) => {
|
|
46
61
|
const low = cases.filter((c) => c.contract?.lowIdentity);
|
|
47
62
|
if (low.length) {
|
|
@@ -57,7 +72,8 @@ const conformanceStep: Step = (s, cases) => {
|
|
|
57
72
|
export const TEST_CASE_CONTRACT_NODES: readonly TestCaseNodeDecl[] = [
|
|
58
73
|
contractNode('tc.contract-approve', 106, 'Re-approve this changed case', 'human', ['contract.hash-changed'], true,
|
|
59
74
|
(tc) => classOf(tc) === 'hash-changed' && !tc.contract?.uncommitted,
|
|
60
|
-
(s, [c]) => ({ kind: 'decide', command: `sungen contract approve --screen ${s.unit} --scenario ${c.id}`, because: `this case changed since QA approved it, not weaker${c.contract?.detail ? ` (${c.contract.detail})` : ''} — a QA re-approves this scenario on a terminal; until then review blocks \`sungen delivery\`. An agent presents it and never approves its own edit` })
|
|
75
|
+
(s, [c]) => ({ kind: 'decide', command: `sungen contract approve --screen ${s.unit} --scenario ${c.id}`, because: `this case changed since QA approved it, not weaker${c.contract?.detail ? ` (${c.contract.detail})` : ''} — a QA re-approves this scenario on a terminal; until then review blocks \`sungen delivery\`. An agent presents it and never approves its own edit` }),
|
|
76
|
+
undefined, humanStep('contract-approve')),
|
|
61
77
|
contractNode('tc.contract-check', 107, 'Restore the approved check of this case', 'human',
|
|
62
78
|
['contract.oracle-weakened', 'contract.target-changed', 'contract.assertion-lost', 'contract.lock-uncommitted'], true,
|
|
63
79
|
(tc) => Boolean(tc.contract?.uncommitted) || HARD_LADDER.has(classOf(tc) ?? ''),
|
|
@@ -70,5 +86,5 @@ export const TEST_CASE_CONTRACT_NODES: readonly TestCaseNodeDecl[] = [
|
|
|
70
86
|
(s, cases) => ({ kind: 'do', command: `/sungen:run-test ${s.unit}${idsArg(cases)}`, because: `${cases.length} approved free / impl:code test case(s) have no trace that proves them (${withDetail(cases)}) — run them again with trace: 'on' and the sungen run-record reporter (run-test Phase 4 does), then \`${check(s, true)}\`. Until then \`sungen contract check\` fails (contract.unproven) and \`sungen delivery\` is blocked; re-approving or editing the .feature cannot prove them` }),
|
|
71
87
|
(tc) => classOf(tc) === 'unproven'),
|
|
72
88
|
contractNode('tc.conformance', 138, 'Settle the traces in review', 'ai', ['contract.opaque-step', 'contract.inconclusive'], false,
|
|
73
|
-
(tc) => classOf(tc) === 'opaque' || classOf(tc) === 'inconclusive', conformanceStep),
|
|
89
|
+
(tc) => classOf(tc) === 'opaque' || classOf(tc) === 'inconclusive', conformanceStep, undefined, acceptOpaque),
|
|
74
90
|
];
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* the reds the cases account for; unit.triage keeps the rest (unattributedReds), so a red no case
|
|
16
16
|
* explains — an @cases row, a renamed scenario, a nested describe title — still gets a triage step.
|
|
17
17
|
*/
|
|
18
|
-
import { ALL_KINDS, StepCore, TestCaseNodeDecl } from '../node-decl';
|
|
18
|
+
import { ALL_KINDS, humanStep, StepCore, TestCaseNodeDecl } from '../node-decl';
|
|
19
19
|
import type { UnitState } from '../../harness/next-step';
|
|
20
|
+
import type { HumanAction } from '../human-action';
|
|
20
21
|
import type { TestCaseState } from '../test-case-state-shape';
|
|
21
22
|
import { exemptionOf, TestCaseNeed, triggerOf } from '../test-case-satisfied';
|
|
22
23
|
|
|
@@ -31,15 +32,28 @@ const withRule = (cases: TestCaseState[]): string =>
|
|
|
31
32
|
`${cases.slice(0, 3).map((c) => `${c.id}: ${c.triage?.ruleId}`).join('; ')}${cases.length > 3 ? '; …' : ''}`;
|
|
32
33
|
|
|
33
34
|
function caseNode(id: string, need: TestCaseNeed, rank: number, title: string, actor: TestCaseNodeDecl['actor'],
|
|
34
|
-
step: (s: UnitState, cases: TestCaseState[], single: boolean) => StepCore, produces: string[]): TestCaseNodeDecl {
|
|
35
|
+
step: (s: UnitState, cases: TestCaseState[], single: boolean) => StepCore, produces: string[], human?: TestCaseNodeDecl['human']): TestCaseNodeDecl {
|
|
35
36
|
return {
|
|
36
37
|
id, title, actor, granularity: 'test-case', appliesTo: ALL_KINDS, requires: ['unit.run'], blocking: false, rank,
|
|
37
38
|
gates: [], produces, step,
|
|
38
39
|
active: (_s, tc) => triggerOf(tc) === need,
|
|
39
40
|
satisfied: (_s, tc) => exemptionOf(tc) !== null,
|
|
41
|
+
...(human ? { human } : {}),
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* A red triage left unclassified is a human's to classify (#928): test-defect and environment take it
|
|
47
|
+
* off the product-defect path, so they need a person on a terminal. One action per such case (#954).
|
|
48
|
+
*/
|
|
49
|
+
function classifyByHand(s: UnitState, _step: StepCore, cases: TestCaseState[]): HumanAction[] {
|
|
50
|
+
return cases.filter((c) => c.triage?.class === 'unclassified').map((c) => ({
|
|
51
|
+
kind: 'triage-classify',
|
|
52
|
+
command: `sungen triage ${flag(s)} ${s.unit} --case ${c.id} --as "<test-defect|environment>" --reason "…"`,
|
|
53
|
+
reason: `${c.id} failed and \`sungen triage\` left it unclassified — read its evidence (trace, error), then classify it: test-defect or environment take it off the product-defect path, so a human types them on a terminal (\`--as product\` anyone may record)`,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
43
57
|
export const TEST_CASE_NODES: readonly TestCaseNodeDecl[] = [
|
|
44
58
|
caseNode('tc.run', 'run', 131, 'Run the cases that never ran', 'ai', (s, cases) => ({
|
|
45
59
|
kind: 'do',
|
|
@@ -58,19 +72,19 @@ export const TEST_CASE_NODES: readonly TestCaseNodeDecl[] = [
|
|
|
58
72
|
command: `sungen triage ${flag(s)} ${s.unit}`,
|
|
59
73
|
because: `${cases.length} test case(s) failed in the latest run and are not classified yet${left ? ` (${left} left unclassified by the last triage — read its evidence and classify by hand: \`sungen triage ${flag(s)} ${s.unit} --case <id> --as product|test-defect|environment --reason "…"\`; test-defect and environment are a human's call, on a terminal)` : ''} — classify each (known defect, test defect, environment, product candidate) before exporting or extending: an unexplained red in the deliverable is a finding nobody has judged`,
|
|
60
74
|
};
|
|
61
|
-
}, ['.sungen/triage/<unit>.json']),
|
|
75
|
+
}, ['.sungen/triage/<unit>.json'], classifyByHand),
|
|
62
76
|
caseNode('tc.product-candidate', 'product-candidate', 134, 'Decide on the product-defect candidates', 'human', (s, cases, single) => ({
|
|
63
77
|
kind: 'decide',
|
|
64
78
|
command: `sungen defect draft ${flag(s)} ${s.unit}${single ? ` --scenario ${cases[0].id}` : ''}`,
|
|
65
79
|
because: `${cases.length} red test case(s) look like a product defect (${listed(cases)}) — the app answered and the answer differs from the oracle. A QA decides whether it is a defect to record; an agent presents it and does not draft it on its own`,
|
|
66
|
-
}), ['qa/defects/DEF-<n>-<slug>.md']),
|
|
80
|
+
}), ['qa/defects/DEF-<n>-<slug>.md'], humanStep('defect-draft')),
|
|
67
81
|
// decide, not do: an agent re-running into an environment that is still down gets the same red and
|
|
68
82
|
// the same step again — a loop with no stop. A human confirms the environment first (#928 review W3).
|
|
69
83
|
caseNode('tc.rerun-environment', 'rerun-environment', 139, 'Re-run the cases the environment failed, once it is up', 'human', (s, cases) => ({
|
|
70
84
|
kind: 'decide',
|
|
71
85
|
command: `/sungen:run-test ${s.unit}${idsArg(cases)}`,
|
|
72
86
|
because: `${cases.length} red test case(s) failed on the environment, not the app (${withRule(cases)}) — the run never reached a usable app, so their results say nothing about it yet. A human confirms the app, base URL, network, auth state or device is up, then they are re-run; an agent presents this and does not re-run on its own — into an environment that is still down, the same red comes back`,
|
|
73
|
-
}), ['specs/generated/<unit>/<unit>-test-result.json']),
|
|
87
|
+
}), ['specs/generated/<unit>/<unit>-test-result.json'], humanStep('rerun-environment')),
|
|
74
88
|
caseNode('tc.fix-test', 'fix-test', 139.5, 'Fix the tests triaged as test defects', 'ai', (s, cases) => ({
|
|
75
89
|
kind: 'do',
|
|
76
90
|
command: `/sungen:run-test ${s.unit}${idsArg(cases)}`,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* 9 unit.repair-findings findings with a known repair → repair + re-audit, names ≤ 4 codes
|
|
9
9
|
* 10 unit.gate-fail gateStatus FAIL → blocked, cuts
|
|
10
10
|
*/
|
|
11
|
-
import { ALL_KINDS, UnitNodeDecl } from '../node-decl';
|
|
11
|
+
import { ALL_KINDS, humanStep, UnitNodeDecl } from '../node-decl';
|
|
12
12
|
import type { UnitState } from '../../harness/next-step';
|
|
13
13
|
|
|
14
14
|
/** The finding codes worth naming in a next step — each maps to a specific repair. */
|
|
@@ -66,6 +66,7 @@ export const UNIT_AUDIT_NODES: readonly UnitNodeDecl[] = [
|
|
|
66
66
|
command: `sungen audit --screen ${s.unit} --accept-viewpoint`,
|
|
67
67
|
because: 'test-viewpoint.md changed since the accepted baseline, so the ledger and traceability axes are not evidence until a QA confirms the new declaration. A decision, not a step: an agent presents it and does not run it — accepting the yardstick you just wrote makes both axes read 100% by construction',
|
|
68
68
|
}),
|
|
69
|
+
human: humanStep('accept-viewpoint'),
|
|
69
70
|
},
|
|
70
71
|
{
|
|
71
72
|
id: 'unit.repair-findings', title: 'Repair the named findings', actor: 'ai', granularity: 'unit',
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* A free / impl:code scenario only its TRACE cannot prove — unproven, inconclusive, opaque — is not a
|
|
17
17
|
* unit decision: the case nodes say what proves it (test-case-contract-nodes.ts, phase 09).
|
|
18
18
|
*/
|
|
19
|
-
import { ALL_KINDS, StepCore, UnitNodeDecl, unitKindDir } from '../node-decl';
|
|
19
|
+
import { ALL_KINDS, humanStep, StepCore, UnitNodeDecl, unitKindDir } from '../node-decl';
|
|
20
20
|
import type { UnitState } from '../../harness/next-step';
|
|
21
|
+
import type { HumanActionKind } from '../human-action';
|
|
21
22
|
|
|
22
23
|
const HUMAN_ONLY = 'A QA decision on a terminal: an agent presents it and never approves its own edit';
|
|
23
24
|
const approveCmd = (s: UnitState): string => `sungen contract approve --screen ${s.unit}`;
|
|
@@ -50,6 +51,10 @@ function contractStep(s: UnitState): StepCore {
|
|
|
50
51
|
return { kind: 'decide', command: approveCmd(s), because: `${c.review} approved scenario(s) changed since QA approved them (changed, not weaker) — review blocks \`sungen delivery\` until QA re-approves. ${HUMAN_ONLY}` };
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/** What the human does for the step contractStep offered: approve, commit / restore through review, or read the verdict. */
|
|
55
|
+
const contractActionKind = (command: string): HumanActionKind =>
|
|
56
|
+
command.startsWith('sungen contract approve') ? 'contract-approve' : command.startsWith('sungen contract check') ? 'contract-review' : 'contract-commit';
|
|
57
|
+
|
|
53
58
|
const CONTRACT_RULES = ['contract.hash-changed', 'contract.oracle-weakened', 'contract.target-changed', 'contract.assertion-lost', 'contract.lock-uncommitted'];
|
|
54
59
|
/** The verdict is a pass and it was built: nothing blocks, nothing to repair. */
|
|
55
60
|
const verdictClean = (s: UnitState): boolean => !s.contract!.error && s.contract!.verdict === 'pass';
|
|
@@ -67,6 +72,11 @@ export const UNIT_CONTRACT_NODES: readonly UnitNodeDecl[] = [
|
|
|
67
72
|
active: (s) => Boolean(s.contract),
|
|
68
73
|
satisfied: nothingToDecide,
|
|
69
74
|
step: contractStep,
|
|
75
|
+
human: (_s, step) => {
|
|
76
|
+
const kind = contractActionKind(step.command);
|
|
77
|
+
// A lock repair is a commit (or `git checkout --`) after review — prose, never a sungen command to copy.
|
|
78
|
+
return [{ command: step.command, reason: step.because, kind, ...(kind === 'contract-commit' ? { instruction: true as const } : {}) }];
|
|
79
|
+
},
|
|
70
80
|
// A hard verdict must not wait behind a re-audit (#940): the audit's PASS says nothing about it —
|
|
71
81
|
// a broken approved check, or a lock that is not what QA approved and committed (contract.lock-
|
|
72
82
|
// uncommitted, hard in `contract check`; the step names that repair first). What stays behind the
|
|
@@ -85,5 +95,6 @@ export const UNIT_CONTRACT_NODES: readonly UnitNodeDecl[] = [
|
|
|
85
95
|
command: approveCmd(s),
|
|
86
96
|
because: `${s.contract!.proposed} scenario(s) have no approved contract yet — approving locks the checks QA reviewed, so a later edit that weakens one (a healer's included) fails \`sungen contract check\`. ${HUMAN_ONLY}; proposed scenarios do not block`,
|
|
87
97
|
}),
|
|
98
|
+
human: humanStep('contract-approve'),
|
|
88
99
|
},
|
|
89
100
|
];
|
|
@@ -586,7 +586,7 @@ rules:
|
|
|
586
586
|
severity: review
|
|
587
587
|
kind: hard
|
|
588
588
|
rationale: "A check on a JS value, API or canvas result is not tied to an element and cannot be compared automatically (DOS-14.6); it always goes to review and is locked at review."
|
|
589
|
-
fix: "QA reviews the verbatim step and its file:line, then
|
|
589
|
+
fix: "QA reviews the verbatim step and its file:line (`sungen contract check`), then accepts it as it reads now on a terminal: `sungen contract approve --screen <unit> --scenario <id> --accept-opaque`. A plain re-approve never clears it."
|
|
590
590
|
since: "p0"
|
|
591
591
|
offAllowed: false
|
|
592
592
|
- id: contract.nondeterministic
|
|
@@ -50,13 +50,23 @@ async function quietly<T>(fn: () => Promise<T>): Promise<T> {
|
|
|
50
50
|
try { return await fn(); } finally { Object.assign(console, saved); }
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** Findings a fresh `sungen generate` cures: the spec is out of step with the .feature, not hollow. */
|
|
54
|
+
const REGENERATE_CURES = /^(?:DRIFT|MISSING in spec|EXTRA in spec|Count mismatch)\b/;
|
|
55
|
+
|
|
56
|
+
/** The first finding's first sentence — its cause after "→" kept (#943) — and, when regenerating cures it, the command. */
|
|
57
|
+
function scriptCheckFailure(u: ChecklistUnit, finding = 'not faithful'): string {
|
|
58
|
+
const first = finding.split(/\. /)[0].slice(0, 240);
|
|
59
|
+
const generate = `sungen generate --${u.kind === 'flow' ? 'flow' : u.kind === 'api' ? 'api' : 'screen'} ${u.unitName}`;
|
|
60
|
+
return `FAIL — ${first}${REGENERATE_CURES.test(finding) ? ` — run \`${generate}\` after fixing the scenario` : ''} (\`sungen script-check\` exits 2)`;
|
|
61
|
+
}
|
|
62
|
+
|
|
53
63
|
async function scriptCheckBox(u: ChecklistUnit): Promise<ChecklistBox> {
|
|
54
64
|
try {
|
|
55
65
|
const r = await quietly(() => runScriptCheck(u.unitDir, u.unitName, u.kind));
|
|
56
66
|
if (!r.specPath) return box('script-check', 'skipped', 'no compiled spec to compare: nothing was compiled for this unit');
|
|
57
67
|
return r.status === 'OK'
|
|
58
68
|
? box('script-check', 'pass', `OK — the spec is a 1:1 of the .feature, no drift (${r.automatedScenarios} automated)`)
|
|
59
|
-
: box('script-check', 'fail',
|
|
69
|
+
: box('script-check', 'fail', scriptCheckFailure(u, r.findings[0]));
|
|
60
70
|
} catch (e) {
|
|
61
71
|
return box('script-check', 'fail', `could not run: ${why(e)}`);
|
|
62
72
|
}
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
* `humanConfirmed`, #928) is a claim the entry makes about itself — a script can write one. What backs
|
|
24
24
|
* it is review, so it is in-run while git HEAD does not hold it (and, with no git, always: nothing
|
|
25
25
|
* shows it was reviewed). Judged by HEAD membership only: it is always recorded after the run it
|
|
26
|
-
* judges, so its commit time says nothing.
|
|
26
|
+
* judges, so its commit time says nothing. Only a test-defect / environment entry that is not
|
|
27
|
+
* superseded counts — the only kind the reader puts in force on that claim.
|
|
27
28
|
*/
|
|
28
29
|
import * as fs from 'fs';
|
|
29
30
|
import * as path from 'path';
|
|
@@ -31,6 +32,7 @@ import { DECISION_FILE_PATH } from '../../decisions/decision-file';
|
|
|
31
32
|
import { DecisionVisibility } from '../../decisions/decision-schema';
|
|
32
33
|
import { gitOutput } from '../../decisions/git-head-trust';
|
|
33
34
|
import { readDecisions } from '../../decisions/read-decisions';
|
|
35
|
+
import { HUMAN_ONLY_TRIAGE, ManualTriageClass, TRIAGE_DECISION_KIND } from '../../graph/manual-triage-decisions';
|
|
34
36
|
import { migratedWaiverReview } from './migrated-waiver-review';
|
|
35
37
|
|
|
36
38
|
export const IN_RUN_KINDS = ['gate-override', 'waiver', 'contract-approval'] as const;
|
|
@@ -64,9 +66,14 @@ export function runStartOf(resultsPath: string | null): string | null {
|
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
const isInRunKind = (kind: string): boolean => (IN_RUN_KINDS as readonly string[]).includes(kind);
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
/**
|
|
70
|
+
* A triage classification claiming a human typed it (#928): reviewed only once git HEAD holds it.
|
|
71
|
+
* Only what the reader (manual-triage-decisions.ts) can put in force: a superseded entry is never read,
|
|
72
|
+
* and a product classification needs no human — its claim backs nothing, so it blocks nothing.
|
|
73
|
+
*/
|
|
74
|
+
const claimsHumanTriage = (e: { kind: string; payload?: Record<string, unknown>; superseded_by?: string }): boolean =>
|
|
75
|
+
e.kind === TRIAGE_DECISION_KIND && !e.superseded_by && e.payload?.humanConfirmed === true
|
|
76
|
+
&& HUMAN_ONLY_TRIAGE.has(e.payload?.class as ManualTriageClass);
|
|
70
77
|
|
|
71
78
|
/** In-run decisions among `scopes`, judged against one run start (null: HEAD membership only) and the committed set. */
|
|
72
79
|
export function inRunDecisions(projectRoot: string, scopes: string[], runStart: string | null, committed: Set<string> | null): InRunDecision[] {
|
package/src/harness/journey.ts
CHANGED
|
@@ -243,6 +243,52 @@ function computeNext(r: JourneyReport, unit: string): string {
|
|
|
243
243
|
* A read-only checkout gets the same board without throwing.
|
|
244
244
|
*/
|
|
245
245
|
export function runJourney(projectRoot: string, unit: string): JourneyReport {
|
|
246
|
+
const r = reconcileJourney(projectRoot, unit);
|
|
247
|
+
try {
|
|
248
|
+
commitWaiverSync(projectRoot, unit, r.slug, r.sync, r.statePath, JSON.stringify(r.next, null, 2));
|
|
249
|
+
} catch (err) {
|
|
250
|
+
if (!(err instanceof DecisionConflictError)) throw err;
|
|
251
|
+
const rel = path.relative(projectRoot, err.path).split(path.sep).join('/');
|
|
252
|
+
if (!r.decisionFindings.some((f) => f.path === rel)) r.decisionFindings.push({ rule: 'decision.mutated', path: rel, message: err.message });
|
|
253
|
+
}
|
|
254
|
+
return finishReport(r, unit);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A waiver the board asks a human to give again: its evidence moved (stale), or git HEAD does not hold it (untrusted). */
|
|
258
|
+
export interface WaiverToRedecide { obligation: string; title: string; reason: string; why: 'stale' | 'untrusted' }
|
|
259
|
+
|
|
260
|
+
/** What the board asks of a human, read without writing anything (`sungen next`'s "Waiting for you", #954). */
|
|
261
|
+
export interface JourneyHumanAsks {
|
|
262
|
+
report: JourneyReport;
|
|
263
|
+
redecide: WaiverToRedecide[];
|
|
264
|
+
/** OB-signoff: `pending` (never given), `stale` (given before the audit changed), `given`. */
|
|
265
|
+
signoff: { status: 'pending' | 'stale' | 'given'; at?: string };
|
|
266
|
+
/** `sungen journey --signoff` would be accepted now (assertCanSignOff holds). */
|
|
267
|
+
canSignOff: boolean;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The board's asks, read-only: no import, no dual-write, no state file — the same reconcile `runJourney` commits. */
|
|
271
|
+
export function readJourneyHumanAsks(projectRoot: string, unit: string): JourneyHumanAsks {
|
|
272
|
+
const r = reconcileJourney(projectRoot, unit);
|
|
273
|
+
const report = finishReport(r, unit);
|
|
274
|
+
let canSignOff = report.obligations.some((o) => o.id === SIGNOFF_OBLIGATION);
|
|
275
|
+
try { assertCanSignOff(report); } catch { canSignOff = false; }
|
|
276
|
+
return { report, redecide: r.redecide, signoff: r.signoff, canSignOff };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface Reconciled {
|
|
280
|
+
report: JourneyReport;
|
|
281
|
+
slug: string;
|
|
282
|
+
statePath: string;
|
|
283
|
+
sync: ReturnType<typeof planWaiverSync>;
|
|
284
|
+
next: JourneyState;
|
|
285
|
+
decisionFindings: DecisionGateFinding[];
|
|
286
|
+
redecide: WaiverToRedecide[];
|
|
287
|
+
signoff: JourneyHumanAsks['signoff'];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Compute fresh, apply the waivers and the sign-off in force — read-only; `runJourney` commits the plan. */
|
|
291
|
+
function reconcileJourney(projectRoot: string, unit: string): Reconciled {
|
|
246
292
|
const slug = reportSlug(unit);
|
|
247
293
|
assertNoPendingMigration(projectRoot, slug);
|
|
248
294
|
const report = computeFresh(projectRoot, unit);
|
|
@@ -250,9 +296,11 @@ export function runJourney(projectRoot: string, unit: string): JourneyReport {
|
|
|
250
296
|
const state = loadState(sp) || { unit, auditHash: '', waivers: {} };
|
|
251
297
|
const curHash = auditHashOf(projectRoot, slug);
|
|
252
298
|
const sync = planWaiverSync(projectRoot, unit, slug, sp, state.waivers);
|
|
299
|
+
const redecide: WaiverToRedecide[] = [];
|
|
253
300
|
for (const u of sync.untrusted) {
|
|
254
301
|
const title = report.obligations.find((o) => o.id === u.obligation)?.title ?? u.obligation;
|
|
255
302
|
const ignored = unwaivableWhy(u.obligation, unit);
|
|
303
|
+
if (!ignored) redecide.push({ obligation: u.obligation, title, reason: u.waiver.reason, why: 'untrusted' });
|
|
256
304
|
report.needsYou.unshift({ text: ignored ? ignoredWaiverText(u.obligation, ignored, u.waiver.reason) : untrustedWaiverText(unit, slug, title, u), anchor: { vpId: u.obligation } });
|
|
257
305
|
}
|
|
258
306
|
|
|
@@ -267,30 +315,33 @@ export function runJourney(projectRoot: string, unit: string): JourneyReport {
|
|
|
267
315
|
ob.waivedReason = w.reason;
|
|
268
316
|
ob.detail = `waived — ${w.reason}`;
|
|
269
317
|
} else {
|
|
318
|
+
redecide.push({ obligation: ob.id, title: ob.title, reason: w.reason, why: 'stale' });
|
|
270
319
|
report.needsYou.unshift({ text: `⚠️ Waiver on "${ob.title}" is STALE (evidence changed since ${w.at}) — re-decide. Was: ${w.reason}`, anchor: { vpId: ob.id } });
|
|
271
320
|
}
|
|
272
321
|
}
|
|
273
322
|
// S5 — sign-off reconcile: a recorded sign-off satisfies OB-signoff while its evidence holds;
|
|
274
323
|
// if the audit changed since, the sign-off is STALE → re-surface for re-review (anti-amnesia).
|
|
275
324
|
const signoffOb = report.obligations.find((o) => o.id === 'OB-signoff');
|
|
325
|
+
let signoff: Reconciled['signoff'] = { status: 'pending' };
|
|
276
326
|
if (signoffOb && state.signoff) {
|
|
277
327
|
if (state.signoff.auditHashAtSignoff === curHash) {
|
|
328
|
+
signoff = { status: 'given', at: state.signoff.at };
|
|
278
329
|
signoffOb.status = 'satisfied';
|
|
279
330
|
signoffOb.detail = `signed off${state.signoff.by ? ' by ' + state.signoff.by : ''}${state.signoff.recordedBy ? ` (recorded by ${state.signoff.recordedBy})` : ''} at ${state.signoff.at}`;
|
|
280
331
|
} else {
|
|
332
|
+
signoff = { status: 'stale', at: state.signoff.at };
|
|
281
333
|
report.needsYou.unshift({ text: `⚠️ Sign-off is STALE (suite changed since ${state.signoff.at}) — re-review and sign off again.`, anchor: { vpId: 'OB-signoff' } });
|
|
282
334
|
}
|
|
283
335
|
}
|
|
284
336
|
// Phase-03 carry-over: a decision that does not verify is a FINDING the gate blocks on, not a crash.
|
|
285
337
|
const decisionFindings = journeyDecisionFindings(projectRoot, unitOverrides(projectRoot, unit));
|
|
286
338
|
const next: JourneyState = { unit, auditHash: curHash, waivers: sync.stateWaivers, signoff: state.signoff };
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
339
|
+
return { report, slug, statePath: sp, sync, next, decisionFindings, redecide, signoff };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The decision findings on the board, then the next suggestion — after the waivers and the sign-off. */
|
|
343
|
+
function finishReport(r: Reconciled, unit: string): JourneyReport {
|
|
344
|
+
const { report, decisionFindings } = r;
|
|
294
345
|
if (decisionFindings.length) {
|
|
295
346
|
report.decisionFindings = decisionFindings;
|
|
296
347
|
for (const f of [...decisionFindings].reverse()) report.needsYou.unshift({ text: decisionFindingText(f), anchor: { file: f.path } });
|
|
@@ -408,11 +459,31 @@ export function writeJourneyFileBestEffort(file: string, text: string): string |
|
|
|
408
459
|
|
|
409
460
|
const ICON: Record<ObStatus, string> = { satisfied: '✅', 'needs-work': '⚠️ ', pending: '⏳', waived: '🚫', overridden: '🔓' };
|
|
410
461
|
|
|
462
|
+
/** `ms` as a person reads a duration: `850 ms`, `45 s`, `8 min 40 s`. */
|
|
463
|
+
function elapsedLabel(ms: number): string {
|
|
464
|
+
if (ms < 1000) return `${ms} ms`;
|
|
465
|
+
const s = Math.round(ms / 1000);
|
|
466
|
+
return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min${s % 60 ? ` ${s % 60} s` : ''}`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* A ledger step as the board shows it (#939 item 8). A step recorded with its elapsed milliseconds in the
|
|
471
|
+
* NAME (`--step "viewpoint 45000"`, the time meant for `--ms`) printed as a bare number; it reads
|
|
472
|
+
* `viewpoint (45 s)` now, and `timed` says the line needs its legend.
|
|
473
|
+
*/
|
|
474
|
+
function phaseLabel(step: string): { label: string; timed: boolean } {
|
|
475
|
+
const m = step.match(/^(.*\S)\s+(\d+)$/);
|
|
476
|
+
return m ? { label: `${m[1]} (${elapsedLabel(Number(m[2]))})`, timed: true } : { label: step, timed: false };
|
|
477
|
+
}
|
|
478
|
+
|
|
411
479
|
export function renderJourneyBoard(r: JourneyReport): string {
|
|
412
480
|
const L: string[] = [];
|
|
413
481
|
L.push(`# Journey — ${r.unit}`);
|
|
414
482
|
L.push('');
|
|
415
|
-
|
|
483
|
+
const phases = r.phasesDone.map(phaseLabel);
|
|
484
|
+
const legend = phases.some((p) => p.timed) || phaseLabel(r.youAreHere).timed
|
|
485
|
+
? ` — a time is that phase's elapsed time, from the ledger (\`sungen ledger report --screen ${r.unit}\`)` : '';
|
|
486
|
+
L.push(`📍 **You are here:** ${phaseLabel(r.youAreHere).label}` + (phases.length ? ` (phases: ${phases.map((p) => p.label).join(' → ')})${legend}` : ''));
|
|
416
487
|
if (r.gateStatus) L.push(` gate: **${r.gateStatus}** · score: ${r.score ?? '—'}/10`);
|
|
417
488
|
L.push('');
|
|
418
489
|
L.push('## What must still be true');
|
package/src/harness/next-step.ts
CHANGED
|
@@ -19,7 +19,9 @@ import * as path from 'path';
|
|
|
19
19
|
import { reportSlug } from './unit-paths';
|
|
20
20
|
import { checkAssetsDrift } from '../orchestrator/assets-drift';
|
|
21
21
|
import type { HeadSnapshot } from '../contract/contract-git';
|
|
22
|
-
import {
|
|
22
|
+
import { boardHumanActions } from '../graph/board-human-actions';
|
|
23
|
+
import { evaluateGraph, evaluateHumanActions, EvaluateOptions } from '../graph/evaluate-graph';
|
|
24
|
+
import type { HumanAction } from '../graph/human-action';
|
|
23
25
|
import { CORE_NODES } from '../graph/nodes/core-node-catalog';
|
|
24
26
|
import type { TestCaseState } from '../graph/test-case-state-shape';
|
|
25
27
|
import { testCaseStates } from '../graph/test-case-states';
|
|
@@ -93,6 +95,17 @@ export interface UnitState {
|
|
|
93
95
|
/** Which run the results come from and what they are older than (#942); absent without results. Advisory, decides no step. */
|
|
94
96
|
results?: ResultsFreshness;
|
|
95
97
|
steps: NextStep[];
|
|
98
|
+
/**
|
|
99
|
+
* Every pending HUMAN-ONLY action ("Waiting for you", #954): the frontier's decisions (deriveHumanActions)
|
|
100
|
+
* and the board's asks (sign-off, a waiver to give again, a decision recorded during the run). Set by
|
|
101
|
+
* readUnitState; commands canonical (`sungen …`). An agent presents them and never runs them.
|
|
102
|
+
*/
|
|
103
|
+
humanActions?: HumanAction[];
|
|
104
|
+
/**
|
|
105
|
+
* The title of the blocking step that cut the frontier: past it only actions that do not depend on it
|
|
106
|
+
* are listed (sign-off, accept-in-run and the first contract approval wait). Absent when nothing cuts.
|
|
107
|
+
*/
|
|
108
|
+
humanActionsHeldBy?: string;
|
|
96
109
|
}
|
|
97
110
|
|
|
98
111
|
export interface ReadUnitStateOptions {
|
|
@@ -170,6 +183,11 @@ export function readUnitState(
|
|
|
170
183
|
if (results) state.results = results;
|
|
171
184
|
} catch { /* advisory: results that cannot be dated add nothing */ }
|
|
172
185
|
state.steps = deriveSteps(state);
|
|
186
|
+
const graph = evaluateHumanActions(CORE_NODES, state, state.testCases);
|
|
187
|
+
const seen = new Set<string>();
|
|
188
|
+
state.humanActions = [...graph.actions, ...boardHumanActions(projectRoot, unitDir, state, graph.cut)]
|
|
189
|
+
.filter((a) => !seen.has(a.command) && Boolean(seen.add(a.command)));
|
|
190
|
+
if (graph.heldBy) state.humanActionsHeldBy = graph.heldBy;
|
|
173
191
|
return state;
|
|
174
192
|
}
|
|
175
193
|
|
|
@@ -181,3 +199,8 @@ export function readUnitState(
|
|
|
181
199
|
export function deriveSteps(s: UnitState, opts: EvaluateOptions = {}): NextStep[] {
|
|
182
200
|
return evaluateGraph(CORE_NODES, s, s.testCases, opts);
|
|
183
201
|
}
|
|
202
|
+
|
|
203
|
+
/** The human-only actions of the same frontier (#954) — the graph's part of `humanActions`, without the board. */
|
|
204
|
+
export function deriveHumanActions(s: UnitState): HumanAction[] {
|
|
205
|
+
return evaluateHumanActions(CORE_NODES, s, s.testCases).actions;
|
|
206
|
+
}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* Guard rule `tty-spoof` (phase 06, M5): ASK before an agent runs a human-confirmed sungen command
|
|
3
3
|
* through a terminal emulator.
|
|
4
4
|
*
|
|
5
|
-
* `contract approve`, `journey --waive`, `journey --decide`, `journey --signoff
|
|
6
|
-
* (#928: a red classified away from the product-defect path)
|
|
5
|
+
* `contract approve`, `journey --waive`, `journey --decide`, `journey --signoff`, `triage --as test-defect|environment`
|
|
6
|
+
* (#928: a red classified away from the product-defect path) and `delivery --accept-in-run` (delivering past
|
|
7
|
+
* decisions recorded during the run) refuse without a TTY (DOS-13.4), and
|
|
7
8
|
* an agent's Bash has none — unless it borrows one: `script`, `expect`, `unbuffer`, or a pty from
|
|
8
9
|
* Python/Perl/zsh. Then the prompt sees a "terminal" and a piped phrase reads as a human typing it.
|
|
9
10
|
* This rule spots the pair in the raw command (quotes included, so `bash -c "…"` and `expect -c '…'`
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
import type { GuardRule } from './guard-rules';
|
|
15
16
|
|
|
16
17
|
const TTY_EMULATOR = /\b(?:script|unbuffer|expect|pty|zpty|openpty|forkpty|ptyprocess)\b/i;
|
|
17
|
-
const HUMAN_GATED = /\bsungen\b[\s\S]*?\b(?:contract['",\s]+approve\b|journey\b[\s\S]*?--(?:waive|decide|signoff)\b|triage\b[\s\S]*?--as[\s='",]+(?:test-defect|environment)\b)/i;
|
|
18
|
+
const HUMAN_GATED = /\bsungen\b[\s\S]*?\b(?:contract['",\s]+approve\b|journey\b[\s\S]*?--(?:waive|decide|signoff)\b|triage\b[\s\S]*?--as[\s='",]+(?:test-defect|environment)\b|delivery\b[\s\S]*?--accept-in-run\b)/i;
|
|
18
19
|
|
|
19
20
|
export const ttySpoof: GuardRule = {
|
|
20
21
|
id: 'tty-spoof',
|
|
@@ -25,7 +26,7 @@ export const ttySpoof: GuardRule = {
|
|
|
25
26
|
return {
|
|
26
27
|
status: 'ask',
|
|
27
28
|
askReason: 'sungen: this runs a human-confirmed sungen action (`contract approve` / `journey --waive|--decide|--signoff` / '
|
|
28
|
-
+ '`triage --as test-defect|environment`) through a terminal '
|
|
29
|
+
+ '`triage --as test-defect|environment` / `delivery --accept-in-run`) through a terminal '
|
|
29
30
|
+ 'emulator (script / expect / unbuffer / pty). Those confirmations exist so a PERSON at a terminal decides — allow only if you '
|
|
30
31
|
+ 'are that person and asked for exactly this; otherwise run the command yourself in your own terminal.',
|
|
31
32
|
};
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* ended in silence. The state now starts the moment parsing does; the action only refines it.
|
|
12
12
|
*/
|
|
13
13
|
import { sungenInvocation, withProjectInvocation } from '../cli/project-sungen-invocation';
|
|
14
|
+
import { waitingForYouLines } from '../cli/waiting-for-you-block';
|
|
14
15
|
import { resolveUnitCandidates } from '../cli/resolve-unit';
|
|
15
16
|
import { readUnitState, NextStep, UnitState } from '../harness/next-step';
|
|
16
17
|
import { NO_OP, HookContext, HookInput, HookResult } from './hook-result-contract';
|
|
@@ -101,12 +102,13 @@ export function locateUnitState(ctx: HookContext, preferred?: string): { root: s
|
|
|
101
102
|
return unit ? { root, unit, state: readUnitState(root, unit.dir, unit.name, unit.kind) } : null;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
/** Hook core: the next steps for the session's unit (input.unit, else the nearest) as context. */
|
|
105
|
+
/** Hook core: the next steps for the session's unit (input.unit, else the nearest) as context, then what waits for a human (#954). */
|
|
105
106
|
export function run(input: HookInput, ctx: HookContext): HookResult {
|
|
106
107
|
const found = locateUnitState(ctx, typeof input.unit === 'string' ? input.unit : undefined);
|
|
107
108
|
if (!found || found.state.steps.length === 0) return NO_OP;
|
|
108
109
|
return {
|
|
109
110
|
status: 'context',
|
|
110
|
-
output: [`${handBackLine(found.unit.name, sungenInvocation(found.root))} — derived from what the project holds now:`, ...formatSteps(found.state.steps, 4, found.root)
|
|
111
|
+
output: [`${handBackLine(found.unit.name, sungenInvocation(found.root))} — derived from what the project holds now:`, ...formatSteps(found.state.steps, 4, found.root),
|
|
112
|
+
...waitingForYouLines(found.state.humanActions, found.root, found.state.humanActionsHeldBy)].join('\n'),
|
|
111
113
|
};
|
|
112
114
|
}
|
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
* After `/clear` or a context compaction the agent has lost the thread: it re-derives the state
|
|
6
6
|
* from memory, or worse, re-runs work that is already done. This injects what `sungen next`
|
|
7
7
|
* derives for the session's unit (the one cwd sits in, else the most recently active): what the
|
|
8
|
-
* project already holds — stated as "do not redo" — the audit verdict,
|
|
8
|
+
* project already holds — stated as "do not redo" — the audit verdict, the next steps, and what waits
|
|
9
|
+
* for a human (#954).
|
|
9
10
|
*/
|
|
10
11
|
import { NO_OP, HookMeta, SungenHook } from './hook-result-contract';
|
|
11
12
|
import { formatSteps, locateUnitState } from './hand-back-core';
|
|
12
13
|
import { sungenInvocation } from '../cli/project-sungen-invocation';
|
|
14
|
+
import { waitingForYouLines } from '../cli/waiting-for-you-block';
|
|
13
15
|
import { listUnits } from './sungen-project-root';
|
|
14
16
|
import type { UnitState } from '../harness/next-step';
|
|
15
17
|
|
|
@@ -48,6 +50,7 @@ export const sessionStartUnitStateHook: SungenHook = {
|
|
|
48
50
|
state.audit ? `Audit: ${state.audit.overall}/10 [${state.audit.gateStatus}] · ${state.audit.findings.length} finding(s).` : '',
|
|
49
51
|
`Next (\`${sungen} next ${unit.name}\`, derived from the project state):`,
|
|
50
52
|
...formatSteps(state.steps, 4, root),
|
|
53
|
+
...waitingForYouLines(state.humanActions, root, state.humanActionsHeldBy),
|
|
51
54
|
input.source === 'compact' ? 'Context was compacted: re-read the unit\'s files before acting on them.' : '',
|
|
52
55
|
others > 0 ? `Working on another unit? Ask \`${sungen} next <unit>\` (or \`${sungen} next\` for all).` : '',
|
|
53
56
|
];
|
|
@@ -30,6 +30,7 @@ export const stopNextStepHook: SungenHook = {
|
|
|
30
30
|
output: [
|
|
31
31
|
'[sungen] This turn ran sungen work but ended without the next-step hand-back.',
|
|
32
32
|
'Hand back now: present these steps to the user with AskUserQuestion and stop — do not run them yourself.',
|
|
33
|
+
...(/Waiting for you/.test(core.output) ? ['Quote the "Waiting for you" block verbatim: the user runs those commands in their own terminal (`! <command>`); never run them.'] : []),
|
|
33
34
|
core.output,
|
|
34
35
|
].join('\n'),
|
|
35
36
|
};
|
|
@@ -237,3 +237,5 @@ sungen delivery [names...] # matrix (default; needs the map) → XLSX onl
|
|
|
237
237
|
Outputs: `qa/deliverables/<unit>-testcases.xlsx` (Overview + Auto + Manual + Coverage sheets) by default;
|
|
238
238
|
`--format csv` writes `<unit>-testcases.csv` instead (flat, `Level` column `item|variant`),
|
|
239
239
|
`--format both` writes both. Legacy mode writes the classic files instead.
|
|
240
|
+
|
|
241
|
+
<!-- include: waiting-for-you -->
|
|
@@ -97,3 +97,5 @@ You are an **independent QA Reviewer** — you did not author these tests. You d
|
|
|
97
97
|
5. **Trace + next** — `sungen trace --screen ${input:name}` (human-loop focus), then offer:
|
|
98
98
|
- **`/sungen-run-test ${input:name}`** (Recommended) · **`/sungen-delivery ${input:name}`** · **`/sungen-create-test ${input:name}`** · Done.
|
|
99
99
|
{{/cap}}
|
|
100
|
+
|
|
101
|
+
<!-- include: waiting-for-you -->
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<!-- sungen:waiting-for-you v1 -->
|
|
2
|
+
## Waiting for you — the commands only the QA runs
|
|
3
|
+
|
|
4
|
+
Right before the hand-back question, run `sungen next {{unitArg}}` and quote its **⏳ Waiting for you**
|
|
5
|
+
block **verbatim** — every command exactly as printed, each with its reason. Those commands approve,
|
|
6
|
+
sign off, waive, classify a red, accept in-run decisions or re-run once the environment is up: they are
|
|
7
|
+
the QA's alone, and most refuse without a person typing a confirmation. Tell the QA to run each one in
|
|
8
|
+
their own terminal — in Claude Code, type `! <command>` in the prompt (a `/sungen:…` command is typed
|
|
9
|
+
as is). **Never run them yourself**, and never pipe or type a confirmation into them. When `sungen next`
|
|
10
|
+
prints no such block, say that nothing is waiting for the QA.
|
|
11
|
+
<!-- /sungen:waiting-for-you -->
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<!-- sungen:waiting-for-you v1 -->
|
|
2
|
+
## Waiting for you — the commands only the QA runs
|
|
3
|
+
|
|
4
|
+
Right before the hand-back list, run `sungen next {{unitArg}}` and quote its **⏳ Waiting for you**
|
|
5
|
+
block **verbatim** — every command exactly as printed, each with its reason. Those commands approve,
|
|
6
|
+
sign off, waive, classify a red, accept in-run decisions or re-run once the environment is up: they are
|
|
7
|
+
the QA's alone, and most refuse without a person typing a confirmation. Tell the QA to run each one
|
|
8
|
+
themselves, in their own terminal (a slash command is typed as is). **Never run them yourself**, and
|
|
9
|
+
never pipe or type a confirmation into them. When `sungen next` prints no such block, say that nothing
|
|
10
|
+
is waiting for the QA.
|
|
11
|
+
<!-- /sungen:waiting-for-you -->
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<!-- sungen:waiting-for-you v1 -->
|
|
2
|
+
## Waiting for you — the commands only the QA runs
|
|
3
|
+
|
|
4
|
+
Right before the hand-back list, run `sungen next {{unitArg}}` and quote its **⏳ Waiting for you**
|
|
5
|
+
block **verbatim** — every command exactly as printed, each with its reason. Those commands approve,
|
|
6
|
+
sign off, waive, classify a red, accept in-run decisions or re-run once the environment is up: they are
|
|
7
|
+
the QA's alone, and most refuse without a person typing a confirmation. Tell the QA to run each one
|
|
8
|
+
themselves, in their own terminal (a slash command is typed as is). **Never run them yourself**, and
|
|
9
|
+
never pipe or type a confirmation into them. When `sungen next` prints no such block, say that nothing
|
|
10
|
+
is waiting for the QA.
|
|
11
|
+
<!-- /sungen:waiting-for-you -->
|