@try-works/dsh-recursive-mode 0.6.1 → 0.7.0
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/lib/enforcement.d.ts +6 -1
- package/lib/index.js +398 -173
- package/lib/memory-feedback.d.ts +83 -0
- package/lib/memory.d.ts +15 -2
- package/lib/recursive_phase.tool.d.ts +16 -0
- package/package.json +1 -1
- package/scripts/check-workflow-map-escapes.mjs +103 -0
- package/scripts/check-workflow-map.mjs +423 -0
- package/scripts/gen-workflow-map.mjs +2812 -0
- package/src/enforcement.ts +6 -1
- package/src/memory-feedback.ts +148 -3
- package/src/memory.ts +30 -4
- package/src/policy-globs.ts +111 -0
- package/src/policy.ts +17 -0
- package/src/recursive_phase.tool.ts +24 -1
- package/src/runtime.ts +18 -2
|
@@ -0,0 +1,2812 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gen-workflow-map.mjs — generates workflow-map/recursive-mode-workflow.html.
|
|
4
|
+
*
|
|
5
|
+
* WHY A GENERATOR. The page's whole value is that its content is TRANSCRIBED from
|
|
6
|
+
* this repo's source, with a file:line citation on every factual block. Keeping the
|
|
7
|
+
* facts as data in one place (below) and rendering them once means the citations and
|
|
8
|
+
* the wording cannot drift apart from each other, and `--verify` re-reads the SOURCE
|
|
9
|
+
* files and re-derives the claim-bearing lists, so a fact that stops being true in
|
|
10
|
+
* `src/` fails loudly instead of shipping as a plausible diagram.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT DOES NOT DO: it does not parse TypeScript. The facts below were transcribed
|
|
13
|
+
* by reading the sources, and `--verify` checks the ones that can be checked
|
|
14
|
+
* mechanically (phase list and order, required sections, late/audited/optional sets,
|
|
15
|
+
* tool names, hook seam strings, error codes, guard labels, the run-start labels).
|
|
16
|
+
* Anything that cannot be checked mechanically is marked `unverified` in the page.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* node scripts/gen-workflow-map.mjs # write the HTML
|
|
20
|
+
* node scripts/gen-workflow-map.mjs --verify # check the facts against src/, write nothing
|
|
21
|
+
* node scripts/gen-workflow-map.mjs --out <p> # write somewhere else
|
|
22
|
+
*
|
|
23
|
+
* READ-ONLY with respect to src/ and tests/. Writes exactly one file.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
26
|
+
import { dirname, join, resolve } from 'node:path'
|
|
27
|
+
import { fileURLToPath } from 'node:url'
|
|
28
|
+
|
|
29
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
30
|
+
const DEFAULT_OUT = join(ROOT, 'workflow-map', 'recursive-mode-workflow.html')
|
|
31
|
+
|
|
32
|
+
/* ========================================================================== */
|
|
33
|
+
/* CITATIONS — resolved from source ANCHORS, never hardcoded line numbers. */
|
|
34
|
+
/* */
|
|
35
|
+
/* WHY. While this page was being written, another agent edited */
|
|
36
|
+
/* `policy-globs.ts` and `runtime.ts` and inserted dozens of lines for a new */
|
|
37
|
+
/* guard rule. Every line number the page had transcribed below that point */
|
|
38
|
+
/* became WRONG — and a wrong citation is exactly the defect this page exists */
|
|
39
|
+
/* to avoid: a diagram that asserts something the code does not say. So each */
|
|
40
|
+
/* citation is resolved HERE, from a named anchor searched in the file it */
|
|
41
|
+
/* belongs to, at generate time. A missing anchor is a hard failure; so is an */
|
|
42
|
+
/* ambiguous one; so is a range that runs past the end of its file. */
|
|
43
|
+
/* ========================================================================== */
|
|
44
|
+
|
|
45
|
+
/** Search texts written OUT (not read from the source) so a MOVED anchor is caught. */
|
|
46
|
+
const ANCHORS = {
|
|
47
|
+
'lock.sequence': ['src/lock.ts', 'export const PHASE_SEQUENCE = ['],
|
|
48
|
+
'lock.optional': ['src/lock.ts', 'export const OPTIONAL_PHASES = new Set(['],
|
|
49
|
+
'lock.hash': ['src/lock.ts', 'export function lockHashFromContent'],
|
|
50
|
+
'lock.status': ['src/lock.ts', 'export function getLockStatus'],
|
|
51
|
+
'lock.receipts': ['src/lock.ts', 'export function receiptPath'],
|
|
52
|
+
'lock.staleDownstream': ['src/lock.ts', 'export function getStaleDownstreamPhases'],
|
|
53
|
+
'lock.staleAll': ['src/lock.ts', 'export function getAllStaleReceipts'],
|
|
54
|
+
'lock.chainBreaks': ['src/lock.ts', 'export type ReceiptChainBreakKind'],
|
|
55
|
+
'lock.validateChain': ['src/lock.ts', 'export function validateReceiptChain'],
|
|
56
|
+
|
|
57
|
+
'rules.late': ['src/phase-rules.ts', 'export const LATE_PHASE_ARTIFACTS'],
|
|
58
|
+
'rules.audited': ['src/phase-rules.ts', 'export const AUDITED_PHASE_FILES = new Set(['],
|
|
59
|
+
'rules.priorEvidence': ['src/phase-rules.ts', 'export const PRIOR_RECURSIVE_EVIDENCE_FILES'],
|
|
60
|
+
'rules.diffAudited': ['src/phase-rules.ts', 'export const DIFF_AUDITED_FILES'],
|
|
61
|
+
'rules.traceability': ['src/phase-rules.ts', 'export const TRACEABILITY_REQUIRED_FILES = new Set(['],
|
|
62
|
+
'rules.auditHeadings': ['src/phase-rules.ts', 'export const AUDIT_REQUIRED_HEADINGS'],
|
|
63
|
+
'rules.sectionMapRoot': ['src/phase-rules.ts', 'const SECTION_MAP: Record<string, string[]> = {'],
|
|
64
|
+
'rules.memoryPlane': ['src/phase-rules.ts', 'export const MEMORY_PLANE_PREFIX'],
|
|
65
|
+
'rules.phase8Rule': ['src/phase-rules.ts', 'export const PHASE8_MEMORY_WRITE_RULE'],
|
|
66
|
+
'rules.memoryLocations': ['src/phase-rules.ts', 'export const MEMORY_DOC_LOCATIONS'],
|
|
67
|
+
'rules.getSections': ['src/phase-rules.ts', 'export function getArtifactRequiredSections'],
|
|
68
|
+
'rules.reminderGate': ['src/phase-rules.ts', 'export class ReminderOnceGate'],
|
|
69
|
+
'rules.lintMessage': ['src/phase-rules.ts', 'export function phaseLintRulesMessage'],
|
|
70
|
+
'rules.narrowing': ['src/phase-rules.ts', 'export function withPhaseBaseline'],
|
|
71
|
+
'rules.baseline': ['src/phase-rules.ts', 'export function phaseBaselineRules'],
|
|
72
|
+
'rules.verdictRank': ['src/phase-rules.ts', 'const VERDICT_RANK'],
|
|
73
|
+
'rules.phaseNumber': ['src/phase-rules.ts', 'export function phaseNumberForArtifact'],
|
|
74
|
+
'rules.ownMemoryPlane': ['src/phase-rules.ts', 'function writesOwnMemoryPlane'],
|
|
75
|
+
'rules.resolveFrom': ['src/phase-rules.ts', 'FIX 2 — THIS USED TO STRIP A LEADING DOT'],
|
|
76
|
+
|
|
77
|
+
'lint.inputMap': ['src/ts-lint.ts', 'export function getPhaseExpectedInputArtifactNames'],
|
|
78
|
+
'lint.inputAddenda': ['src/ts-lint.ts', 'export function getExpectedEffectiveInputAddendaPaths'],
|
|
79
|
+
'lint.effectiveInputs': ['src/ts-lint.ts', 'lint_effective_input_addenda'],
|
|
80
|
+
'lint.rereadPaths': ['src/ts-lint.ts', 'Effective Inputs Re-read is missing relevant addenda'],
|
|
81
|
+
'lint.sequence': ['src/ts-lint.ts', 'export const RUN_ARTIFACT_SEQUENCE = ['],
|
|
82
|
+
'lint.tddMode': ['src/ts-lint.ts', 'TDD Compliance Log is missing TDD Mode'],
|
|
83
|
+
'lint.qaMode': ['src/ts-lint.ts', 'QA Execution Record is missing QA Execution Mode'],
|
|
84
|
+
'lint.qaSignoff': ['src/ts-lint.ts', 'requires User Sign-Off -> Approved by'],
|
|
85
|
+
'lint.auditContext': ['src/ts-lint.ts', 'Audit Context section is empty'],
|
|
86
|
+
'lint.staleBundle': ['src/ts-lint.ts', 'Review bundle is stale'],
|
|
87
|
+
|
|
88
|
+
'tpl.requirements': ['src/init-templates.ts', 'export function requirementsContent'],
|
|
89
|
+
'tpl.worktree': ['src/init-templates.ts', 'export function worktreeContent'],
|
|
90
|
+
'tpl.later': ['src/init-templates.ts', 'export function laterPhaseContent'],
|
|
91
|
+
|
|
92
|
+
'runtime.lockArtifact': ['src/runtime.ts', 'async lockArtifact(runId: string'],
|
|
93
|
+
'runtime.blockers': ['src/runtime.ts', "throw new Error('Prerequisite blockers: '"],
|
|
94
|
+
'runtime.quiescence': ['src/runtime.ts', 'const inFlight = pendingWork(runDir)'],
|
|
95
|
+
'runtime.memoryGate': ['src/runtime.ts', 'const memoryRefusal = phase8MemoryLockRefusal'],
|
|
96
|
+
'runtime.lintGate': ['src/runtime.ts', 'const lint = await this.lintArtifact'],
|
|
97
|
+
'runtime.lintRefusal': ['src/runtime.ts', 'does not meet the phase standard, so it was not locked'],
|
|
98
|
+
'runtime.reopen': ['src/runtime.ts', 'private reopenArtifact('],
|
|
99
|
+
'runtime.staleInvalidate': ['src/runtime.ts', 'for (const entry of stale) invalidateReceipt'],
|
|
100
|
+
|
|
101
|
+
'index.tools': ['src/index.ts', 'ctx.tools.register(createRecursiveStatusTool(recursive))'],
|
|
102
|
+
'index.auditTeam': ['src/index.ts', 'let auditTeamRegistered'],
|
|
103
|
+
'index.injects': ['src/index.ts', "ctx.inject(['subagents']"],
|
|
104
|
+
'index.prompt': ['src/index.ts', "name: 'recursive:policy'"],
|
|
105
|
+
'index.preExecute': ['src/index.ts', "on('tools/pre-execute'"],
|
|
106
|
+
// NOTE: the register CALL is identical for both bindings, so this anchor is the
|
|
107
|
+
// guard's own name CONSTANT — unique, and it is the line the two share.
|
|
108
|
+
'index.guardRegister': ['src/index.ts', "const BUILTIN_GUARD_HOOK_NAME = 'builtin-tool-guard'"],
|
|
109
|
+
'index.planGate': ['src/index.ts', "name: 'exit-plan-mode-gate'"],
|
|
110
|
+
'index.fsObserved': ['src/index.ts', "on('fs/observed'"],
|
|
111
|
+
'index.sessionEvent': ['src/index.ts', "on('session/event'"],
|
|
112
|
+
'index.preStep': ['src/index.ts', "on('agent/pre-step'"],
|
|
113
|
+
'index.webServer': ['src/index.ts', "disposers.push(mountRecursiveRoutesOnce("],
|
|
114
|
+
'index.guardLog': ['src/index.ts', 'appendGuardDecision(root, record)'],
|
|
115
|
+
'index.denyText': ['src/index.ts', 'the gate-block payload added for FU-7'],
|
|
116
|
+
'index.blockGoal': ['src/index.ts', 'blockGoalOnGuardRefusal(recursive, exec, final, runId)'],
|
|
117
|
+
|
|
118
|
+
'hooks.points': ['src/hooks.ts', 'export const HOOK_POINTS'],
|
|
119
|
+
'hooks.gating': ['src/hooks.ts', 'export const GATING_POINTS'],
|
|
120
|
+
'hooks.observing': ['src/hooks.ts', 'export const OBSERVING_POINTS'],
|
|
121
|
+
'hooks.downgrade': ['src/hooks.ts', 'A deny from an observing point is therefore downgraded'],
|
|
122
|
+
'hooks.downgradeImpl': ['src/hooks.ts', 'OBSERVE-ONLY POINTS CANNOT DENY: the message has already streamed'],
|
|
123
|
+
'hooks.failure': ['src/hooks.ts', 'FAILURE DEFAULTS DIFFER BY POINT'],
|
|
124
|
+
|
|
125
|
+
'globs.writeTools': ['src/policy-globs.ts', 'export const WRITE_TOOL_NAMES'],
|
|
126
|
+
'globs.lockTools': ['src/policy-globs.ts', 'export const LOCK_TOOL_NAMES'],
|
|
127
|
+
'globs.lockOrder': ['src/policy-globs.ts', 'function lockOrderRule'],
|
|
128
|
+
'globs.lockedWrite': ['src/policy-globs.ts', 'function lockedWriteRule'],
|
|
129
|
+
'globs.directChild': ['src/policy-globs.ts', 'function directChildName'],
|
|
130
|
+
'globs.phaseOrder': ['src/policy-globs.ts', 'function phaseOrderRule'],
|
|
131
|
+
'globs.memoryRead': ['src/policy-globs.ts', 'function memoryReadRule'],
|
|
132
|
+
'globs.builtIn': ['src/policy-globs.ts', 'export function builtInToolPolicyRules'],
|
|
133
|
+
'globs.attach': ['src/policy-globs.ts', 'export function attachPolicyPredicate'],
|
|
134
|
+
'globs.tdd': ['src/policy-globs.ts', 'export function tddEvidenceVerdict'],
|
|
135
|
+
'globs.phaseOrderHole': ['src/policy-globs.ts', 'THE HOLE THIS CLOSES'],
|
|
136
|
+
'globs.phaseOrderAllow': ['src/policy-globs.ts', 'THE ALLOW HALF IS LOAD-BEARING'],
|
|
137
|
+
|
|
138
|
+
'enf.mode': ['src/enforcement.ts', 'export type EnforcementMode'],
|
|
139
|
+
'enf.default': ['src/enforcement.ts', 'export const DEFAULT_ENFORCEMENT_MODE'],
|
|
140
|
+
'enf.evaluate': ['src/enforcement.ts', 'export function evaluateToolGuard'],
|
|
141
|
+
'enf.verdictFor': ['src/enforcement.ts', 'function verdictFor'],
|
|
142
|
+
'enf.coerce': ['src/enforcement.ts', 'export function coerceAskToDecision'],
|
|
143
|
+
'enf.consult': ['src/enforcement.ts', 'function consultTransitionGate'],
|
|
144
|
+
|
|
145
|
+
'err.registry': ['src/errors.ts', 'export const TOOL_ERRORS = {'],
|
|
146
|
+
'err.classes': ['src/errors.ts', 'export type ToolErrorClass'],
|
|
147
|
+
'err.specUnfilled': ['src/errors.ts', 'RUN_START_SPEC_UNFILLED: {'],
|
|
148
|
+
'err.unanswered': ['src/errors.ts', 'RM5503 USED TO LIE'],
|
|
149
|
+
'err.noChannel': ['src/errors.ts', 'RUN_START_NO_CHANNEL: {'],
|
|
150
|
+
'err.pending': ['src/errors.ts', 'PENDING_WORK: {'],
|
|
151
|
+
|
|
152
|
+
'start.module': ['src/run-start.ts', 'PHASE 0 — STARTING A RUN IS A HUMAN DECISION'],
|
|
153
|
+
'start.gate': ['src/run-start.ts', 'export const RUN_START_GATE = {'],
|
|
154
|
+
'start.labels': ['src/run-start.ts', 'export const RUN_START_APPROVE'],
|
|
155
|
+
'start.line': ['src/run-start.ts', 'export function runStartApprovalLine'],
|
|
156
|
+
'start.approval': ['src/run-start.ts', 'export function readRunStartApproval'],
|
|
157
|
+
'start.valueMatch': ['src/run-start.ts', 'MATCHED ON THE VALUE, NOT ON THE LINE'],
|
|
158
|
+
'start.guard': ['src/run-start.ts', 'export function runStartSpecGuard'],
|
|
159
|
+
'start.notApproved': ['src/run-start.ts', 'export const RUN_START_NOT_APPROVED'],
|
|
160
|
+
'start.notAGate': ['src/run-start.ts', 'WHY THIS GATE IS NOT IN'],
|
|
161
|
+
|
|
162
|
+
'ask.gates': ['src/recursive_ask.tool.ts', 'export const ASK_GATES'],
|
|
163
|
+
'ask.tdd': ['src/recursive_ask.tool.ts', "'tdd-mode': {"],
|
|
164
|
+
'ask.qa': ['src/recursive_ask.tool.ts', "'qa-signoff': {"],
|
|
165
|
+
'ask.gateBlock': ['src/recursive_ask.tool.ts', "'gate-block': {"],
|
|
166
|
+
'ask.payload': ['src/recursive_ask.tool.ts', 'THE GATE-BLOCK REFUSAL PAYLOAD, BUILT IN ONE PLACE'],
|
|
167
|
+
'ask.relayOnly': ['src/recursive_ask.tool.ts', "toolError('RELAY_ONLY_FOR_RUN_START'"],
|
|
168
|
+
'ask.fixedArtifact': ['src/recursive_ask.tool.ts', 'const artifact = (isRunStartGate(gateId)'],
|
|
169
|
+
|
|
170
|
+
'review.driver': ['src/review-round.ts', 'THE FOUR OUTCOMES THAT MATTER'],
|
|
171
|
+
'review.failClosed': ['src/review-round.ts', 'THE VERDICT IS READ FROM'],
|
|
172
|
+
'teams.loop': ['src/teams-loop.ts', 'createTask (pending) → claim (in_progress) → audit round'],
|
|
173
|
+
'graph.why': ['src/phase-graph.ts', 'the phase dependency as a GRAPH, not a flat sequence'],
|
|
174
|
+
'graph.build': ['src/phase-graph.ts', 'export function buildPhaseGraph'],
|
|
175
|
+
'graph.reach': ['src/phase-graph.ts', 'export function reachableFrom'],
|
|
176
|
+
'graph.next': ['src/phase-graph.ts', 'export function nextLegalPhase'],
|
|
177
|
+
'graph.back': ['src/phase-graph.ts', 'export function backEdges'],
|
|
178
|
+
'graph.addendum': ['src/phase-graph.ts', 'ADDENDUM edges'],
|
|
179
|
+
'graph.backEdgeWhy': ['src/phase-graph.ts', 'BACK-EDGES ARE FIRST-CLASS'],
|
|
180
|
+
'graph.visited': ['src/phase-graph.ts', 'The VISITED set is load-bearing'],
|
|
181
|
+
'deleg.verdict': ['src/delegation.ts', 'export type DelegationVerdict'],
|
|
182
|
+
'deleg.reviseDefault': ['src/delegation.ts', 'export function readRepairFromReply'],
|
|
183
|
+
'closeout.report': ['src/closeout-report.ts', 'it opens no file for writing'],
|
|
184
|
+
'closeout.scaffold': ['src/closeout.ts', 'export function closeoutPhase'],
|
|
185
|
+
'closeout.lockedRefusal': ['src/closeout.ts', 'A CLOSEOUT MUST NOT SILENTLY UNLOCK ITS OWN ARTIFACT'],
|
|
186
|
+
'closeout.prereq': ['src/closeout.ts', 'const blockers = getPrerequisiteBlockers(runDir, config.file)'],
|
|
187
|
+
'training.evidence': ['src/training.ts', 'export function phase8MemoryEvidence'],
|
|
188
|
+
'training.refusal': ['src/training.ts', 'export function phase8MemoryRefusal'],
|
|
189
|
+
'settlement.seam': ['src/settlement.ts', "ctx.on('session/event'"],
|
|
190
|
+
'policy.text': ['src/policy.ts', 'Writes to a Status: LOCKED phase doc are denied/asked; reopen explicitly to edit.'],
|
|
191
|
+
'skills.phase': ['src/skills-phase.ts', 'export function registerPhaseSkills'],
|
|
192
|
+
'tool.closeout': ['src/recursive_closeout.tool.ts', 'It NEVER writes the phase document'],
|
|
193
|
+
'tool.closeoutPhase08': ['src/recursive_closeout.tool.ts', 'Phase 08 additionally fires the training trigger'],
|
|
194
|
+
'tool.reviewRevise': ['src/recursive_review.tool.ts', 'When a round comes back REVISE the SAME child'],
|
|
195
|
+
'tool.delegateNotJudge': ['src/recursive_delegate.tool.ts', 'and YOU remain the judge'],
|
|
196
|
+
'tool.auditTeamApprove': ['src/recursive_audit_team.tool.ts', 'ONLY after an APPROVE verdict'],
|
|
197
|
+
'tool.lockOrdering': ['src/recursive_lock.tool.ts', 'IT IS ATTACHED TO THE ORDERING REFUSAL SPECIFICALLY'],
|
|
198
|
+
'tool.reopenParam': ['src/recursive_lock.tool.ts', 'reopen: { type:'],
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Anchors that legitimately appear MORE THAN ONCE, where citing the first would
|
|
203
|
+
* be a half-truth. They are named here explicitly: the ambiguity guard still
|
|
204
|
+
* fails on every other duplicate, so a surprise copy is still caught, but a
|
|
205
|
+
* deliberate one is DECLARED and cited in full.
|
|
206
|
+
*/
|
|
207
|
+
const MULTI_HIT_ANCHORS = new Set(['policy.text'])
|
|
208
|
+
|
|
209
|
+
const sourceCache = new Map()
|
|
210
|
+
function sourceLines(rel) {
|
|
211
|
+
if (!sourceCache.has(rel)) {
|
|
212
|
+
const p = join(ROOT, ...rel.split('/'))
|
|
213
|
+
if (!existsSync(p)) throw new Error('citation source missing: ' + rel)
|
|
214
|
+
sourceCache.set(rel, readFileSync(p, 'utf8').split('\n'))
|
|
215
|
+
}
|
|
216
|
+
return sourceCache.get(rel)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Resolve one anchor to `file:line`, or `file:a, file:b` for a declared
|
|
221
|
+
* multi-hit anchor. Three properties matter:
|
|
222
|
+
* 1. the file-relative path is ALWAYS part of the answer, so no citation can
|
|
223
|
+
* ever render as a bare line number;
|
|
224
|
+
* 2. an UNDECLARED duplicate is a FAILURE, not a first-match: a second copy of a
|
|
225
|
+
* marker is a duplication symptom, and silently citing the first hides it;
|
|
226
|
+
* 3. every resolved line is checked to lie inside the file it names.
|
|
227
|
+
*/
|
|
228
|
+
function anchor(key) {
|
|
229
|
+
const entry = ANCHORS[key]
|
|
230
|
+
if (!entry) throw new Error('unknown citation anchor: ' + key)
|
|
231
|
+
const [rel, text] = entry
|
|
232
|
+
const lines = sourceLines(rel)
|
|
233
|
+
const hits = []
|
|
234
|
+
for (let i = 0; i < lines.length; i += 1) if (lines[i].includes(text)) hits.push(i + 1)
|
|
235
|
+
if (hits.length === 0) throw new Error('ANCHOR NOT FOUND — ' + key + ' ("' + text + '") in ' + rel)
|
|
236
|
+
if (hits.length > 1 && !MULTI_HIT_ANCHORS.has(key)) {
|
|
237
|
+
throw new Error('ANCHOR AMBIGUOUS (' + hits.length + ' hits, undeclared) — ' + key + ' ("' + text + '") in ' + rel
|
|
238
|
+
+ '. Either anchor on something unique, or declare it in MULTI_HIT_ANCHORS.')
|
|
239
|
+
}
|
|
240
|
+
return hits.map((n) => rel + ':' + n).join(', ')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** `'a#1-3, b'` → `'file:1-3, file2:9'`. Ranges are checked against the real file length. */
|
|
244
|
+
function citeOf(spec) {
|
|
245
|
+
return spec.split(',').map((raw) => {
|
|
246
|
+
const part = raw.trim()
|
|
247
|
+
const hash = part.indexOf('#')
|
|
248
|
+
const key = hash < 0 ? part : part.slice(0, hash)
|
|
249
|
+
const range = hash < 0 ? null : part.slice(hash + 1)
|
|
250
|
+
const at = anchor(key)
|
|
251
|
+
if (range === null) return at
|
|
252
|
+
// A range needs ONE line to start from; a declared multi-hit anchor has several.
|
|
253
|
+
if (at.includes(', ')) throw new Error('cannot take a line range from a multi-hit anchor: ' + spec)
|
|
254
|
+
const file = at.slice(0, at.indexOf(':'))
|
|
255
|
+
const start = Number(at.slice(at.indexOf(':') + 1))
|
|
256
|
+
const [aRaw, bRaw] = range.split('-')
|
|
257
|
+
const a = start + Number(aRaw) - 1
|
|
258
|
+
const b = bRaw === undefined ? undefined : start + Number(bRaw) - 1
|
|
259
|
+
if (!Number.isFinite(a)) throw new Error('bad range in citation spec: ' + spec)
|
|
260
|
+
if (b !== undefined && b < a) throw new Error('backwards range in citation spec: ' + spec)
|
|
261
|
+
if (b !== undefined && b > sourceLines(file).length) throw new Error('range past end of ' + file + ' in: ' + spec)
|
|
262
|
+
return file + ':' + a + (b === undefined ? '' : '-' + b)
|
|
263
|
+
}).join(', ')
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** All twelve SECTION_MAP anchors, declared by construction rather than by hand. */
|
|
267
|
+
const SECTION_FILES = ['00-worktree.md', '00-requirements.md', '01-as-is.md', '01.5-root-cause.md',
|
|
268
|
+
'02-to-be-plan.md', '03-implementation-summary.md', '03.5-code-review.md', '04-test-summary.md',
|
|
269
|
+
'05-manual-qa.md', '06-decisions-update.md', '07-state-update.md', '08-memory-impact.md']
|
|
270
|
+
for (const f of SECTION_FILES) ANCHORS['section.' + f] = ['src/phase-rules.ts', " '" + f + "': ["]
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* One anchor per tool, keyed by its own registered NAME. The anchor text IS the
|
|
274
|
+
* `name:` literal, so a citation and the tool's identity cannot disagree — and
|
|
275
|
+
* verify() re-reads each file to confirm the name it declares.
|
|
276
|
+
*/
|
|
277
|
+
const TOOL_FILES = [
|
|
278
|
+
'recursive_status', 'recursive_init', 'recursive_lock', 'recursive_lint', 'recursive_closeout',
|
|
279
|
+
'recursive_scratch', 'recursive_worktree', 'recursive_phase', 'recursive_review',
|
|
280
|
+
'recursive_delegate', 'recursive_ask', 'recursive_preview', 'recursive_audit_team',
|
|
281
|
+
]
|
|
282
|
+
for (const tool of TOOL_FILES) ANCHORS['tool.' + tool] = ['src/' + tool + '.tool.ts', "name: '" + tool + "',"]
|
|
283
|
+
|
|
284
|
+
/** Resolved once. A typo in a key throws here, before anything renders. */
|
|
285
|
+
const SRC = {
|
|
286
|
+
seq: citeOf('lock.sequence'),
|
|
287
|
+
optional: citeOf('lock.optional'),
|
|
288
|
+
lockHash: citeOf('lock.hash'),
|
|
289
|
+
lockStatus: citeOf('lock.status'),
|
|
290
|
+
receipts: citeOf('lock.receipts'),
|
|
291
|
+
staleDownstream: citeOf('lock.staleDownstream'),
|
|
292
|
+
staleAll: citeOf('lock.staleAll'),
|
|
293
|
+
chainBreaks: citeOf('lock.chainBreaks'),
|
|
294
|
+
validateChain: citeOf('lock.validateChain'),
|
|
295
|
+
|
|
296
|
+
late: citeOf('rules.late'),
|
|
297
|
+
audited: citeOf('rules.audited'),
|
|
298
|
+
priorEvidence: citeOf('rules.priorEvidence'),
|
|
299
|
+
diffAudited: citeOf('rules.diffAudited'),
|
|
300
|
+
traceability: citeOf('rules.traceability'),
|
|
301
|
+
auditHeadings: citeOf('rules.auditHeadings'),
|
|
302
|
+
sectionMapRoot: citeOf('rules.sectionMapRoot'),
|
|
303
|
+
memoryPlane: citeOf('rules.memoryPlane'),
|
|
304
|
+
phase8Rule: citeOf('rules.phase8Rule'),
|
|
305
|
+
memoryLocations: citeOf('rules.memoryLocations'),
|
|
306
|
+
getSections: citeOf('rules.getSections'),
|
|
307
|
+
reminderGate: citeOf('rules.reminderGate'),
|
|
308
|
+
lintMessage: citeOf('rules.lintMessage'),
|
|
309
|
+
narrowing: citeOf('rules.narrowing'),
|
|
310
|
+
baseline: citeOf('rules.baseline'),
|
|
311
|
+
verdictRank: citeOf('rules.verdictRank'),
|
|
312
|
+
phaseNumber: citeOf('rules.phaseNumber'),
|
|
313
|
+
ownMemoryPlane: citeOf('rules.ownMemoryPlane'),
|
|
314
|
+
resolveFrom: citeOf('rules.resolveFrom'),
|
|
315
|
+
|
|
316
|
+
inputMap: citeOf('lint.inputMap'),
|
|
317
|
+
inputAddenda: citeOf('lint.inputAddenda'),
|
|
318
|
+
effectiveInputs: citeOf('lint.effectiveInputs'),
|
|
319
|
+
rereadPaths: citeOf('lint.rereadPaths'),
|
|
320
|
+
lintSequence: citeOf('lint.sequence'),
|
|
321
|
+
lintTdd: citeOf('lint.tddMode'),
|
|
322
|
+
lintQa: citeOf('lint.qaMode'),
|
|
323
|
+
lintQaSignoff: citeOf('lint.qaSignoff'),
|
|
324
|
+
lintAuditContext: citeOf('lint.auditContext'),
|
|
325
|
+
lintStale: citeOf('lint.staleBundle'),
|
|
326
|
+
|
|
327
|
+
tplRequirements: citeOf('tpl.requirements'),
|
|
328
|
+
tplWorktree: citeOf('tpl.worktree'),
|
|
329
|
+
tplLater: citeOf('tpl.later'),
|
|
330
|
+
|
|
331
|
+
lockArtifact: citeOf('runtime.lockArtifact'),
|
|
332
|
+
blockers: citeOf('runtime.blockers'),
|
|
333
|
+
quiescence: citeOf('runtime.quiescence'),
|
|
334
|
+
memoryGate: citeOf('runtime.memoryGate'),
|
|
335
|
+
lintGate: citeOf('runtime.lintGate'),
|
|
336
|
+
lintRefusal: citeOf('runtime.lintRefusal'),
|
|
337
|
+
reopen: citeOf('runtime.reopen'),
|
|
338
|
+
staleInvalidate: citeOf('runtime.staleInvalidate'),
|
|
339
|
+
|
|
340
|
+
toolsReg: citeOf('index.tools'),
|
|
341
|
+
auditTeamReg: citeOf('index.auditTeam'),
|
|
342
|
+
injects: citeOf('index.injects'),
|
|
343
|
+
promptSection: citeOf('index.prompt'),
|
|
344
|
+
preExecute: citeOf('index.preExecute'),
|
|
345
|
+
guardRegister: citeOf('index.guardRegister'),
|
|
346
|
+
planGate: citeOf('index.planGate'),
|
|
347
|
+
fsObserved: citeOf('index.fsObserved'),
|
|
348
|
+
sessionEvent: citeOf('index.sessionEvent'),
|
|
349
|
+
preStep: citeOf('index.preStep'),
|
|
350
|
+
webServer: citeOf('index.webServer'),
|
|
351
|
+
guardLog: citeOf('index.guardLog'),
|
|
352
|
+
denyText: citeOf('index.denyText'),
|
|
353
|
+
blockGoal: citeOf('index.blockGoal'),
|
|
354
|
+
|
|
355
|
+
hookPoints: citeOf('hooks.points'),
|
|
356
|
+
hookGating: citeOf('hooks.gating'),
|
|
357
|
+
hookObserving: citeOf('hooks.observing'),
|
|
358
|
+
hookDowngrade: citeOf('hooks.downgrade'),
|
|
359
|
+
hookFailure: citeOf('hooks.failure'),
|
|
360
|
+
|
|
361
|
+
writeTools: citeOf('globs.writeTools'),
|
|
362
|
+
lockTools: citeOf('globs.lockTools'),
|
|
363
|
+
lockOrderRule: citeOf('globs.lockOrder'),
|
|
364
|
+
lockedWriteRule: citeOf('globs.lockedWrite'),
|
|
365
|
+
directChild: citeOf('globs.directChild'),
|
|
366
|
+
phaseOrderRule: citeOf('globs.phaseOrder'),
|
|
367
|
+
memoryReadRule: citeOf('globs.memoryRead'),
|
|
368
|
+
builtInRules: citeOf('globs.builtIn'),
|
|
369
|
+
attachPredicate: citeOf('globs.attach'),
|
|
370
|
+
tddVerdict: citeOf('globs.tdd'),
|
|
371
|
+
phaseOrderHole: citeOf('globs.phaseOrderHole'),
|
|
372
|
+
phaseOrderAllow: citeOf('globs.phaseOrderAllow'),
|
|
373
|
+
|
|
374
|
+
mode: citeOf('enf.mode'),
|
|
375
|
+
modeDefault: citeOf('enf.default'),
|
|
376
|
+
evaluate: citeOf('enf.evaluate'),
|
|
377
|
+
verdictFor: citeOf('enf.verdictFor'),
|
|
378
|
+
coerce: citeOf('enf.coerce'),
|
|
379
|
+
consult: citeOf('enf.consult'),
|
|
380
|
+
|
|
381
|
+
errRegistry: citeOf('err.registry'),
|
|
382
|
+
errClasses: citeOf('err.classes'),
|
|
383
|
+
errSpecUnfilled: citeOf('err.specUnfilled'),
|
|
384
|
+
errUnanswered: citeOf('err.unanswered'),
|
|
385
|
+
errNoChannel: citeOf('err.noChannel'),
|
|
386
|
+
errPending: citeOf('err.pending'),
|
|
387
|
+
|
|
388
|
+
startModule: citeOf('start.module'),
|
|
389
|
+
startGate: citeOf('start.gate'),
|
|
390
|
+
startLabels: citeOf('start.labels'),
|
|
391
|
+
startLine: citeOf('start.line'),
|
|
392
|
+
startApproval: citeOf('start.approval'),
|
|
393
|
+
startValueMatch: citeOf('start.valueMatch'),
|
|
394
|
+
startGuard: citeOf('start.guard'),
|
|
395
|
+
startNotApproved: citeOf('start.notApproved'),
|
|
396
|
+
startNotAGate: citeOf('start.notAGate'),
|
|
397
|
+
|
|
398
|
+
askGates: citeOf('ask.gates'),
|
|
399
|
+
askTdd: citeOf('ask.tdd'),
|
|
400
|
+
askQa: citeOf('ask.qa'),
|
|
401
|
+
askGateBlock: citeOf('ask.gateBlock'),
|
|
402
|
+
askPayload: citeOf('ask.payload'),
|
|
403
|
+
askRelayOnly: citeOf('ask.relayOnly'),
|
|
404
|
+
askFixedArtifact: citeOf('ask.fixedArtifact'),
|
|
405
|
+
|
|
406
|
+
reviewDriver: citeOf('review.driver'),
|
|
407
|
+
reviewFailClosed: citeOf('review.failClosed'),
|
|
408
|
+
teamsLoop: citeOf('teams.loop'),
|
|
409
|
+
graphWhy: citeOf('graph.why'),
|
|
410
|
+
graphBuild: citeOf('graph.build'),
|
|
411
|
+
graphReach: citeOf('graph.reach'),
|
|
412
|
+
graphNext: citeOf('graph.next'),
|
|
413
|
+
graphBack: citeOf('graph.back'),
|
|
414
|
+
graphAddendum: citeOf('graph.addendum'),
|
|
415
|
+
graphBackEdgeWhy: citeOf('graph.backEdgeWhy'),
|
|
416
|
+
graphVisited: citeOf('graph.visited'),
|
|
417
|
+
delegVerdict: citeOf('deleg.verdict'),
|
|
418
|
+
delegRevise: citeOf('deleg.reviseDefault'),
|
|
419
|
+
closeoutReport: citeOf('closeout.report'),
|
|
420
|
+
closeoutScaffold: citeOf('closeout.scaffold'),
|
|
421
|
+
closeoutLocked: citeOf('closeout.lockedRefusal'),
|
|
422
|
+
closeoutPrereq: citeOf('closeout.prereq'),
|
|
423
|
+
trainingEvidence: citeOf('training.evidence'),
|
|
424
|
+
trainingRefusal: citeOf('training.refusal'),
|
|
425
|
+
settlementSeam: citeOf('settlement.seam'),
|
|
426
|
+
policyText: citeOf('policy.text'),
|
|
427
|
+
skillsPhase: citeOf('skills.phase'),
|
|
428
|
+
toolCloseout: citeOf('tool.closeout'),
|
|
429
|
+
toolCloseoutPhase08: citeOf('tool.closeoutPhase08'),
|
|
430
|
+
toolReviewRevise: citeOf('tool.reviewRevise'),
|
|
431
|
+
toolDelegateNotJudge: citeOf('tool.delegateNotJudge'),
|
|
432
|
+
toolAuditTeamApprove: citeOf('tool.auditTeamApprove'),
|
|
433
|
+
toolLockOrdering: citeOf('tool.lockOrdering'),
|
|
434
|
+
toolReopenParam: citeOf('tool.reopenParam'),
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Declared SECTION_MAP list lengths — every one is re-derived from source in verify(). */
|
|
438
|
+
const SECTION_LENGTHS = {
|
|
439
|
+
'00-worktree.md': 12, '00-requirements.md': 6, '01-as-is.md': 10, '01.5-root-cause.md': 12,
|
|
440
|
+
'02-to-be-plan.md': 13, '03-implementation-summary.md': 8, '03.5-code-review.md': 10,
|
|
441
|
+
'04-test-summary.md': 12, '05-manual-qa.md': 8, '06-decisions-update.md': 7,
|
|
442
|
+
'07-state-update.md': 7, '08-memory-impact.md': 12,
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The section-map range for one artifact: its anchor's start line through the
|
|
447
|
+
* declared LIST LENGTH, so the range moves with the file and is still checked
|
|
448
|
+
* against it. The lengths are asserted against the parsed source in verify().
|
|
449
|
+
*/
|
|
450
|
+
function sectionsCite(file) {
|
|
451
|
+
const at = citeOf('section.' + file)
|
|
452
|
+
const f = at.slice(0, at.indexOf(':'))
|
|
453
|
+
const start = Number(at.slice(at.indexOf(':') + 1))
|
|
454
|
+
const len = SECTION_LENGTHS[file]
|
|
455
|
+
if (!len) throw new Error('no declared section length for ' + file)
|
|
456
|
+
const end = start + len - 1
|
|
457
|
+
if (end > sourceLines(f).length) throw new Error('section range past end of ' + f + ' for ' + file)
|
|
458
|
+
return f + ':' + start + '-' + end
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/* ========================================================================== */
|
|
462
|
+
/* TOKENS — the single definition of the palette. */
|
|
463
|
+
/* */
|
|
464
|
+
/* The CSS below is RENDERED FROM THIS OBJECT and the contrast audit READS IT, */
|
|
465
|
+
/* so a colour cannot be changed in one place and audited in the other. Every */
|
|
466
|
+
/* value lives here once. */
|
|
467
|
+
/* ========================================================================== */
|
|
468
|
+
|
|
469
|
+
/* NOTE on the two *_INK values: they are SVG BOX FILLS, not text or state
|
|
470
|
+
borders. A diagram region's fill owes no WCAG text contrast — what owes
|
|
471
|
+
contrast is the LABEL on it, and every label is an audited pair below
|
|
472
|
+
(warn on n-950 = 10.06:1, danger on n-950 = 8.55:1). They are listed as
|
|
473
|
+
tokens so the "no hardcoded hex" check stays meaningful. */
|
|
474
|
+
const C = {
|
|
475
|
+
'n-950': '#070a0c', 'n-900': '#0d1116', 'n-850': '#121820', 'n-800': '#18202a',
|
|
476
|
+
'n-700': '#1f2933', 'n-600': '#5a6a80', 'n-500': '#55637a', 'n-400': '#7c8b9d',
|
|
477
|
+
'n-300': '#8c9dad', 'n-200': '#adbccb', 'n-100': '#d2dce6', 'n-050': '#eaf0f6',
|
|
478
|
+
acc: '#5fd0e8', 'acc-dim': '#1d4d59',
|
|
479
|
+
warn: '#f0ab4d', 'warn-dim': '#5a3f14',
|
|
480
|
+
danger: '#ff8877', 'danger-dim': '#5c2820',
|
|
481
|
+
ok: '#7fd6a3', 'ok-dim': '#1c4632',
|
|
482
|
+
violet: '#b9a7ff', 'violet-dim': '#3a3468',
|
|
483
|
+
'warn-ink': '#1b1409', 'danger-ink': '#3a1310',
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* The pairs that must satisfy WCAG, and the threshold each one owes.
|
|
488
|
+
*
|
|
489
|
+
* ⚠ EVERY TEXT PAIR IS HERE, and each owes 4.5:1 (WCAG 2.2 AA, 1.4.3). The 3:1
|
|
490
|
+
* entries are 1.4.11 non-text contrast, and only for borders that carry STATE
|
|
491
|
+
* (a tab's border, a tag's border). Decorative separators owe nothing — WCAG
|
|
492
|
+
* exempts purely decorative presentation — and are listed with a threshold of 1
|
|
493
|
+
* so the number is still REPORTED rather than quietly excluded. Writing a low
|
|
494
|
+
* number down where nothing is owed is the point: an exemption that is stated
|
|
495
|
+
* can be argued with; one that is silent cannot.
|
|
496
|
+
*/
|
|
497
|
+
const CONTRAST_REQUIREMENTS = [
|
|
498
|
+
{ fg: 'n-050', bg: 'n-950', need: 4.5, what: 'body text on the page canvas' },
|
|
499
|
+
{ fg: 'n-050', bg: 'n-900', need: 4.5, what: 'body text on a panel' },
|
|
500
|
+
{ fg: 'n-050', bg: 'n-850', need: 4.5, what: 'body text on a card' },
|
|
501
|
+
{ fg: 'n-050', bg: 'n-800', need: 4.5, what: 'text on the active tab / table head' },
|
|
502
|
+
{ fg: 'n-100', bg: 'n-850', need: 4.5, what: 'inline code on a card' },
|
|
503
|
+
{ fg: 'n-200', bg: 'n-850', need: 4.5, what: 'secondary text on a card' },
|
|
504
|
+
{ fg: 'n-200', bg: 'n-950', need: 4.5, what: 'pre-formatted text on the canvas' },
|
|
505
|
+
{ fg: 'n-300', bg: 'n-900', need: 4.5, what: 'muted text (subtitles, notes) on a panel' },
|
|
506
|
+
{ fg: 'n-300', bg: 'n-950', need: 4.5, what: 'muted text on the canvas' },
|
|
507
|
+
{ fg: 'n-400', bg: 'n-950', need: 4.5, what: 'the faintest caption — file:line citations' },
|
|
508
|
+
{ fg: 'n-400', bg: 'n-900', need: 4.5, what: 'faint caption inside a panel' },
|
|
509
|
+
{ fg: 'n-400', bg: 'n-850', need: 4.5, what: 'faint caption inside a card' },
|
|
510
|
+
{ fg: 'acc', bg: 'n-900', need: 4.5, what: 'accent text and links' },
|
|
511
|
+
{ fg: 'acc', bg: 'n-950', need: 4.5, what: 'accent text on the canvas' },
|
|
512
|
+
{ fg: 'acc', bg: 'n-850', need: 4.5, what: 'artifact names on a card' },
|
|
513
|
+
{ fg: 'warn', bg: 'n-900', need: 4.5, what: 'a DECISION label — amber' },
|
|
514
|
+
{ fg: 'warn', bg: 'n-950', need: 4.5, what: 'a DECISION label on the canvas / in the diagram' },
|
|
515
|
+
{ fg: 'warn', bg: 'n-850', need: 4.5, what: 'a DECISION label on a card' },
|
|
516
|
+
{ fg: 'danger', bg: 'n-900', need: 4.5, what: 'a REFUSAL label — red' },
|
|
517
|
+
{ fg: 'danger', bg: 'n-850', need: 4.5, what: 'a REFUSAL label on a card' },
|
|
518
|
+
{ fg: 'danger', bg: 'n-950', need: 4.5, what: 'a REFUSAL label in the diagram' },
|
|
519
|
+
{ fg: 'ok', bg: 'n-850', need: 4.5, what: 'an ABSTAIN/allow label — green' },
|
|
520
|
+
{ fg: 'ok', bg: 'n-900', need: 4.5, what: 'a passing label on a panel' },
|
|
521
|
+
{ fg: 'violet', bg: 'n-850', need: 4.5, what: 'a backward-edge label — violet' },
|
|
522
|
+
{ fg: 'violet', bg: 'n-900', need: 4.5, what: 'a backward-edge label on a panel' },
|
|
523
|
+
{ fg: 'warn-ink', bg: 'warn', need: 4.5, what: 'the skip link (dark ink on amber)' },
|
|
524
|
+
{ fg: 'danger-ink', bg: 'danger', need: 4.5, what: 'the focus ring drawn on a refusal surface' },
|
|
525
|
+
{ fg: 'n-600', bg: 'n-900', need: 3.0, what: 'a control border (tab, tag) made of the strong line' },
|
|
526
|
+
{ fg: 'n-600', bg: 'n-950', need: 3.0, what: 'that same control border on the canvas' },
|
|
527
|
+
{ fg: 'n-600', bg: 'n-850', need: 3.0, what: 'that same control border on a card' },
|
|
528
|
+
{ fg: 'n-500', bg: 'n-900', need: 3.0, what: 'the sequence rail dot — a state mark, not a hairline' },
|
|
529
|
+
{ fg: 'n-700', bg: 'n-950', need: 1.0, what: 'the background grid line — DECORATIVE, no threshold owed' },
|
|
530
|
+
{ fg: 'warn-dim', bg: 'n-950', need: 1.0, what: 'the amber rule border — decorative; the label beside it is the signal' },
|
|
531
|
+
{ fg: 'danger-dim', bg: 'n-950', need: 1.0, what: 'the refusal rule border — decorative, same reasoning' },
|
|
532
|
+
{ fg: 'warn-ink', bg: 'n-950', need: 1.0, what: 'the human box fill in the diagram — a region, not a signal' },
|
|
533
|
+
{ fg: 'danger-ink', bg: 'n-950', need: 1.0, what: 'the refusal box fill — a region, not a signal' },
|
|
534
|
+
]
|
|
535
|
+
|
|
536
|
+
function srgbToLinear(channel) {
|
|
537
|
+
const c = channel / 255
|
|
538
|
+
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
|
|
539
|
+
}
|
|
540
|
+
function luminance(hex) {
|
|
541
|
+
const h = hex.replace('#', '')
|
|
542
|
+
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16)
|
|
543
|
+
return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b)
|
|
544
|
+
}
|
|
545
|
+
function contrast(a, b) {
|
|
546
|
+
const la = luminance(a), lb = luminance(b)
|
|
547
|
+
const hi = Math.max(la, lb), lo = Math.min(la, lb)
|
|
548
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
549
|
+
}
|
|
550
|
+
const tone = (name) => `#${C[name].replace('#', '')}`
|
|
551
|
+
|
|
552
|
+
/* ========================================================================== */
|
|
553
|
+
/* FACTS — transcribed from src/, each carrying its citation */
|
|
554
|
+
/* ========================================================================== */
|
|
555
|
+
|
|
556
|
+
const REPO = 'dsh-recursive-mode'
|
|
557
|
+
|
|
558
|
+
/** `lock.ts:21` PHASE_SEQUENCE — the canonical order, and the node order of the graph. */
|
|
559
|
+
const PHASES = [
|
|
560
|
+
{
|
|
561
|
+
file: '00-requirements.md',
|
|
562
|
+
c: SRC.seq,
|
|
563
|
+
phaseN: '0',
|
|
564
|
+
human: true,
|
|
565
|
+
late: false,
|
|
566
|
+
audited: false,
|
|
567
|
+
optional: false,
|
|
568
|
+
traceability: false,
|
|
569
|
+
kind: 'Requirements spec',
|
|
570
|
+
inputs: [{ doc: 'the conversation / the issue', c: SRC.tplRequirements, artifact: false }],
|
|
571
|
+
outputs: [{ doc: '00-requirements.md', c: SRC.tplRequirements, artifact: true }],
|
|
572
|
+
gates: [
|
|
573
|
+
{ t: 'HUMAN GATE — `run-start`. Nothing runs and no goal exists until recursive_ask gate=run-start records the approving label.', c: SRC.startGate, human: true, shape: 'decision' },
|
|
574
|
+
{ t: 'The gate refuses to be RAISED while `00-requirements.md` is still the scaffold template (RM4404), quoting the placeholder lines back.', c: SRC.startGuard, human: true, shape: 'refuse' },
|
|
575
|
+
{ t: 'Approving label `Start run`; withholding label `Hold`. The approving VALUE is what is matched — a recorded `Hold` is not consent.', c: SRC.startLabels + ', ' + SRC.startValueMatch, human: true, shape: 'decision' },
|
|
576
|
+
{ t: 'The approval is written as a durable `- Run Start: Start run` line in this very artifact.', c: SRC.startLine, human: true, shape: 'decision' },
|
|
577
|
+
{ t: '`syncRunGoal` refuses to create a run goal without that record, in EVERY branch that would create one.', c: SRC.startModule, human: true, shape: 'refuse' },
|
|
578
|
+
{ t: 'Coverage gate must read PASS; Approval gate must read PASS.', c: sectionsCite('00-requirements.md'), human: true, shape: 'gate' },
|
|
579
|
+
{ t: 'GUARD RULE `memory-read` — the FIRST write to a phase-0 artifact is denied until this run holds a memory READ RECEIPT for it. It reads the RECEIPT, never the artifact text, because a caller controls the text and could forge it; an empty memory plane satisfies the gate, a never-entered phase does not. `recursive_phase` is the call that records the read.', c: SRC.memoryReadRule, human: false, shape: 'refuse' },
|
|
580
|
+
],
|
|
581
|
+
sections: ['TODO', 'Requirements', 'Out of Scope', 'Constraints', 'Coverage Gate', 'Approval Gate'],
|
|
582
|
+
sectionsC: sectionsCite('00-requirements.md'),
|
|
583
|
+
questions: [],
|
|
584
|
+
refusals: ['RM4404 RUN_START_SPEC_UNFILLED — no run spec to approve', 'RM5502 RUN_START_NO_CHANNEL — no user-questions channel mounted', 'RM5503 RUN_START_UNANSWERED — the channel failed before a person answered', 'RM5504 RUN_START_ANSWER_UNUSABLE — a person answered, off-vocabulary', 'memory-read — no memory read receipt for this run, so the requirements artifact cannot be written yet'],
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
file: '00-worktree.md',
|
|
588
|
+
c: SRC.seq,
|
|
589
|
+
phaseN: '0',
|
|
590
|
+
human: false,
|
|
591
|
+
late: false,
|
|
592
|
+
audited: false,
|
|
593
|
+
optional: false,
|
|
594
|
+
traceability: false,
|
|
595
|
+
kind: 'Isolation + diff basis',
|
|
596
|
+
inputs: [
|
|
597
|
+
{ doc: '00-requirements.md', c: SRC.inputMap, artifact: true },
|
|
598
|
+
{ doc: 'current git repository state', c: SRC.tplWorktree, artifact: false },
|
|
599
|
+
],
|
|
600
|
+
outputs: [{ doc: '00-worktree.md', c: SRC.tplWorktree, artifact: true }],
|
|
601
|
+
gates: [
|
|
602
|
+
{ t: 'Shares phase number 0 with `00-requirements.md` — the phase-order guard treats both as the active phase.', c: SRC.phaseOrderRule, human: false, shape: 'gate' },
|
|
603
|
+
{ t: 'Supplies the executable diff basis every later audited phase reuses; the linter fails until the normalized basis matches live git state.', c: SRC.tplWorktree, human: false, shape: 'gate' },
|
|
604
|
+
{ t: 'Coverage + Approval gates.', c: sectionsCite('00-worktree.md'), human: false, shape: 'gate' },
|
|
605
|
+
],
|
|
606
|
+
sections: ['TODO', 'Directory Selection', 'Safety Verification', 'Worktree Creation', 'Main Branch Protection', 'Project Setup', 'Test Baseline Verification', 'Worktree Context', 'Diff Basis For Later Audits', 'Traceability', 'Coverage Gate', 'Approval Gate'],
|
|
607
|
+
sectionsC: sectionsCite('00-worktree.md'),
|
|
608
|
+
questions: [],
|
|
609
|
+
refusals: ['`write*` outside the run tree (phases 6-8 only)', 'lock-order: an earlier PRESENT artifact is not LOCKED'],
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
file: '01-as-is.md',
|
|
613
|
+
c: SRC.seq,
|
|
614
|
+
phaseN: '1',
|
|
615
|
+
human: false,
|
|
616
|
+
late: false,
|
|
617
|
+
audited: true,
|
|
618
|
+
optional: true,
|
|
619
|
+
traceability: true,
|
|
620
|
+
priorEvidence: true,
|
|
621
|
+
kind: 'AS-IS characterization',
|
|
622
|
+
inputs: [{ doc: '00-requirements.md', c: SRC.inputMap, artifact: true }],
|
|
623
|
+
outputs: [{ doc: '01-as-is.md', c: SRC.tplLater, artifact: true }],
|
|
624
|
+
gates: [
|
|
625
|
+
{ t: 'AUDITED phase: requires `Audit: PASS`, `## Requirement Completion Status`, and a Delegation Decision Basis / Subagent Capability Probe marker.', c: 'src/lifecycle.ts:99-105', human: false, shape: 'gate' },
|
|
626
|
+
{ t: 'Coverage + Approval gates; `## Effective Inputs Re-read` is required of every phase doc.', c: 'src/lifecycle.ts:117', human: false, shape: 'gate' },
|
|
627
|
+
{ t: 'Reads PRIOR recursive evidence (in a profile that carries it).', c: SRC.priorEvidence, human: false, shape: 'gate' },
|
|
628
|
+
{ t: 'The phase baseline DENIES writes to `.recursive/memory/**`, `DECISIONS.md`, `STATE.md` in phases 1-2.', c: SRC.baseline, human: false, shape: 'refuse' },
|
|
629
|
+
{ t: 'Declared OPTIONAL: an absent optional phase does not stop the run.', c: SRC.optional + ', ' + SRC.graphNext, human: false, shape: 'gate' },
|
|
630
|
+
],
|
|
631
|
+
sections: ['TODO', 'Reproduction Steps (Novice-Runnable)', 'Current Behavior by Requirement', 'Source Requirement Inventory', 'Relevant Code Pointers', 'Known Unknowns', 'Evidence', 'Traceability', 'Coverage Gate', 'Approval Gate'],
|
|
632
|
+
sectionsC: sectionsCite('01-as-is.md'),
|
|
633
|
+
sectionsExtraC: SRC.getSections,
|
|
634
|
+
questions: [],
|
|
635
|
+
refusals: ['phase-order: a LATER phase artifact is written while this phase is still active', 'locked-write: the target carries `Status: LOCKED`', 'lock-order: a prerequisite is not LOCKED', 'phase 1-2 memory-plane write denial'],
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
file: '01.5-root-cause.md',
|
|
639
|
+
c: SRC.seq,
|
|
640
|
+
phaseN: '1',
|
|
641
|
+
human: false,
|
|
642
|
+
late: false,
|
|
643
|
+
audited: true,
|
|
644
|
+
optional: true,
|
|
645
|
+
traceability: true,
|
|
646
|
+
kind: 'Root-cause analysis',
|
|
647
|
+
inputs: [{ doc: '01-as-is.md', c: SRC.inputMap, artifact: true }],
|
|
648
|
+
outputs: [{ doc: '01.5-root-cause.md', c: SRC.tplLater, artifact: true }],
|
|
649
|
+
gates: [
|
|
650
|
+
{ t: 'Shares phase number 1 with `01-as-is.md` — `phaseNumberForArtifact` takes the leading digits, so both are phase 1.', c: SRC.phaseNumber + ', ' + SRC.phaseOrderRule, human: false, shape: 'gate' },
|
|
651
|
+
{ t: 'AUDITED phase (same requirement as 01).', c: SRC.audited + ', src/lifecycle.ts:99-105', human: false, shape: 'gate' },
|
|
652
|
+
{ t: 'Optional; absent-and-optional is skipped by the legal-phase selector.', c: SRC.optional + ', ' + SRC.graphNext, human: false, shape: 'gate' },
|
|
653
|
+
{ t: 'Its presence changes what phase 2 must cite as input.', c: SRC.inputMap, human: false, shape: 'gate' },
|
|
654
|
+
],
|
|
655
|
+
sections: ['TODO', 'Error Analysis', 'Reproduction Verification', 'Recent Changes Analysis', 'Evidence Gathering (Multi-Layer if applicable)', 'Data Flow Trace', 'Pattern Analysis', 'Hypothesis Testing', 'Root Cause Summary', 'Traceability', 'Coverage Gate', 'Approval Gate'],
|
|
656
|
+
sectionsC: sectionsCite('01.5-root-cause.md'),
|
|
657
|
+
sectionsExtraC: SRC.getSections,
|
|
658
|
+
questions: [],
|
|
659
|
+
refusals: ['Same guard set as 01.', 'lock-order: `01-as-is.md` present but not LOCKED'],
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
file: '02-to-be-plan.md',
|
|
663
|
+
c: SRC.seq,
|
|
664
|
+
phaseN: '2',
|
|
665
|
+
human: false,
|
|
666
|
+
late: false,
|
|
667
|
+
audited: true,
|
|
668
|
+
optional: true,
|
|
669
|
+
traceability: true,
|
|
670
|
+
priorEvidence: true,
|
|
671
|
+
kind: 'TO-BE plan',
|
|
672
|
+
inputs: [
|
|
673
|
+
{ doc: '00-requirements.md', c: SRC.inputMap, artifact: true },
|
|
674
|
+
{ doc: '01-as-is.md', c: SRC.inputMap, artifact: true },
|
|
675
|
+
{ doc: '01.5-root-cause.md — only when present', c: SRC.inputMap, artifact: true },
|
|
676
|
+
],
|
|
677
|
+
outputs: [{ doc: '02-to-be-plan.md', c: SRC.tplLater, artifact: true }],
|
|
678
|
+
gates: [
|
|
679
|
+
{ t: 'AUDITED + `Requirement Mapping` / `Plan Drift Check` sections.', c: sectionsCite('02-to-be-plan.md') + ', src/lifecycle.ts:99-105', human: false, shape: 'gate' },
|
|
680
|
+
{ t: 'Diff-audited: the run diff is audited against this plan.', c: SRC.diffAudited, human: false, shape: 'gate' },
|
|
681
|
+
{ t: 'Reads PRIOR recursive evidence.', c: SRC.priorEvidence, human: false, shape: 'gate' },
|
|
682
|
+
{ t: 'PLAN GATE at the plan-mode exit: `exit_plan_mode` is DENIED while the run still waits on a discovery phase.', c: SRC.planGate, human: false, shape: 'refuse' },
|
|
683
|
+
{ t: 'Phase 2 memory-plane write denial (phases 1-2).', c: SRC.baseline, human: false, shape: 'refuse' },
|
|
684
|
+
],
|
|
685
|
+
sections: ['TODO', 'Planned Changes by File', 'Requirement Mapping', 'Implementation Steps', 'Testing Strategy', 'Playwright Plan (if applicable)', 'Manual QA Scenarios', 'Idempotence and Recovery', 'Implementation Sub-phases', 'Plan Drift Check', 'Traceability', 'Coverage Gate', 'Approval Gate'],
|
|
686
|
+
sectionsC: sectionsCite('02-to-be-plan.md'),
|
|
687
|
+
sectionsExtraC: SRC.getSections,
|
|
688
|
+
questions: [],
|
|
689
|
+
refusals: ['`exit_plan_mode` refused by the `exit-plan-mode-gate` hook (priority 5)', 'phase-order / locked-write / lock-order', 'phase 1-2 memory-plane write denial'],
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
file: '03-implementation-summary.md',
|
|
693
|
+
c: SRC.seq,
|
|
694
|
+
phaseN: '3',
|
|
695
|
+
human: false,
|
|
696
|
+
late: false,
|
|
697
|
+
audited: true,
|
|
698
|
+
optional: true,
|
|
699
|
+
traceability: true,
|
|
700
|
+
tdd: true,
|
|
701
|
+
kind: 'Implementation + TDD evidence',
|
|
702
|
+
inputs: [{ doc: '02-to-be-plan.md', c: SRC.inputMap, artifact: true }],
|
|
703
|
+
outputs: [{ doc: '03-implementation-summary.md', c: SRC.tplLater, artifact: true }],
|
|
704
|
+
gates: [
|
|
705
|
+
{ t: 'HUMAN GATE — `tdd-mode`. `recursive_ask` labels `strict` / `pragmatic` are written to the `TDD Mode` marker.', c: SRC.askTdd, human: true, shape: 'decision' },
|
|
706
|
+
{ t: 'TDD gate: `strict` requires RED **and** GREEN evidence paths to exist on disk.', c: 'src/lifecycle.ts:84-96', human: false, shape: 'gate' },
|
|
707
|
+
{ t: 'Guard rule `tdd-evidence`: the phase-3 baseline DENIES `recursive_lock*` while the artifact declares `TDD Mode: strict` without both RED and GREEN.', c: SRC.baseline + ', ' + SRC.tddVerdict, human: false, shape: 'refuse' },
|
|
708
|
+
{ t: '`pragmatic` requires an exception rationale (`## Pragmatic TDD Exception`).', c: 'src/lifecycle.ts:91-92', human: false, shape: 'gate' },
|
|
709
|
+
{ t: 'Diff-audited; reads PRIOR recursive evidence.', c: SRC.diffAudited + ', ' + SRC.priorEvidence, human: false, shape: 'gate' },
|
|
710
|
+
],
|
|
711
|
+
sections: ['TODO', 'Changes Applied', 'TDD Compliance Log', 'Plan Deviations', 'Implementation Evidence', 'Traceability', 'Coverage Gate', 'Approval Gate'],
|
|
712
|
+
sectionsC: sectionsCite('03-implementation-summary.md'),
|
|
713
|
+
sectionsExtraC: SRC.getSections,
|
|
714
|
+
questions: [{ gate: 'tdd-mode', header: 'TDD Mode', q: 'How should phase 3 handle test evidence?', opts: ['strict — RED and GREEN evidence paths are required before the phase can lock', 'pragmatic — a written rationale is accepted in place of evidence paths'], c: SRC.askTdd }],
|
|
715
|
+
refusals: ['`tdd-evidence`: lock denied without RED + GREEN when `TDD Mode: strict`', 'phase-order / locked-write / lock-order', 'PENDING_WORK (RM4403): unresolved delegated work blocks the lock'],
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
file: '03.5-code-review.md',
|
|
719
|
+
c: SRC.seq,
|
|
720
|
+
phaseN: '3',
|
|
721
|
+
human: false,
|
|
722
|
+
late: false,
|
|
723
|
+
audited: true,
|
|
724
|
+
optional: true,
|
|
725
|
+
traceability: true,
|
|
726
|
+
kind: 'Independent code review',
|
|
727
|
+
inputs: [
|
|
728
|
+
{ doc: '02-to-be-plan.md', c: SRC.inputMap, artifact: true },
|
|
729
|
+
{ doc: '03-implementation-summary.md', c: SRC.inputMap, artifact: true },
|
|
730
|
+
],
|
|
731
|
+
outputs: [{ doc: '03.5-code-review.md', c: SRC.tplLater, artifact: true }],
|
|
732
|
+
gates: [
|
|
733
|
+
{ t: 'Shares phase number 3 with the implementation summary.', c: SRC.phaseNumber, human: false, shape: 'gate' },
|
|
734
|
+
{ t: 'Carries its own `## Verdict` section — the APPROVE / REVISE / REJECT vocabulary lives here.', c: sectionsCite('03.5-code-review.md'), human: false, shape: 'gate' },
|
|
735
|
+
{ t: 'AUDITED + diff-audited.', c: SRC.audited + ', ' + SRC.diffAudited, human: false, shape: 'gate' },
|
|
736
|
+
{ t: 'A review bundle whose recorded artifact hash no longer matches the artifact is reported STALE.', c: SRC.lintStale, human: false, shape: 'gate' },
|
|
737
|
+
],
|
|
738
|
+
sections: ['TODO', 'Review Scope', 'Plan Alignment Assessment', 'Code Quality Assessment', 'Issues Found', 'Verdict', 'Review Metadata', 'Traceability', 'Coverage Gate', 'Approval Gate'],
|
|
739
|
+
sectionsC: sectionsCite('03.5-code-review.md'),
|
|
740
|
+
sectionsExtraC: SRC.getSections,
|
|
741
|
+
questions: [],
|
|
742
|
+
refusals: ['Same guard set as 03.', 'A REVISE verdict routes to a repair follow-up rather than a lock — see the loop section.'],
|
|
743
|
+
},
|
|
744
|
+
{
|
|
745
|
+
file: '04-test-summary.md',
|
|
746
|
+
c: SRC.seq,
|
|
747
|
+
phaseN: '4',
|
|
748
|
+
human: false,
|
|
749
|
+
late: false,
|
|
750
|
+
audited: true,
|
|
751
|
+
optional: true,
|
|
752
|
+
traceability: true,
|
|
753
|
+
priorEvidence: true,
|
|
754
|
+
closeout: true,
|
|
755
|
+
kind: 'Test evidence',
|
|
756
|
+
inputs: [
|
|
757
|
+
{ doc: '02-to-be-plan.md', c: SRC.inputMap, artifact: true },
|
|
758
|
+
{ doc: '03-implementation-summary.md', c: SRC.inputMap, artifact: true },
|
|
759
|
+
{ doc: '03.5-code-review.md — only when present', c: SRC.inputMap, artifact: true },
|
|
760
|
+
],
|
|
761
|
+
outputs: [{ doc: '04-test-summary.md', c: SRC.closeoutScaffold + ', ' + SRC.tplLater, artifact: true }],
|
|
762
|
+
gates: [
|
|
763
|
+
{ t: 'CLOSEOUT ENTRY — `recursive_closeout` REPORTS what this artifact is missing (required sections, Coverage/Approval gates). It never writes the phase document.', c: SRC.toolCloseout + ', ' + SRC.closeoutReport, human: false, shape: 'gate' },
|
|
764
|
+
{ t: 'A closeout refuses when an earlier artifact is not LOCKED, because scaffolding a receipt over a DRAFT is the defect closeout-report.ts exists to prevent.', c: SRC.closeoutPrereq, human: false, shape: 'refuse' },
|
|
765
|
+
{ t: 'A closeout REFUSES to run against an artifact that is already LOCKED (it must not silently unlock its own artifact).', c: SRC.closeoutLocked, human: false, shape: 'refuse' },
|
|
766
|
+
{ t: 'AUDITED; reads PRIOR recursive evidence; exact commands and evidence must be recorded.', c: SRC.audited + ', ' + SRC.priorEvidence, human: false, shape: 'gate' },
|
|
767
|
+
],
|
|
768
|
+
sections: ['TODO', 'Pre-Test Implementation Audit', 'Environment', 'Execution Mode', 'Commands Executed (Exact)', 'Results Summary', 'Evidence and Artifacts', 'Failures and Diagnostics (if any)', 'Flake/Rerun Notes', 'Traceability', 'Coverage Gate', 'Approval Gate'],
|
|
769
|
+
sectionsC: sectionsCite('04-test-summary.md'),
|
|
770
|
+
sectionsExtraC: SRC.getSections,
|
|
771
|
+
questions: [],
|
|
772
|
+
refusals: ['closeout: unsupported phase key', 'closeout: an earlier artifact is not LOCKED', 'closeout: the artifact is already LOCKED', 'lock-order / locked-write / phase-order'],
|
|
773
|
+
},
|
|
774
|
+
{
|
|
775
|
+
file: '05-manual-qa.md',
|
|
776
|
+
c: SRC.seq,
|
|
777
|
+
phaseN: '5',
|
|
778
|
+
human: true,
|
|
779
|
+
late: false,
|
|
780
|
+
audited: false,
|
|
781
|
+
optional: true,
|
|
782
|
+
traceability: true,
|
|
783
|
+
closeout: true,
|
|
784
|
+
kind: 'Manual QA + sign-off',
|
|
785
|
+
inputs: [{ doc: '02-to-be-plan.md', c: SRC.inputMap, artifact: true }],
|
|
786
|
+
outputs: [{ doc: '05-manual-qa.md', c: SRC.closeoutScaffold + ', ' + SRC.tplLater, artifact: true }],
|
|
787
|
+
gates: [
|
|
788
|
+
{ t: 'HUMAN GATE — `qa-signoff`. `recursive_ask` labels `human` / `agent-operated` / `hybrid` are written to the `QA Execution Mode` marker.', c: SRC.askQa, human: true, shape: 'decision' },
|
|
789
|
+
{ t: '`human` and `hybrid` REQUIRE user sign-off before the lock; the sign-off needs a meaningful `Approved by` and `Date`.', c: 'src/lifecycle.ts:108-114, ' + SRC.lintQaSignoff, human: true, shape: 'decision' },
|
|
790
|
+
{ t: '`agent-operated` requires a named Agent Executor and Tools Used.', c: SRC.lintQa, human: false, shape: 'gate' },
|
|
791
|
+
{ t: 'The QA mode must be declared at all — an undeclared mode was a lock failure. (Note the two spellings: `05-manual-qa.md` is NOT in AUDITED_PHASE_FILES, but it IS in the linter\'s AUDITED_REQUIREMENT_FILES set at ts-lint.ts:60.)', c: 'src/lifecycle.ts:110', human: false, shape: 'gate' },
|
|
792
|
+
{ t: 'CLOSEOUT ENTRY: report-only, with a receipt of its own.', c: SRC.toolCloseout, human: false, shape: 'gate' },
|
|
793
|
+
],
|
|
794
|
+
sections: ['TODO', 'QA Execution Record', 'QA Scenarios and Results', 'Evidence and Artifacts', 'User Sign-Off', 'Traceability', 'Coverage Gate', 'Approval Gate'],
|
|
795
|
+
sectionsC: sectionsCite('05-manual-qa.md'),
|
|
796
|
+
questions: [{ gate: 'qa-signoff', header: 'QA sign-off', q: 'Who signs off the manual QA phase?', opts: ['human — a person runs and signs the QA checklist', 'agent-operated — the agent runs it and records the results', 'hybrid — the agent runs it; a person signs the result'], c: SRC.askQa }],
|
|
797
|
+
refusals: ['`QA Execution Mode human|hybrid requires user sign-off`', 'closeout prerequisite / already-LOCKED refusals', 'lock-order / locked-write / phase-order'],
|
|
798
|
+
},
|
|
799
|
+
{
|
|
800
|
+
file: '06-decisions-update.md',
|
|
801
|
+
c: SRC.seq,
|
|
802
|
+
phaseN: '6',
|
|
803
|
+
human: false,
|
|
804
|
+
late: true,
|
|
805
|
+
audited: true,
|
|
806
|
+
optional: false,
|
|
807
|
+
traceability: true,
|
|
808
|
+
closeout: true,
|
|
809
|
+
kind: 'Decision ledger delta',
|
|
810
|
+
inputs: [{ doc: 'every present artifact in the run except itself', c: SRC.inputMap, artifact: true }],
|
|
811
|
+
outputs: [
|
|
812
|
+
{ doc: '06-decisions-update.md', c: SRC.closeoutScaffold + ', ' + SRC.tplLater, artifact: true },
|
|
813
|
+
{ doc: '.recursive/DECISIONS.md — the ledger this phase updates', c: SRC.closeoutScaffold, artifact: false },
|
|
814
|
+
],
|
|
815
|
+
gates: [
|
|
816
|
+
{ t: 'LATE phase (`LATE_PHASE_ARTIFACTS`).', c: SRC.late, human: false, shape: 'gate' },
|
|
817
|
+
{ t: 'Phase baseline DENIES `write*` outside the run tree: the implementation is frozen in phases 6-8.', c: SRC.baseline, human: false, shape: 'refuse' },
|
|
818
|
+
{ t: 'Phase baseline DENIES `write*` to the memory planes in phase 6 — `.recursive/DECISIONS.md`, `STATE.md` and `.recursive/memory/**` are earned by their own phases.', c: SRC.baseline, human: false, shape: 'refuse' },
|
|
819
|
+
{ t: 'AUDITED + closeout report-only entry.', c: SRC.audited + ', ' + SRC.toolCloseout, human: false, shape: 'gate' },
|
|
820
|
+
],
|
|
821
|
+
sections: ['TODO', 'Decisions Changes Applied', 'Rationale', 'Resulting Decision Entry', 'Traceability', 'Coverage Gate', 'Approval Gate'],
|
|
822
|
+
sectionsC: sectionsCite('06-decisions-update.md'),
|
|
823
|
+
sectionsExtraC: SRC.getSections,
|
|
824
|
+
questions: [],
|
|
825
|
+
refusals: ['phase 6: `write*` outside the run tree denied', 'phase 6: `write*` to `.recursive/DECISIONS.md|STATE.md` / `.recursive/memory/**` denied', 'lock-order / locked-write / phase-order'],
|
|
826
|
+
},
|
|
827
|
+
{
|
|
828
|
+
file: '07-state-update.md',
|
|
829
|
+
c: SRC.seq,
|
|
830
|
+
phaseN: '7',
|
|
831
|
+
human: false,
|
|
832
|
+
late: true,
|
|
833
|
+
audited: true,
|
|
834
|
+
optional: false,
|
|
835
|
+
traceability: true,
|
|
836
|
+
priorEvidence: true,
|
|
837
|
+
closeout: true,
|
|
838
|
+
kind: 'State ledger delta',
|
|
839
|
+
inputs: [{ doc: '06-decisions-update.md', c: SRC.inputMap, artifact: true }],
|
|
840
|
+
outputs: [
|
|
841
|
+
{ doc: '07-state-update.md', c: SRC.closeoutScaffold + ', ' + SRC.tplLater, artifact: true },
|
|
842
|
+
{ doc: '.recursive/STATE.md — the ledger this phase updates', c: SRC.closeoutScaffold, artifact: false },
|
|
843
|
+
],
|
|
844
|
+
gates: [
|
|
845
|
+
{ t: 'LATE phase.', c: SRC.late, human: false, shape: 'gate' },
|
|
846
|
+
{ t: 'Denies `write*` outside the run tree, and `write*` to the memory planes.', c: SRC.baseline, human: false, shape: 'refuse' },
|
|
847
|
+
{ t: 'AUDITED + reads PRIOR recursive evidence + closeout report-only entry.', c: SRC.audited + ', ' + SRC.priorEvidence, human: false, shape: 'gate' },
|
|
848
|
+
],
|
|
849
|
+
sections: ['TODO', 'State Changes Applied', 'Rationale', 'Resulting State Summary', 'Traceability', 'Coverage Gate', 'Approval Gate'],
|
|
850
|
+
sectionsC: sectionsCite('07-state-update.md'),
|
|
851
|
+
sectionsExtraC: SRC.getSections,
|
|
852
|
+
questions: [],
|
|
853
|
+
refusals: ['phase 7: `write*` outside the run tree denied', 'phase 7: `write*` to the memory planes denied', 'lock-order / locked-write / phase-order'],
|
|
854
|
+
},
|
|
855
|
+
{
|
|
856
|
+
file: '08-memory-impact.md',
|
|
857
|
+
c: SRC.seq,
|
|
858
|
+
phaseN: '8',
|
|
859
|
+
human: false,
|
|
860
|
+
late: true,
|
|
861
|
+
audited: true,
|
|
862
|
+
optional: false,
|
|
863
|
+
traceability: true,
|
|
864
|
+
priorEvidence: true,
|
|
865
|
+
closeout: true,
|
|
866
|
+
memoryGate: true,
|
|
867
|
+
kind: 'Durable memory promotion',
|
|
868
|
+
inputs: [
|
|
869
|
+
{ doc: 'every present artifact in the run except itself', c: SRC.inputMap, artifact: true },
|
|
870
|
+
{ doc: 'the final run diff under `.recursive/memory/`, which phase 8 OWNS', c: SRC.phase8Rule, artifact: false },
|
|
871
|
+
],
|
|
872
|
+
outputs: [
|
|
873
|
+
{ doc: '08-memory-impact.md', c: SRC.closeoutScaffold + ', ' + SRC.tplLater, artifact: true },
|
|
874
|
+
{ doc: 'at least one doc under `.recursive/memory/` carrying `Source-Runs: <this-run-id>`', c: SRC.memoryPlane + ', ' + SRC.phase8Rule, artifact: false },
|
|
875
|
+
],
|
|
876
|
+
gates: [
|
|
877
|
+
{ t: 'HARD PHASE-8 MEMORY GATE, checked at lock: the artifact must DECLARE a path under `.recursive/memory/` in `## Affected Memory Docs`, the path must EXIST, and the doc on disk must carry `Source-Runs` naming THIS run.', c: SRC.trainingEvidence + ', ' + SRC.phase8Rule, human: false, shape: 'refuse' },
|
|
878
|
+
{ t: 'A closed-out phase 08 also fires the training trigger.', c: SRC.toolCloseoutPhase08, human: false, shape: 'gate' },
|
|
879
|
+
{ t: 'The phase-8 write baseline uses a NARROWER predicate than phases 6-7: `.recursive/memory/**` is admitted, `DECISIONS.md` / `STATE.md` are NOT, and an unresolvable target stays denied.', c: SRC.baseline + ', ' + SRC.ownMemoryPlane, human: false, shape: 'refuse' },
|
|
880
|
+
{ t: 'Declares its own `## Run-Local Skill Usage Capture` and `## Skill Memory Promotion Review` sections.', c: sectionsCite('08-memory-impact.md'), human: false, shape: 'gate' },
|
|
881
|
+
],
|
|
882
|
+
sections: ['TODO', 'Diff Basis', 'Changed Paths Review', 'Affected Memory Docs', 'Run-Local Skill Usage Capture', 'Skill Memory Promotion Review', 'Uncovered Paths', 'Router and Parent Refresh', 'Final Status Summary', 'Traceability', 'Coverage Gate', 'Approval Gate'],
|
|
883
|
+
sectionsC: sectionsCite('08-memory-impact.md'),
|
|
884
|
+
sectionsExtraC: SRC.getSections,
|
|
885
|
+
questions: [],
|
|
886
|
+
refusals: ['phase8-memory-missing: lock denied until a doc under `.recursive/memory/` carries this run\'s `Source-Runs`', 'phase 8: `write*` outside the run tree denied (except the memory plane)', 'lock-order / locked-write / phase-order'],
|
|
887
|
+
},
|
|
888
|
+
]
|
|
889
|
+
|
|
890
|
+
/** The three-late-phase set, as its own row so the overview can call it out. */
|
|
891
|
+
const LATE = { files: ['06-decisions-update.md', '07-state-update.md', '08-memory-impact.md'], c: SRC.late }
|
|
892
|
+
|
|
893
|
+
/* -- the DSH seams this plugin attaches to --------------------------------- */
|
|
894
|
+
|
|
895
|
+
const SEAMS = [
|
|
896
|
+
{ seam: '`ctx.tools.register(...)`', when: 'plugin apply, once per composition', act: 'registers 12 of the 13 `recursive_*` tools eagerly; `recursive_audit_team` only when `agentTeams` is present.', c: SRC.toolsReg, ref: false },
|
|
897
|
+
{ seam: '`ctx.inject([\'agentTeams\'])`', when: 'when the service appears (or immediately if already mounted)', act: 'lates the 13th tool, `recursive_audit_team`, into a catalogue that otherwise ships twelve.', c: SRC.auditTeamReg, ref: false },
|
|
898
|
+
{ seam: '`ctx.inject([\'subagents\'])` / `[\'llm\']` / `[\'userQuestions\']`', when: 'when each optional service appears', act: 'late-attaches the continuable-subagent seam, the LLM inventory, and the blocking human-question channel the `run-start` gate asks through.', c: SRC.injects, ref: false },
|
|
899
|
+
{ seam: '`systemPrompt.section({ name: \'recursive:policy\', order: 55 })`', when: 'every prompt render', act: 'renders the workspace policy + current-phase contract from the filesystem fold — read-only, zero session-event emission.', c: SRC.promptSection + ', ' + SRC.policyText, ref: false },
|
|
900
|
+
{ seam: '`on(\'tools/pre-execute\')`', when: 'before EVERY tool call is dispatched', act: 'resolves the root and the active run id per call, runs the `pre_trigger` hook chain, then returns the guard decision verbatim (a refusal becomes `Error: <reason>` in the caller\'s text).', c: SRC.preExecute, ref: true },
|
|
901
|
+
{ seam: '`on(\'fs/observed\')`', when: 'after every successful write, synchronously', act: 'OBSERVE ONLY — records a lock tamper into the guard log. Contractually cannot veto and must not throw.', c: SRC.fsObserved, ref: false },
|
|
902
|
+
{ seam: '`on(\'session/event\')`', when: 'every committed session event', act: 'captures a delegated child\'s settlement at delivery time and files it into the run (or adopts it when nobody filed it).', c: SRC.sessionEvent + ', ' + SRC.settlementSeam, ref: false },
|
|
903
|
+
{ seam: '`on(\'agent/pre-step\')`', when: 'before each agent step', act: 'repairs the scaffold once per root, then injects THIS phase\'s lint rules at most once per phase, only while the phase doc is DRAFT.', c: SRC.preStep + ', ' + SRC.reminderGate, ref: false },
|
|
904
|
+
{ seam: '`ctx.skills.register` / `ctx.skills.registerProvider`', when: 'plugin apply', act: 'publishes each phase\'s rules as a `recursive-phase-<slug>` skill and the packaged `recursive-mode` skill.', c: SRC.skillsPhase, ref: false },
|
|
905
|
+
{ seam: '`ctx.on(\'commands\')` → `/recursive`', when: 'plugin apply', act: 'the `/recursive` slash command (status, memory, and the run verbs).', c: 'src/commands.ts:265', ref: false },
|
|
906
|
+
{ seam: '`webServer.register` (mountOnce-global)', when: 'plugin apply, when a webServer is composed', act: 'the live board route (HTTP state + SSE). No-op headless.', c: SRC.webServer, ref: false },
|
|
907
|
+
]
|
|
908
|
+
|
|
909
|
+
/** The plugin's OWN hook registry: five named points mapped onto the seams above. */
|
|
910
|
+
const HOOKS = [
|
|
911
|
+
{ point: 'pre_turn', maps: '`agent/pre-step`, before `next()`', gating: true, policy: 'fail_closed', registered: [], c: SRC.hookPoints + ', ' + SRC.hookGating },
|
|
912
|
+
{ point: 'pre_generate', maps: 'the policy section callback', gating: true, policy: 'fail_closed', registered: [], c: SRC.hookPoints + ', ' + SRC.hookGating },
|
|
913
|
+
{ point: 'post_generate', maps: 'a post-step listener — OBSERVE ONLY', gating: false, policy: 'fail_open', registered: [], c: SRC.hookPoints + ', ' + SRC.hookObserving },
|
|
914
|
+
{ point: 'pre_trigger', maps: '`tools/pre-execute`', gating: true, policy: 'fail_closed', registered: ['`builtin-tool-guard` (priority 0, onError fail_closed)', '`exit-plan-mode-gate` (priority 5, onError fail_closed)'], c: SRC.hookPoints + ', ' + SRC.guardRegister + ', ' + SRC.planGate },
|
|
915
|
+
{ point: 'post_trigger', maps: '`tools/post-execute`', gating: false, policy: 'fail_open', registered: [], c: SRC.hookPoints + ', ' + SRC.hookObserving },
|
|
916
|
+
]
|
|
917
|
+
|
|
918
|
+
/* -- guards, with the exact reason strings --------------------------------- */
|
|
919
|
+
|
|
920
|
+
const GUARDS = [
|
|
921
|
+
{ k: 'guard rule `lock-order`', what: 'monotonic lock-order: an earlier phase must be locked first', on: '`recursive_lock*`', mode: 'deny; `advisory` downgrades to ask→allow-with-warning', c: SRC.lockOrderRule, modeC: SRC.verdictFor + ', ' + SRC.coerce },
|
|
922
|
+
{ k: 'guard rule `locked-write`', what: 'locked-artifact write denial: the target carries Status: LOCKED', on: 'the write-tool family (8 names)', mode: 'deny; advisory as above', c: SRC.lockedWriteRule + ', ' + SRC.writeTools, modeC: SRC.verdictFor },
|
|
923
|
+
{ k: 'guard rule `phase-order`', what: 'phase order: only one phase may be active at a time - the active phase must be locked before a later phase artifact is written', on: 'the write-tool family', mode: 'deny under `strict`; under `advisory` an ask, which the live path coerces to an allow-WITH-WARNING. ABSTAINS for the active artifact, an earlier phase, another run, and any support file.', c: SRC.phaseOrderRule, modeC: SRC.verdictFor },
|
|
924
|
+
{ k: 'guard rule `memory-read`', what: 'memory read gate: memory must be read before the requirements artifact that defines the run is written', on: 'the write-tool family', mode: 'deny under `strict`; advisory makes it an allow-WITH-WARNING, never a silent allow and never a block. LAST of the three rules that share the write-tool patterns, deliberately: it is the narrowest, and the other two must keep their labels on the cases they already own. Decided from the READ RECEIPT, never from the artifact text — which a caller controls and could therefore forge.', c: SRC.memoryReadRule, modeC: SRC.verdictFor },
|
|
925
|
+
{ k: 'guard rule `tdd-evidence`', what: 'TDD Mode: strict requires RED + GREEN evidence before locking Phase 3', on: '`recursive_lock*` in phase 3 only (a phase baseline rule)', mode: 'deny', c: SRC.baseline + ', ' + SRC.tddVerdict, modeC: '' },
|
|
926
|
+
{ k: 'catch-all', what: 'no deny rule matches this tool', on: '`*`', mode: 'allow — and every `deny` is listed before it, which is what makes "deny wins over allow" a fact about the list', c: SRC.builtInRules, modeC: '' },
|
|
927
|
+
{ k: 'phase 6/7/8 baseline', what: 'phase N is a documentation phase: writes outside the run tree are denied (the implementation is frozen)', on: '`write*`', mode: 'deny — phase 8 admits `.recursive/memory/**` through a narrower predicate; an unplaceable target stays denied', c: SRC.baseline, modeC: SRC.ownMemoryPlane },
|
|
928
|
+
{ k: 'phase 6/7 baseline', what: 'phase N writes no memory plane: .recursive/DECISIONS.md|STATE.md and .recursive/memory/** are written by their own phases', on: '`write*`', mode: 'deny', c: SRC.baseline, modeC: '' },
|
|
929
|
+
{ k: 'phase 1/2 baseline', what: 'phase N writes no memory plane: .recursive/memory/** and .recursive/DECISIONS.md|STATE.md are earned at phases 6-8', on: '`write*`', mode: 'deny', c: SRC.baseline, modeC: '' },
|
|
930
|
+
]
|
|
931
|
+
|
|
932
|
+
/** The lock chain, in the order lockArtifact actually applies it. */
|
|
933
|
+
const LOCK_CHAIN = [
|
|
934
|
+
{ n: '1', t: 'Artifact exists', d: '`Artifact not found: <artifact>`', c: SRC.lockArtifact },
|
|
935
|
+
{ n: '2', t: 'Not already locked', d: '`Artifact already LOCKED: <artifact>`', c: SRC.lockArtifact },
|
|
936
|
+
{ n: '3', t: 'LOCK ORDER — prerequisites', d: '`Prerequisite blockers: <a> (<STATUS>), …` and the run goal is blocked with `code: prerequisite-blockers`', c: SRC.blockers },
|
|
937
|
+
{ n: '4', t: 'QUIESCENCE — no unresolved delegation', d: 'RM4403 `PENDING_WORK`', c: SRC.quiescence + ', ' + SRC.errPending },
|
|
938
|
+
{ n: '5', t: 'PHASE-8 MEMORY GATE', d: '`locking 08-memory-impact.md requires this run to have WRITTEN a doc under .recursive/memory/: …` and the goal is blocked with `code: phase8-memory-missing`', c: SRC.memoryGate + ', ' + SRC.trainingRefusal },
|
|
939
|
+
{ n: '6', t: 'STANDARD — the linter, placed last', d: '`Artifact <a> does not meet the phase standard, so it was not locked: <FAIL list>`; a lint that could not run returns `passed: false`, so "not measured" is not a pass', c: SRC.lintGate },
|
|
940
|
+
{ n: '7', t: 'Write Status/LockedAt/LockHash + receipt', d: 'authoritative-before/after SHA-256 over LF-normalized content with `LockHash:` lines stripped', c: SRC.lockArtifact + ', ' + SRC.lockHash },
|
|
941
|
+
{ n: '8', t: 'Receipt written', d: '`<run>/locks/<stem>.receipt.json` — artifact_hash, locked_at, prerequisite_hashes, previous_receipt_hash, receipt_hash', c: SRC.receipts },
|
|
942
|
+
]
|
|
943
|
+
|
|
944
|
+
/** The backward edges. */
|
|
945
|
+
const LOOPS = [
|
|
946
|
+
{
|
|
947
|
+
n: 'B1',
|
|
948
|
+
title: 'REVISE → repair follow-up to the SAME child',
|
|
949
|
+
what: 'A review round that comes back REVISE delivers a repair instruction to the SAME continuable child, whose working context is intact, and the driver waits for the re-submission. The verdict is read from `reply.md` FAIL-CLOSED: prose, an empty reply or an off-vocabulary answer becomes REVISE with a repair instruction, never a false APPROVE.',
|
|
950
|
+
c: SRC.reviewDriver + ', ' + SRC.reviewFailClosed + ', ' + SRC.delegRevise + ', ' + SRC.toolReviewRevise,
|
|
951
|
+
},
|
|
952
|
+
{
|
|
953
|
+
n: 'B2',
|
|
954
|
+
title: 'The teams task board: re-audit the SAME task',
|
|
955
|
+
what: '`createTask` (pending) → `claim` (in_progress) → audit round → on REVISE `updateTask(edit, repair)` → re-audit the SAME task → on APPROVE `updateTask(complete)` → lock. A REJECT or the round cap RELEASES the task and fails loud: a lock NEVER happens before an APPROVE verdict.',
|
|
956
|
+
c: SRC.teamsLoop + ', ' + SRC.toolAuditTeamApprove,
|
|
957
|
+
},
|
|
958
|
+
{
|
|
959
|
+
n: 'B3',
|
|
960
|
+
title: 'reopen — the run goes BACKWARDS in the sequence',
|
|
961
|
+
what: '`recursive_lock` with `reopen: true` reverts a LOCKED artifact to DRAFT, invalidates every reachable downstream receipt, and re-arms the run goal. It is the ONE genuinely destructive operation, so it carries a deterministic operation id and refuses a recognised repeat.',
|
|
962
|
+
c: SRC.toolReopenParam + ', ' + SRC.staleInvalidate + ', ' + SRC.reopen + ', ' + SRC.staleDownstream,
|
|
963
|
+
},
|
|
964
|
+
{
|
|
965
|
+
n: 'B4',
|
|
966
|
+
title: 'Stale downstream receipts — the graph remembers',
|
|
967
|
+
what: 'A receipt records each prerequisite\'s hash at lock time. If an upstream artifact changes afterwards, the downstream phase is reported stale: `prerequisite \'<a>\' hash changed`, or `prerequisite \'<a>\' content changed since lock at <t>`, or `prerequisite \'<a>\' no longer exists`.',
|
|
968
|
+
c: SRC.staleDownstream + ', ' + SRC.staleAll,
|
|
969
|
+
},
|
|
970
|
+
{
|
|
971
|
+
n: 'B5',
|
|
972
|
+
title: 'HUMAN ROUTE — `gate-block`: fix | reopen | abandon',
|
|
973
|
+
what: 'A refused lock is a decision a person resolves. The refusal carries the three labels: `fix` (return to the phase and satisfy the gate), `reopen` (reopen an earlier locked artifact and repair it there), `abandon` (stop the run). The same payload is built in ONE place so the guard refusal and the tool refusal offer the same choice.',
|
|
974
|
+
c: SRC.askGateBlock + ', ' + SRC.askPayload + ', ' + SRC.blockGoal,
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
n: 'B6',
|
|
978
|
+
title: 'Back-edges are first-class, and the graph is modelled as a graph',
|
|
979
|
+
what: 'An `upstream-gap` addendum legitimately points at a LATER artifact — the gap was found downstream and must be closed upstream. `buildPhaseGraph` carries `addendum` edges in either direction and `backEdges()` reports them; a depth-first walk with a VISITED set is what makes reachability terminate over a cyclic graph.',
|
|
980
|
+
c: SRC.graphBackEdgeWhy + ', ' + SRC.graphAddendum + ', ' + SRC.graphReach + ', ' + SRC.graphBack,
|
|
981
|
+
},
|
|
982
|
+
{
|
|
983
|
+
n: 'B7',
|
|
984
|
+
title: 'Rule 3: an early unlocked node can be blocked by a LATER one',
|
|
985
|
+
what: '`nextLegalPhase` skips a LOCKED node, skips an ABSENT-and-OPTIONAL node, and then — if the first survivor has a prerequisite that is not LOCKED — returns `null`, NOT the next node. With a back-edge this is where "continue" and "blocked" give different answers, and only `null` is correct.',
|
|
986
|
+
c: SRC.graphNext,
|
|
987
|
+
},
|
|
988
|
+
]
|
|
989
|
+
|
|
990
|
+
/** The 13 tools, name + declared purpose, read from the definitions. */
|
|
991
|
+
const TOOLS = [
|
|
992
|
+
{ n: 'recursive_status', p: 'Show the folded status of a recursive-mode run: phase table, current phase, lock validity.', c: citeOf('tool.recursive_status'), reg: 'eager', note: '' },
|
|
993
|
+
{ n: 'recursive_init', p: 'Scaffold a new run directory (or ensure an existing one) with stub artifact headers. THIS DOES NOT START THE RUN: no goal exists until the user approves phase 0 through recursive_ask gate=run-start.', c: citeOf('tool.recursive_init'), reg: 'eager', note: 'Carries `runStartApproval` in its result — read it and ask.' },
|
|
994
|
+
{ n: 'recursive_lock', p: 'Lock a DRAFT artifact: writes Status: LOCKED, LockedAt, LockHash and validates prerequisites (monotonic phase gating). With `reopen: true`, reopen a locked artifact back to DRAFT (invalidates downstream receipts).', c: citeOf('tool.recursive_lock'), reg: 'eager', note: 'The gate the whole chain funnels through; its reopen parameter is cited at ' + SRC.toolReopenParam + '.' },
|
|
995
|
+
{ n: 'recursive_lint', p: 'Lint a run artifact for phase-specific issues (gates, TODO, traceability, diff audit). The result is bounded: when findings are clipped, `elided` says how many and how to see the rest.', c: citeOf('tool.recursive_lint'), reg: 'eager', note: 'The authority on the standard that `recursive_lock` consults.' },
|
|
996
|
+
{ n: 'recursive_closeout', p: 'REPORT what a closeout phase artifact is missing (Phase 4-8): it reads the artifact, lists the required sections and gates that are absent, and records a closeout receipt of its own. It NEVER writes the phase document.', c: citeOf('tool.recursive_closeout'), reg: 'eager', note: 'Phase 08 additionally fires the training trigger when it has been closed out before.' },
|
|
997
|
+
{ n: 'recursive_scratch', p: 'Read, write, or append the run-scoped disposable scratchpad (scratch/scratch.md or scratch/scratch.ts). Scratch is git-ignored and never citable as an Input.', c: citeOf('tool.recursive_scratch'), reg: 'eager', note: '' },
|
|
998
|
+
{ n: 'recursive_worktree', p: 'Create a linked git worktree for a run and/or promote a branch up the dev/stage/main chain.', c: citeOf('tool.recursive_worktree'), reg: 'eager', note: '' },
|
|
999
|
+
{ n: 'recursive_phase', p: 'Return the lint rules + instructions for the current phase (required sections, gates, TDD/QA notes). Call once when entering a new phase; the same rules are also auto-injected once per phase transition.', c: citeOf('tool.recursive_phase'), reg: 'eager', note: '' },
|
|
1000
|
+
{ n: 'recursive_review', p: 'Run or resume an INDEPENDENT review of the current phase artifact with a durable continuable subagent. It reports an outcome (approved | rejected | unavailable) or that the review is still running. Never locks anything itself.', c: citeOf('tool.recursive_review'), reg: 'eager', note: 'This is the REVISE → repair edge.' },
|
|
1001
|
+
{ n: 'recursive_delegate', p: 'Delegate the WORK of a phase to a durable continuable subagent: it produces the content and writes its submission to reply.md, and YOU remain the judge. It never writes the artifact and never locks anything.', c: citeOf('tool.recursive_delegate'), reg: 'eager', note: 'Reports submitted | reviewing | unavailable.' },
|
|
1002
|
+
{ n: 'recursive_ask', p: 'Ask a human gate as a structured decision (tdd-mode, qa-signoff, gate-block), or ASK TO START A RUN (run-start: nothing runs, and no goal exists, until this gate is approved).', c: citeOf('tool.recursive_ask'), reg: 'eager', note: 'The gate is refused while the Phase 0 document is still the unfilled template.' },
|
|
1003
|
+
{ n: 'recursive_preview', p: 'Show what the enforcement contract will do BEFORE it fires: the rendered policy prefix and its digest, the current phase\'s required sections and gates, the next legal transition, and the guard rule a probe tool call would match. Read-only, no model call.', c: citeOf('tool.recursive_preview'), reg: 'eager', note: '' },
|
|
1004
|
+
{ n: 'recursive_audit_team', p: 'Advance one agentTeams Task-board transition for the recursive audit loop (create → claim → edit(REVISE) → complete(APPROVE) → release/interrupt). Complete the task (and lock the phase) ONLY after an APPROVE verdict.', c: citeOf('tool.recursive_audit_team'), reg: 'conditional', note: 'Registered only when the composition mounts ctx.agentTeams — a composition without it offers twelve tools, not thirteen. ' + SRC.auditTeamReg + '.' },
|
|
1005
|
+
]
|
|
1006
|
+
|
|
1007
|
+
/** Every numbered code, with its class group and the gate it belongs to. */
|
|
1008
|
+
const ERRORS = [
|
|
1009
|
+
{ code: 'RM1101', k: 'input', p: 'runId is required', where: 'every run-scoped tool' },
|
|
1010
|
+
{ code: 'RM1102', k: 'input', p: 'artifact is required', where: '`recursive_lock`, `recursive_lint`' },
|
|
1011
|
+
{ code: 'RM1103', k: 'input', p: 'phase and runId are required', where: '`recursive_closeout`' },
|
|
1012
|
+
{ code: 'RM1104', k: 'input', p: 'action, runId and target are all required', where: '`recursive_scratch`' },
|
|
1013
|
+
{ code: 'RM1105', k: 'input', p: 'runId is required for create', where: '`recursive_worktree`' },
|
|
1014
|
+
{ code: 'RM1106', k: 'input', p: 'fromBranch and toBranch are required for promote', where: '`recursive_worktree`' },
|
|
1015
|
+
{ code: 'RM1107', k: 'input', p: 'runId is not a single directory name', where: 'init / lock / closeout / scratch / worktree / phase — refused BEFORE any directory is made' },
|
|
1016
|
+
{ code: 'RM1141', k: 'input', p: 'the requested human gate is not one of tdd-mode, qa-signoff or gate-block', where: '`recursive_ask`' },
|
|
1017
|
+
{ code: 'RM1142', k: 'input', p: 'the answer is not one of the labels the gate offered', where: '`recursive_ask`' },
|
|
1018
|
+
{ code: 'RM1143', k: 'input', p: 'this gate has no default artifact, so one must be named', where: '`recursive_ask` (gate-block)' },
|
|
1019
|
+
{ code: 'RM1144', k: 'input', p: 'probeArguments is not a JSON object', where: '`recursive_preview`' },
|
|
1020
|
+
{ code: 'RM1150', k: 'input', p: 'relay applies only to the run-start gate', where: '`recursive_ask`' },
|
|
1021
|
+
{ code: 'RM2201', k: 'value', p: 'target must be md or ts', where: '`recursive_scratch`' },
|
|
1022
|
+
{ code: 'RM2202', k: 'value', p: 'action must be create | promote | status', where: '`recursive_worktree`' },
|
|
1023
|
+
{ code: 'RM3301', k: 'workspace', p: 'this session is not attached to a registered workspace', where: 'the workspace-root resolution every tool shares' },
|
|
1024
|
+
{ code: 'RM4401', k: 'state', p: 'no recursive run exists in this workspace', where: '`recursive_status`, `recursive_review`, `recursive_delegate`' },
|
|
1025
|
+
{ code: 'RM4402', k: 'state', p: 'no current recursive phase could be determined', where: '`recursive_phase`, `recursive_review`, `recursive_delegate`' },
|
|
1026
|
+
{ code: 'RM4403', k: 'state', p: 'the run has unresolved delegated work, so this phase cannot lock yet', where: 'the lock chain, step 4 (QUIESCENCE)' },
|
|
1027
|
+
{ code: 'RM4404', k: 'state', p: 'the Phase 0 requirements document is still the unfilled template, so there is no run spec for a person to approve', where: 'the `run-start` gate (`runStartSpecGuard`)' },
|
|
1028
|
+
{ code: 'RM5501', k: 'runtime', p: 'the recursive runtime refused the operation', where: 'the wrapper `codeRuntimeRefusal` puts around a bare thrown message' },
|
|
1029
|
+
{ code: 'RM5502', k: 'runtime', p: 'the run-start gate needs an answer, and this composition mounts no user-questions channel', where: '`recursive_ask gate=run-start`' },
|
|
1030
|
+
{ code: 'RM5503', k: 'runtime', p: 'the run-start question reached no decision: the mounted channel failed before a person answered it', where: '`recursive_ask gate=run-start` — relayable with `relay=true`, except cancellation/abort/timeout' },
|
|
1031
|
+
{ code: 'RM5504', k: 'runtime', p: 'a person was asked to start this run and their answer was not one of the labels offered', where: '`recursive_ask gate=run-start` — NOT relayable' },
|
|
1032
|
+
{ code: 'RM6601', k: 'capability', p: 'the agent-teams service is not available in this composition', where: '`recursive_audit_team`' },
|
|
1033
|
+
]
|
|
1034
|
+
|
|
1035
|
+
/** Pre-step lint rules message, verbatim in shape. */
|
|
1036
|
+
const PRESTEP = {
|
|
1037
|
+
lines: [
|
|
1038
|
+
'<system-reminder>',
|
|
1039
|
+
'Recursive-mode phase lint rules for THIS phase (<artifact>):',
|
|
1040
|
+
'Required sections: <this phase\'s list, joined by " | ">',
|
|
1041
|
+
'Gates: Coverage: FAIL until all checkboxes pass; Approval: FAIL until user sign-off; lock only via recursive_lock (monotonic).',
|
|
1042
|
+
'Audited phases: end with Audit: PASS before setting Coverage/Approval PASS; record Audit Context and Audit Verdict.',
|
|
1043
|
+
'TDD (phase 3): declare TDD Mode: strict|pragmatic; strict requires RED + GREEN evidence paths.',
|
|
1044
|
+
'QA (phase 5): declare QA Execution Mode: human|agent-operated|hybrid; human/hybrid need user sign-off.',
|
|
1045
|
+
'Memory write (phase 8, HARD): <the PHASE8_MEMORY_WRITE_RULE.summary sentence>',
|
|
1046
|
+
'</system-reminder>',
|
|
1047
|
+
],
|
|
1048
|
+
c: SRC.lintMessage,
|
|
1049
|
+
gate: 'Injected at most ONCE per (root, runId, phase), and only while the phase doc\'s status is DRAFT.',
|
|
1050
|
+
gateC: SRC.preStep + ', ' + SRC.reminderGate,
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/* ========================================================================== */
|
|
1054
|
+
/* RENDER */
|
|
1055
|
+
/* ========================================================================== */
|
|
1056
|
+
|
|
1057
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
1058
|
+
const cite = (c) => (c ? `<span class="cite">${esc(c)}</span>` : '')
|
|
1059
|
+
|
|
1060
|
+
function tagList(p) {
|
|
1061
|
+
const t = []
|
|
1062
|
+
if (p.human) t.push('<span class="tag tag-human">human gate</span>')
|
|
1063
|
+
if (p.late) t.push(`<span class="tag tag-late">late phase</span>`)
|
|
1064
|
+
if (p.audited) t.push('<span class="tag tag-audited">audited</span>')
|
|
1065
|
+
if (p.optional) t.push('<span class="tag tag-optional">optional</span>')
|
|
1066
|
+
if (p.tdd) t.push('<span class="tag tag-tdd">TDD gate</span>')
|
|
1067
|
+
if (p.memoryGate) t.push('<span class="tag tag-refuse">lock gate</span>')
|
|
1068
|
+
if (p.closeout) t.push('<span class="tag tag-closeout">closeout</span>')
|
|
1069
|
+
if (p.artifactInputsOnly) t.push('<span class="tag">artifact inputs only</span>')
|
|
1070
|
+
return t.join(' ')
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/** One phase row on the overview: inputs → artifact → gates, with a loop rail on the left. */
|
|
1074
|
+
function phaseRow(p, i) {
|
|
1075
|
+
const loopIds = []
|
|
1076
|
+
if (p.tdd) loopIds.push('B5')
|
|
1077
|
+
if (p.sections.length) loopIds.push('B4')
|
|
1078
|
+
if (p.audited || p.closeout) loopIds.push('B1')
|
|
1079
|
+
if (p.memoryGate) loopIds.push('B2')
|
|
1080
|
+
|
|
1081
|
+
return `
|
|
1082
|
+
<article class="step${p.human ? ' step-human' : ''}" id="ov-${esc(p.file.replace(/[^a-z0-9]+/gi, '-'))}">
|
|
1083
|
+
<div class="rail" aria-hidden="true"><span class="rail-dot"></span></div>
|
|
1084
|
+
<div class="step-body">
|
|
1085
|
+
<header class="step-head">
|
|
1086
|
+
<div class="step-id">
|
|
1087
|
+
<span class="step-num">${esc(p.phaseN)}</span>
|
|
1088
|
+
<span class="step-artifact">${esc(p.file)}</span>
|
|
1089
|
+
</div>
|
|
1090
|
+
<div class="step-tags">${tagList(p)}</div>
|
|
1091
|
+
<div class="step-kind">${esc(p.kind)}${cite(p.c)}</div>
|
|
1092
|
+
</header>
|
|
1093
|
+
<div class="io">
|
|
1094
|
+
<div class="io-col">
|
|
1095
|
+
<h4>reads <span class="io-sub">input docs</span></h4>
|
|
1096
|
+
<ul>${p.inputs.map((x) => `<li><span class="doc${x.artifact ? ' doc-artifact' : ''}">${esc(x.doc)}</span>${cite(x.c)}</li>`).join('')}</ul>
|
|
1097
|
+
</div>
|
|
1098
|
+
<div class="io-col">
|
|
1099
|
+
<h4>writes <span class="io-sub">output doc</span></h4>
|
|
1100
|
+
<ul>${p.outputs.map((x) => `<li><span class="doc${x.artifact ? ' doc-artifact' : ' doc-notartifact'}">${esc(x.doc)}</span>${cite(x.c)}</li>`).join('')}</ul>
|
|
1101
|
+
</div>
|
|
1102
|
+
</div>
|
|
1103
|
+
<div class="gates">
|
|
1104
|
+
<h4>gates & hooks at this step</h4>
|
|
1105
|
+
<ul class="gate-list">
|
|
1106
|
+
${p.gates.map((g) => `<li class="g g-${esc(g.shape)}${g.human ? ' g-human' : ''}"><span class="g-shape">${g.human ? 'DECISION' : g.shape === 'refuse' ? 'REFUSAL' : 'AUTO'}</span><span class="g-text">${g.t}${cite(g.c)}</span></li>`).join('')}
|
|
1107
|
+
</ul>
|
|
1108
|
+
</div>
|
|
1109
|
+
${loopIds.length ? `<div class="loop-note">can be sent back: ${[...new Set(loopIds)].map((id) => `<a href="#loop-${id}">${id}</a>`).join(' · ')}</div>` : ''}
|
|
1110
|
+
</div>
|
|
1111
|
+
</article>`
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const phaseDetail = (p, i) => {
|
|
1115
|
+
const id = 'phase-' + p.file.replace(/[^a-z0-9]+/gi, '-')
|
|
1116
|
+
const extraNote = p.sectionsExtraC
|
|
1117
|
+
? `<p class="note">The list above is the canonical parity list. In a STRICT workflow profile an audited phase gets the audit headings APPENDED${p.priorEvidence ? ', plus `Prior Recursive Evidence Reviewed`' : ''} — rendered by <code>getArtifactRequiredSections</code>. ${cite(p.sectionsExtraC)}</p>`
|
|
1118
|
+
: ''
|
|
1119
|
+
return `
|
|
1120
|
+
<section class="panel" id="panel-${id}" role="tabpanel" aria-labelledby="tab-${id}" tabindex="0" hidden>
|
|
1121
|
+
<div class="panel-head">
|
|
1122
|
+
<h2><span class="h-num">phase ${esc(p.phaseN)}</span> ${esc(p.file)}</h2>
|
|
1123
|
+
<p class="lede">${esc(p.kind)} — artifact ${i + 1} of ${PHASES.length} in <code>PHASE_SEQUENCE</code>.${cite(p.c)}</p>
|
|
1124
|
+
<div class="step-tags">${tagList(p)}</div>
|
|
1125
|
+
</div>
|
|
1126
|
+
|
|
1127
|
+
<div class="detail-grid">
|
|
1128
|
+
<section class="card">
|
|
1129
|
+
<h3>Required sections <span class="h-count">${p.sections.length}</span></h3>
|
|
1130
|
+
<ol class="sections">${p.sections.map((s) => `<li><code>## ${esc(s)}</code></li>`).join('')}</ol>
|
|
1131
|
+
${extraNote}
|
|
1132
|
+
<p class="cite-line">${cite(p.sectionsC)}</p>
|
|
1133
|
+
</section>
|
|
1134
|
+
|
|
1135
|
+
<section class="card">
|
|
1136
|
+
<h3>Gates</h3>
|
|
1137
|
+
<ul class="gate-list">
|
|
1138
|
+
${p.gates.filter((g) => g.shape !== 'refuse').map((g) => `<li class="g g-${esc(g.shape)}${g.human ? ' g-human' : ''}"><span class="g-shape">${g.human ? 'DECISION' : 'AUTO'}</span><span class="g-text">${g.t}${cite(g.c)}</span></li>`).join('') || '<li class="g"><span class="g-text">No phase-specific gate recorded beyond the universal ones.</span></li>'}
|
|
1139
|
+
</ul>
|
|
1140
|
+
</section>
|
|
1141
|
+
|
|
1142
|
+
<section class="card">
|
|
1143
|
+
<h3>Inputs — what this phase reads</h3>
|
|
1144
|
+
<ul class="io-list">${p.inputs.map((x) => `<li><span class="doc${x.artifact ? ' doc-artifact' : ''}">${esc(x.doc)}</span>${cite(x.c)}</li>`).join('')}</ul>
|
|
1145
|
+
<p class="note">The expected-input list is the linter's own: <code>getPhaseExpectedInputArtifactNames</code> names the input artifacts it requires this phase's <code>Inputs:</code> header and <code>## Effective Inputs Re-read</code> section to cite — and only those that exist on disk are demanded. ${cite(SRC.inputMap)}</p>
|
|
1146
|
+
<p class="cite-line">${cite(SRC.effectiveInputs)} <span class="unv">Note: the linter also demands that every addendum attached to an expected input be cited, which is checked at <code>ts-lint.ts:691-705</code>.</span></p>
|
|
1147
|
+
</section>
|
|
1148
|
+
|
|
1149
|
+
<section class="card">
|
|
1150
|
+
<h3>Output artifact</h3>
|
|
1151
|
+
<ul class="io-list">${p.outputs.map((x) => `<li><span class="doc${x.artifact ? ' doc-artifact' : ' doc-notartifact'}">${esc(x.doc)}</span>${cite(x.c)}</li>`).join('')}</ul>
|
|
1152
|
+
<p class="note">Scaffolded by <code>recursive_init</code> with a header block (<code>Run:</code>, <code>Phase:</code>, <code>Status: DRAFT</code>, <code>Workflow version:</code>, <code>Inputs:</code>, <code>Outputs:</code>, <code>Scope note:</code>) and every required section as an empty heading, with <code>Coverage: FAIL</code> and <code>Approval: FAIL</code> at the end. ${cite(SRC.tplLater)}</p>
|
|
1153
|
+
</section>
|
|
1154
|
+
|
|
1155
|
+
<section class="card card-wide">
|
|
1156
|
+
<h3>What can REFUSE this phase</h3>
|
|
1157
|
+
<ul class="refuse-list">${p.refusals.map((r) => `<li><span class="g-shape g-shape-refuse">REFUSAL</span><span>${esc(r)}</span></li>`).join('')}</ul>
|
|
1158
|
+
<p class="note">The guard refuses BEFORE dispatch at the <code>tools/pre-execute</code> seam; the tool refuses again inside <code>lockArtifact</code>. Both describe the same violation on purpose — one refusal, two layers that agree. ${cite(SRC.preExecute + ', ' + SRC.blockers)}</p>
|
|
1159
|
+
</section>
|
|
1160
|
+
|
|
1161
|
+
<section class="card card-wide">
|
|
1162
|
+
<h3>Human questions attached to this phase</h3>
|
|
1163
|
+
${p.questions.length === 0
|
|
1164
|
+
? `<p class="note">None. <code>recursive_ask</code>'s three workflow gates belong to phases 3 (<code>tdd-mode</code>) and 5 (<code>qa-signoff</code>), plus <code>gate-block</code>, which any refused transition can raise.${cite(SRC.askGates)}</p>`
|
|
1165
|
+
: p.questions.map((q) => `
|
|
1166
|
+
<div class="ask">
|
|
1167
|
+
<div class="ask-head"><code>gate: ${esc(q.gate)}</code><span class="ask-header">${esc(q.header)}</span></div>
|
|
1168
|
+
<p class="ask-q">${esc(q.q)}</p>
|
|
1169
|
+
<ul class="ask-opts">${q.opts.map((o) => `<li>${esc(o)}</li>`).join('')}</ul>
|
|
1170
|
+
${cite(q.c)}
|
|
1171
|
+
</div>`).join('')}
|
|
1172
|
+
${p.file === '00-requirements.md' ? `<div class="ask"><div class="ask-head"><code>gate: run-start</code><span class="ask-header">Start run</span></div><p class="ask-q">Approve phase 0 and start this run? Approving creates an armed goal the harness will keep driving.</p><ul class="ask-opts"><li>Start run — record the approval and arm the run goal</li><li>Hold — leave the spec inert: no run goal, no autonomous rounds</li></ul>${cite(SRC.startGate)}</div>` : ''}
|
|
1173
|
+
</section>
|
|
1174
|
+
|
|
1175
|
+
<section class="card card-wide">
|
|
1176
|
+
<h3>Start here at this phase — the pre-step injection</h3>
|
|
1177
|
+
<pre class="pre">${PRESTEP.lines.map(esc).join('\n')}</pre>
|
|
1178
|
+
<p class="note">${esc(PRESTEP.gate)} ${cite(PRESTEP.c)} ${cite(PRESTEP.gateC)}</p>
|
|
1179
|
+
</section>
|
|
1180
|
+
</div>
|
|
1181
|
+
</section>`
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function render() {
|
|
1185
|
+
// The diagram is CHARTED before the page is built, because the chart tells the
|
|
1186
|
+
// stylesheet how wide it is (`--dg-w`): the SVG then keeps its own coordinate
|
|
1187
|
+
// system at every viewport and the wrapper scrolls instead of squeezing it.
|
|
1188
|
+
const dg = diagram()
|
|
1189
|
+
|
|
1190
|
+
const tabs = [
|
|
1191
|
+
{ id: 'overview', label: 'Overview', sub: 'whole flow' },
|
|
1192
|
+
{ id: 'start', label: 'Phase 0', sub: 'the human gate' },
|
|
1193
|
+
...PHASES.map((p) => ({ id: 'phase-' + p.file.replace(/[^a-z0-9]+/gi, '-'), label: p.file.replace(/\.md$/, ''), sub: 'phase ' + p.phaseN })),
|
|
1194
|
+
{ id: 'hooks', label: 'Hooks & seams', sub: 'where it attaches' },
|
|
1195
|
+
{ id: 'guards', label: 'Guards & refusals', sub: 'what says no' },
|
|
1196
|
+
{ id: 'loops', label: 'Backward loops', sub: 'REVISE / repair' },
|
|
1197
|
+
{ id: 'closeout', label: 'Closeout & receipts', sub: 'the chain' },
|
|
1198
|
+
{ id: 'tools', label: 'Tools', sub: '13 definitions' },
|
|
1199
|
+
{ id: 'codes', label: 'Error codes', sub: 'RM####' },
|
|
1200
|
+
{ id: 'verify', label: 'Verification', sub: 'what is checked' },
|
|
1201
|
+
]
|
|
1202
|
+
|
|
1203
|
+
return `<!DOCTYPE html>
|
|
1204
|
+
<html lang="en" data-theme="dark">
|
|
1205
|
+
<head>
|
|
1206
|
+
<meta charset="utf-8">
|
|
1207
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1208
|
+
<title>recursive-mode — the workflow, extracted from the source</title>
|
|
1209
|
+
<meta name="description" content="A tabbed, citation-carrying map of the recursive-mode workflow: phase sequence, per-phase gates, DSH hooks, lock chain and the backward loops.">
|
|
1210
|
+
<style>
|
|
1211
|
+
/* The diagram's own width, decided by the generator's diagram() and consumed by
|
|
1212
|
+
.diagram svg below: the chart keeps its coordinate system and the wrapper scrolls. */
|
|
1213
|
+
:root{ --dg-w:${dg.width}px; --dg-h:${dg.height}px; }
|
|
1214
|
+
/* ==========================================================================
|
|
1215
|
+
TOKENS — primitive → semantic. Dark canvas, one accent per MEANING, and no
|
|
1216
|
+
meaning carried by colour alone: every gate also carries a word and a shape.
|
|
1217
|
+
========================================================================== */
|
|
1218
|
+
/* Tokens — rendered from the C object above, so the contrast audit reads exactly
|
|
1219
|
+
what ships. Primitive → semantic; one accent per MEANING, and no meaning is
|
|
1220
|
+
carried by colour alone (every gate also carries a word and a shape). */
|
|
1221
|
+
:root{
|
|
1222
|
+
${Object.entries(C).map(([k, v]) => ` --${k}:${v};`).join('\n')}
|
|
1223
|
+
|
|
1224
|
+
--bg:var(--n-950); --surface:var(--n-900); --surface-2:var(--n-850);
|
|
1225
|
+
--surface-3:var(--n-800); --line:var(--n-700); --line-strong:var(--n-600);
|
|
1226
|
+
--fg:var(--n-050); --fg-2:var(--n-200); --fg-muted:var(--n-300);
|
|
1227
|
+
--fg-faint:var(--n-400);
|
|
1228
|
+
--accent:var(--acc); --accent-line:var(--acc-dim);
|
|
1229
|
+
|
|
1230
|
+
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono","Courier New",monospace;
|
|
1231
|
+
|
|
1232
|
+
/* Fluid type: one clamp per step so nothing needs a breakpoint to stay legible. */
|
|
1233
|
+
--fs-3xs:0.6875rem; --fs-2xs:0.75rem; --fs-xs:0.8125rem; --fs-sm:0.875rem;
|
|
1234
|
+
--fs-base:clamp(0.9rem, 0.88rem + 0.12vw, 0.9375rem);
|
|
1235
|
+
--fs-lg:clamp(1rem, 0.97rem + 0.16vw, 1.0625rem);
|
|
1236
|
+
--fs-xl:clamp(1.125rem, 1.06rem + 0.3vw, 1.25rem);
|
|
1237
|
+
--fs-2xl:clamp(1.25rem, 1.1rem + 0.7vw, 1.5rem);
|
|
1238
|
+
--fs-3xl:clamp(1.4rem, 1.2rem + 1vw, 1.875rem);
|
|
1239
|
+
|
|
1240
|
+
--sp-1:0.25rem; --sp-2:0.5rem; --sp-3:0.75rem; --sp-4:1rem; --sp-5:1.25rem;
|
|
1241
|
+
--sp-6:1.5rem; --sp-8:2rem; --sp-10:2.5rem; --sp-12:3rem;
|
|
1242
|
+
|
|
1243
|
+
/* -------- radii, concentric by nesting level -----------------------------
|
|
1244
|
+
outer = inner + padding. Three steps, because three levels exist:
|
|
1245
|
+
L1 .panel / .diagram r 8 , padding 24
|
|
1246
|
+
L2 .card / .subcard r 4 , padding 16 (8 > 4: the nested corner
|
|
1247
|
+
is visibly tighter, never equal)
|
|
1248
|
+
L3 code / .cite / .tag r 2 , padding 1-8
|
|
1249
|
+
An EQUAL radius on parent and child is the pattern this avoids: it is what
|
|
1250
|
+
makes a nested surface look pinched. Non-nested controls (.chip, .tab,
|
|
1251
|
+
.legend, .tablewrap, .ask, .loop, .pre) may share L2 — they do not sit
|
|
1252
|
+
inside a rounded, padded parent, so concentricity does not apply to them. */
|
|
1253
|
+
--r-1:2px; --r-2:4px; --r-3:8px; --r-pill:999px;
|
|
1254
|
+
}
|
|
1255
|
+
@media (prefers-color-scheme: light){ :root{ color-scheme: dark; } }
|
|
1256
|
+
|
|
1257
|
+
*,*::before,*::after{ box-sizing:border-box; }
|
|
1258
|
+
html{ -webkit-text-size-adjust:100%; scroll-padding-top:9rem; }
|
|
1259
|
+
body{
|
|
1260
|
+
margin:0; background:var(--bg); color:var(--fg);
|
|
1261
|
+
font-family:var(--mono); font-size:var(--fs-base); line-height:1.55;
|
|
1262
|
+
font-variant-ligatures:none;
|
|
1263
|
+
-webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale;
|
|
1264
|
+
background-image:
|
|
1265
|
+
linear-gradient(var(--n-900) 1px, transparent 1px),
|
|
1266
|
+
linear-gradient(90deg, var(--n-900) 1px, transparent 1px);
|
|
1267
|
+
background-size:64px 64px, 64px 64px;
|
|
1268
|
+
background-position:-1px -1px;
|
|
1269
|
+
}
|
|
1270
|
+
a{ color:var(--accent); text-underline-offset:2px; }
|
|
1271
|
+
a:hover{ color:var(--fg); }
|
|
1272
|
+
code{ font-family:var(--mono); font-size:0.96em; color:var(--n-100); background:var(--n-850); border:1px solid var(--line); border-radius:var(--r-1); padding:0 0.28em; }
|
|
1273
|
+
h1,h2,h3,h4{ margin:0; font-weight:600; line-height:1.25; letter-spacing:-0.01em; text-wrap:balance; }
|
|
1274
|
+
p{ margin:0; text-wrap:pretty; }
|
|
1275
|
+
li{ text-wrap:pretty; }
|
|
1276
|
+
ul,ol{ margin:0; padding:0; list-style:none; }
|
|
1277
|
+
/* Digits that must not shift the layout when they change. */
|
|
1278
|
+
.tnum, .chip b, .seq, .h-count, .step-num, .lede{ font-variant-numeric:tabular-nums; }
|
|
1279
|
+
:focus-visible{ outline:2px solid var(--warn); outline-offset:2px; border-radius:var(--r-1); }
|
|
1280
|
+
|
|
1281
|
+
/* ---- skip link ---------------------------------------------------------- */
|
|
1282
|
+
.skip{ position:absolute; left:-9999px; top:0; z-index:50; background:var(--warn); color:var(--warn-ink); padding:var(--sp-2) var(--sp-4); font-weight:600; border-radius:0 0 var(--r-1) 0; }
|
|
1283
|
+
.skip:focus{ left:0; }
|
|
1284
|
+
|
|
1285
|
+
/* ---- shell ------------------------------------------------------------- */
|
|
1286
|
+
.wrap{ max-width:1340px; margin:0 auto; padding:0 var(--sp-5) var(--sp-12); }
|
|
1287
|
+
header.masthead{ border-bottom:1px solid var(--line-strong); padding:var(--sp-8) 0 var(--sp-5); margin-bottom:var(--sp-5); }
|
|
1288
|
+
.masthead-top{ display:flex; flex-wrap:wrap; gap:var(--sp-3) var(--sp-5); align-items:baseline; justify-content:space-between; }
|
|
1289
|
+
h1{ font-size:var(--fs-2xl); letter-spacing:-0.02em; }
|
|
1290
|
+
h1 .tick{ color:var(--accent); }
|
|
1291
|
+
.mast-sub{ color:var(--fg-muted); font-size:var(--fs-xs); margin-top:var(--sp-2); max-width:88ch; }
|
|
1292
|
+
.mast-meta{ display:flex; flex-wrap:wrap; gap:var(--sp-2); margin-top:var(--sp-4); }
|
|
1293
|
+
.chip{ font-size:var(--fs-3xs); border:1px solid var(--line-strong); border-radius:var(--r-2); padding:0 var(--sp-2); color:var(--fg-muted); background:var(--surface); white-space:nowrap; }
|
|
1294
|
+
.chip b{ color:var(--fg); font-weight:600; }
|
|
1295
|
+
.chip-acc{ border-color:var(--accent-line); color:var(--accent); }
|
|
1296
|
+
.chip-warn{ border-color:var(--warn-dim); color:var(--warn); }
|
|
1297
|
+
.chip-ok{ border-color:var(--ok-dim); color:var(--ok); }
|
|
1298
|
+
|
|
1299
|
+
/* ---- tabs (ARIA tablist; roving tabindex; arrow keys) -------------------
|
|
1300
|
+
The strip is styleable, not just clickable: a 44px minimum height (touch), a
|
|
1301
|
+
visible focus ring, and at narrow widths it SCROLLS in one row instead of
|
|
1302
|
+
wrapping into a tall block that pushes the panel below the fold. scroll-padding
|
|
1303
|
+
keeps a keyboard-focused tab fully in view, and overscroll-behavior stops the
|
|
1304
|
+
scroll from chaining to the page. */
|
|
1305
|
+
.tabstrip{ position:sticky; top:0; z-index:20; background:linear-gradient(var(--bg) 80%, transparent); padding:var(--sp-3) 0 var(--sp-4); border-bottom:1px solid var(--line); margin-bottom:var(--sp-6); }
|
|
1306
|
+
.tabstrip-label{ font-size:var(--fs-3xs); color:var(--fg-faint); text-transform:uppercase; letter-spacing:0.14em; margin-bottom:var(--sp-2); }
|
|
1307
|
+
[role="tablist"]{
|
|
1308
|
+
display:flex; gap:var(--sp-1);
|
|
1309
|
+
overflow-x:auto; overscroll-behavior-inline:contain;
|
|
1310
|
+
scroll-padding-inline:var(--sp-2); scroll-snap-type:x proximity;
|
|
1311
|
+
padding-bottom:var(--sp-1);
|
|
1312
|
+
scrollbar-width:thin; scrollbar-color:var(--n-600) transparent;
|
|
1313
|
+
}
|
|
1314
|
+
[role="tablist"]::-webkit-scrollbar{ height:6px; }
|
|
1315
|
+
[role="tablist"]::-webkit-scrollbar-thumb{ background:var(--n-600); border-radius:var(--r-pill); }
|
|
1316
|
+
[role="tab"]{
|
|
1317
|
+
appearance:none; background:var(--surface); color:var(--fg-muted);
|
|
1318
|
+
border:1px solid var(--line-strong); border-radius:var(--r-2);
|
|
1319
|
+
font-family:inherit; font-size:var(--fs-2xs); line-height:1.2;
|
|
1320
|
+
padding:var(--sp-2) var(--sp-3); cursor:pointer; text-align:left;
|
|
1321
|
+
display:flex; flex-direction:column; gap:2px; justify-content:center;
|
|
1322
|
+
min-height:44px; flex:0 0 auto; scroll-snap-align:start;
|
|
1323
|
+
transition-property:background-color, border-color, color, box-shadow;
|
|
1324
|
+
transition-duration:120ms; transition-timing-function:ease-out;
|
|
1325
|
+
}
|
|
1326
|
+
[role="tab"]:hover{ background:var(--surface-3); color:var(--fg); border-color:var(--n-500); }
|
|
1327
|
+
[role="tab"]:active{ background:var(--n-700); }
|
|
1328
|
+
[role="tab"][aria-selected="true"]{ background:var(--n-800); color:var(--fg); border-color:var(--accent); box-shadow:inset 0 -2px 0 var(--accent); }
|
|
1329
|
+
[role="tab"][aria-selected="true"] .tsub{ color:var(--accent); }
|
|
1330
|
+
.tlabel{ white-space:nowrap; }
|
|
1331
|
+
.tsub{ font-size:var(--fs-3xs); color:var(--fg-faint); white-space:nowrap; }
|
|
1332
|
+
|
|
1333
|
+
/* ---- panels ------------------------------------------------------------- */
|
|
1334
|
+
.panel{ background:var(--surface); border:1px solid var(--line); border-radius:var(--r-3); padding:var(--sp-6); }
|
|
1335
|
+
.panel[hidden]{ display:none; }
|
|
1336
|
+
.js .panel{ display:none; }
|
|
1337
|
+
.js .panel.is-active{ display:block; }
|
|
1338
|
+
.panel-head{ border-bottom:1px solid var(--line); padding-bottom:var(--sp-4); margin-bottom:var(--sp-5); }
|
|
1339
|
+
.panel-head h2{ font-size:var(--fs-xl); display:flex; flex-wrap:wrap; gap:var(--sp-3); align-items:baseline; }
|
|
1340
|
+
.h-num{ font-size:var(--fs-2xs); color:var(--accent); border:1px solid var(--accent-line); border-radius:var(--r-1); padding:0 var(--sp-2); }
|
|
1341
|
+
.lede{ color:var(--fg-muted); font-size:var(--fs-xs); margin-top:var(--sp-2); max-width:92ch; }
|
|
1342
|
+
h3{ font-size:var(--fs-sm); color:var(--fg); margin-bottom:var(--sp-3); display:flex; align-items:baseline; gap:var(--sp-2); }
|
|
1343
|
+
.h-count{ font-size:var(--fs-3xs); color:var(--fg-faint); font-weight:400; }
|
|
1344
|
+
|
|
1345
|
+
/* ---- citations ---------------------------------------------------------- */
|
|
1346
|
+
/* A citation is a file:line — long, unbreakable-looking, and it used to carry
|
|
1347
|
+
white-space:nowrap. THAT was a layout defect, not a style: a nowrap inline box
|
|
1348
|
+
whose text is wider than its column cannot shrink, so it pushed its own card wider
|
|
1349
|
+
than the grid track, the card overflowed, and the ink of the overflowing citation
|
|
1350
|
+
landed on top of the next card's heading and code spans (measured: 12 text-on-text
|
|
1351
|
+
collisions at 1440px across the phase and verification views). It breaks normally
|
|
1352
|
+
now, and overflow-wrap:anywhere is the last resort that keeps a 40-character path
|
|
1353
|
+
inside its column even when there is no space to break on. */
|
|
1354
|
+
.cite{ display:inline-block; font-size:var(--fs-3xs); color:var(--fg-faint); background:var(--n-850); border:1px solid var(--line); border-radius:var(--r-1); padding:0 var(--sp-1); margin-left:var(--sp-2); max-width:100%; overflow-wrap:anywhere; vertical-align:baseline; }
|
|
1355
|
+
.cite-line{ margin-top:var(--sp-3); }
|
|
1356
|
+
.note{ font-size:var(--fs-2xs); color:var(--fg-muted); margin-top:var(--sp-3); }
|
|
1357
|
+
.unv{ color:var(--warn); }
|
|
1358
|
+
.note code, .lede code{ font-size:0.94em; }
|
|
1359
|
+
|
|
1360
|
+
/* ---- tags --------------------------------------------------------------- */
|
|
1361
|
+
.tag{ font-size:var(--fs-3xs); border:1px solid var(--line-strong); border-radius:var(--r-pill); padding:0 var(--sp-2); color:var(--fg-muted); white-space:nowrap; }.tag-human{ border-color:var(--warn); color:var(--warn); }
|
|
1362
|
+
.tag-late{ border-color:var(--violet); color:var(--violet); }
|
|
1363
|
+
.tag-audited{ border-color:var(--accent-line); color:var(--accent); }
|
|
1364
|
+
.tag-refuse{ border-color:var(--danger-dim); color:var(--danger); }
|
|
1365
|
+
.tag-tdd{ border-color:var(--ok-dim); color:var(--ok); }
|
|
1366
|
+
.tag-optional{ border-style:dashed; }
|
|
1367
|
+
.tag-closeout{ border-color:var(--n-400); color:var(--fg-2); }
|
|
1368
|
+
.step-tags{ display:flex; flex-wrap:wrap; gap:var(--sp-1); }
|
|
1369
|
+
.step-tags .tag{ margin-top:var(--sp-1); }
|
|
1370
|
+
|
|
1371
|
+
/* ---- overview: the sequence -------------------------------------------- */
|
|
1372
|
+
.flow{ display:flex; flex-direction:column; }
|
|
1373
|
+
.legend{ display:flex; flex-wrap:wrap; gap:var(--sp-4); border:1px solid var(--line); background:var(--surface-2); border-radius:var(--r-2); padding:var(--sp-4); margin-bottom:var(--sp-6); font-size:var(--fs-2xs); color:var(--fg-muted); }
|
|
1374
|
+
.legend div{ display:flex; align-items:center; gap:var(--sp-2); }
|
|
1375
|
+
.swatch{ width:14px; height:14px; border:1px solid var(--line-strong); display:inline-block; flex:none; }
|
|
1376
|
+
.sw-human{ background:var(--warn); border-color:var(--warn); }
|
|
1377
|
+
.sw-auto{ background:var(--n-800); }
|
|
1378
|
+
.sw-refuse{ background:repeating-linear-gradient(45deg,var(--danger) 0 2px,transparent 2px 5px); border-color:var(--danger); }
|
|
1379
|
+
.sw-loop{ background:var(--violet); border-color:var(--violet); }
|
|
1380
|
+
.sw-late{ background:var(--n-800); border-color:var(--violet); border-style:dashed; }
|
|
1381
|
+
|
|
1382
|
+
/* WHY minmax(0,1fr) AND NOT 1fr: a bare 1fr track has an automatic minimum of
|
|
1383
|
+
min-content, so ONE unbreakable string — a 40-character file path in a citation —
|
|
1384
|
+
makes the track wider than its share and the grid overflows its own box. The
|
|
1385
|
+
measured result was a card 491px wide inside a 401px track, with its citation
|
|
1386
|
+
painted across the neighbouring card. minmax(0,1fr) lets the track shrink and the
|
|
1387
|
+
text wrap instead. Same reason for every min-width:0 in this sheet: it is what
|
|
1388
|
+
lets a flex item be narrower than its longest word. */
|
|
1389
|
+
.step{ display:grid; grid-template-columns:var(--sp-8) minmax(0,1fr); gap:0; }
|
|
1390
|
+
.rail{ position:relative; }
|
|
1391
|
+
.rail::before{ content:""; position:absolute; left:50%; top:0; bottom:0; width:1px; background:var(--line-strong); transform:translateX(-50%); }
|
|
1392
|
+
.rail-dot{ position:absolute; left:50%; top:var(--sp-5); width:9px; height:9px; transform:translate(-50%,-50%) rotate(45deg); background:var(--n-850); border:1px solid var(--n-500); }
|
|
1393
|
+
.step-human .rail-dot{ background:var(--warn); border-color:var(--warn); }
|
|
1394
|
+
.step-body{ border:1px solid var(--line); border-left:2px solid var(--n-600); background:var(--surface-2); border-radius:0 var(--r-2) var(--r-2) 0; padding:var(--sp-4) var(--sp-4) var(--sp-4) var(--sp-5); margin-bottom:var(--sp-3); min-width:0; }
|
|
1395
|
+
.step-human .step-body{ border-left-color:var(--warn); background:linear-gradient(90deg, rgba(240,179,87,0.055), transparent 40%), var(--surface-2); }
|
|
1396
|
+
.step-head{ display:flex; flex-wrap:wrap; gap:var(--sp-2) var(--sp-4); align-items:baseline; justify-content:space-between; border-bottom:1px solid var(--line); padding-bottom:var(--sp-2); margin-bottom:var(--sp-3); }
|
|
1397
|
+
.step-id{ display:flex; align-items:baseline; gap:var(--sp-3); min-width:0; flex-wrap:wrap; }
|
|
1398
|
+
.step-num{ font-size:var(--fs-lg); color:var(--accent); font-weight:600; min-width:2ch; }
|
|
1399
|
+
.step-artifact{ font-size:var(--fs-sm); color:var(--fg); font-weight:600; }
|
|
1400
|
+
.step-kind{ font-size:var(--fs-3xs); color:var(--fg-faint); min-width:0; overflow-wrap:anywhere; }
|
|
1401
|
+
|
|
1402
|
+
.io{ display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:var(--sp-4); }
|
|
1403
|
+
.io h4, .gates h4{ font-size:var(--fs-3xs); text-transform:uppercase; letter-spacing:0.12em; color:var(--fg-faint); font-weight:600; margin-bottom:var(--sp-2); }
|
|
1404
|
+
.io-col{ min-width:0; }
|
|
1405
|
+
.io-sub{ text-transform:none; letter-spacing:0; color:var(--n-500); }
|
|
1406
|
+
.io ul li, .io-list li{ font-size:var(--fs-2xs); color:var(--fg-2); padding:var(--sp-1) 0; border-bottom:1px dotted var(--n-700); }
|
|
1407
|
+
.io ul li:last-child, .io-list li:last-child{ border-bottom:0; }
|
|
1408
|
+
.doc{ color:var(--n-100); }
|
|
1409
|
+
.doc-artifact{ color:var(--accent); }
|
|
1410
|
+
.doc-notartifact{ color:var(--fg-muted); font-style:italic; }
|
|
1411
|
+
.gates{ margin-top:var(--sp-4); border-top:1px solid var(--line); padding-top:var(--sp-3); }
|
|
1412
|
+
.gate-list li{ display:grid; grid-template-columns:5.5rem minmax(0,1fr); gap:var(--sp-3); align-items:start; font-size:var(--fs-2xs); color:var(--fg-2); padding:var(--sp-2) 0; border-bottom:1px dotted var(--n-700); }
|
|
1413
|
+
.gate-list li:last-child{ border-bottom:0; }
|
|
1414
|
+
.g-shape{ font-size:var(--fs-3xs); letter-spacing:0.08em; border:1px solid var(--line-strong); border-radius:var(--r-1); padding:0 var(--sp-1); text-align:center; color:var(--fg-muted); }
|
|
1415
|
+
.g-human .g-shape{ border-color:var(--warn); color:var(--warn); background:rgba(240,179,87,0.08); }
|
|
1416
|
+
.g-refuse .g-shape{ border-color:var(--danger); color:var(--danger); background:repeating-linear-gradient(45deg, rgba(255,139,122,0.16) 0 2px, transparent 2px 5px); }
|
|
1417
|
+
.g-human .g-text{ color:var(--fg); }
|
|
1418
|
+
.loop-note{ margin-top:var(--sp-3); font-size:var(--fs-3xs); color:var(--violet); }
|
|
1419
|
+
.loop-note a{ color:var(--violet); }
|
|
1420
|
+
|
|
1421
|
+
/* ---- two-column rails (phase 0) ---------------------------------------- */
|
|
1422
|
+
/* minmax(min(280px,100%),1fr) — the inner min() is what stops the track from
|
|
1423
|
+
demanding 280px in a container narrower than that (a 320px card in a 300px space
|
|
1424
|
+
is an overflow, not a layout). */
|
|
1425
|
+
.rail-pair{ display:grid; grid-template-columns:repeat(auto-fit, minmax(min(280px,100%),1fr)); gap:var(--sp-4); }
|
|
1426
|
+
.subcard{ border:1px solid var(--line); border-radius:var(--r-2); background:var(--surface-2); padding:var(--sp-4); }
|
|
1427
|
+
.subcard h3{ font-size:var(--fs-2xs); text-transform:uppercase; letter-spacing:0.1em; color:var(--fg-faint); }
|
|
1428
|
+
.subcard .pre{ margin-top:var(--sp-2); }
|
|
1429
|
+
|
|
1430
|
+
/* ---- detail cards ------------------------------------------------------- */
|
|
1431
|
+
.detail-grid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(min(320px,100%),1fr)); gap:var(--sp-4); align-items:start; }
|
|
1432
|
+
.card{ border:1px solid var(--line); border-radius:var(--r-2); background:var(--surface-2); padding:var(--sp-4); min-width:0; }
|
|
1433
|
+
.card-wide{ grid-column:1 / -1; }
|
|
1434
|
+
.sections{ counter-reset:s; }
|
|
1435
|
+
.sections li{ counter-increment:s; font-size:var(--fs-2xs); padding:var(--sp-1) 0 var(--sp-1) var(--sp-6); position:relative; border-bottom:1px dotted var(--n-700); }
|
|
1436
|
+
.sections li::before{ content:counter(s) "."; position:absolute; left:0; color:var(--fg-faint); font-size:var(--fs-3xs); }
|
|
1437
|
+
.sections li:last-child{ border-bottom:0; }
|
|
1438
|
+
.refuse-list li{ display:grid; grid-template-columns:5.5rem minmax(0,1fr); gap:var(--sp-3); align-items:start; font-size:var(--fs-2xs); color:var(--fg-2); padding:var(--sp-2) 0; border-bottom:1px dotted var(--n-700); }
|
|
1439
|
+
.refuse-list li:last-child{ border-bottom:0; }
|
|
1440
|
+
.g-shape-refuse{ border-color:var(--danger); color:var(--danger); background:repeating-linear-gradient(45deg, rgba(255,139,122,0.16) 0 2px, transparent 2px 5px); }
|
|
1441
|
+
.pre{ background:var(--n-950); border:1px solid var(--line); border-left:2px solid var(--accent-line); border-radius:var(--r-2); padding:var(--sp-3); font-size:var(--fs-3xs); color:var(--n-200); overflow-x:auto; white-space:pre; line-height:1.6; margin:0; }
|
|
1442
|
+
|
|
1443
|
+
.ask{ border:1px solid var(--warn-dim); border-left:2px solid var(--warn); border-radius:var(--r-2); padding:var(--sp-3); margin-top:var(--sp-3); background:rgba(240,179,87,0.045); }
|
|
1444
|
+
.ask-head{ display:flex; flex-wrap:wrap; gap:var(--sp-3); align-items:baseline; font-size:var(--fs-2xs); }
|
|
1445
|
+
.ask-header{ color:var(--warn); font-weight:600; }
|
|
1446
|
+
.ask-q{ font-size:var(--fs-2xs); color:var(--fg-2); margin-top:var(--sp-2); }
|
|
1447
|
+
.ask-opts{ margin-top:var(--sp-2); }
|
|
1448
|
+
.ask-opts li{ font-size:var(--fs-2xs); color:var(--fg-muted); padding:var(--sp-1) 0 var(--sp-1) var(--sp-5); position:relative; }
|
|
1449
|
+
.ask-opts li::before{ content:"▸"; position:absolute; left:var(--sp-2); color:var(--warn); }
|
|
1450
|
+
|
|
1451
|
+
/* ---- tables ------------------------------------------------------------- */
|
|
1452
|
+
.tablewrap{ overflow-x:auto; border:1px solid var(--line); border-radius:var(--r-2); }
|
|
1453
|
+
table{ border-collapse:collapse; width:100%; font-size:var(--fs-2xs); min-width:640px; }
|
|
1454
|
+
caption{ text-align:left; font-size:var(--fs-3xs); color:var(--fg-faint); padding:var(--sp-3) var(--sp-4) 0; }
|
|
1455
|
+
th,td{ text-align:left; padding:var(--sp-2) var(--sp-3); border-bottom:1px solid var(--line); vertical-align:top; }
|
|
1456
|
+
thead th{ position:sticky; top:0; background:var(--n-800); color:var(--fg); font-size:var(--fs-3xs); text-transform:uppercase; letter-spacing:0.08em; font-weight:600; }
|
|
1457
|
+
tbody tr:last-child td{ border-bottom:0; }
|
|
1458
|
+
tbody tr:hover{ background:var(--n-850); }
|
|
1459
|
+
td .cite{ margin-left:var(--sp-1); }
|
|
1460
|
+
.seq{ display:inline-block; min-width:2ch; color:var(--accent); }
|
|
1461
|
+
.k-input{ color:var(--accent); } .k-value{ color:var(--violet); } .k-workspace{ color:var(--fg-muted); }
|
|
1462
|
+
.k-state{ color:var(--warn); } .k-runtime{ color:var(--danger); } .k-capability{ color:var(--ok); }
|
|
1463
|
+
|
|
1464
|
+
/* ---- lock chain --------------------------------------------------------- */
|
|
1465
|
+
.chain{ counter-reset:c; }
|
|
1466
|
+
.chain li{ counter-increment:c; display:grid; grid-template-columns:2.5rem minmax(0,1fr); gap:var(--sp-3); padding:var(--sp-3) 0; border-bottom:1px solid var(--line); font-size:var(--fs-2xs); }
|
|
1467
|
+
.chain li:last-child{ border-bottom:0; }
|
|
1468
|
+
.chain li::before{ content:counter(c); grid-row:1; color:var(--accent); border:1px solid var(--accent-line); border-radius:var(--r-2); height:1.6em; display:grid; place-items:center; font-size:var(--fs-3xs); }
|
|
1469
|
+
.chain .ct{ color:var(--fg); font-weight:600; display:block; }
|
|
1470
|
+
.chain .cd{ color:var(--fg-muted); display:block; margin-top:var(--sp-1); overflow-wrap:anywhere; }
|
|
1471
|
+
|
|
1472
|
+
/* ---- loop cards --------------------------------------------------------- */
|
|
1473
|
+
.loop{ border:1px solid var(--violet-dim); border-left:2px solid var(--violet); border-radius:var(--r-2); background:linear-gradient(90deg, rgba(185,167,255,0.05), transparent 30%), var(--surface-2); padding:var(--sp-4); margin-bottom:var(--sp-3); min-width:0; }
|
|
1474
|
+
.loop h3{ color:var(--violet); font-size:var(--fs-sm); flex-wrap:wrap; }
|
|
1475
|
+
.loop .lid{ font-size:var(--fs-3xs); border:1px solid var(--violet); color:var(--violet); border-radius:var(--r-1); padding:0 var(--sp-1); margin-right:var(--sp-2); }
|
|
1476
|
+
.loop p{ font-size:var(--fs-2xs); color:var(--fg-2); margin-top:var(--sp-2); overflow-wrap:anywhere; }
|
|
1477
|
+
|
|
1478
|
+
/* ---- diagram ------------------------------------------------------------
|
|
1479
|
+
THE DIAGRAM IS AN SVG IN AN EXPLICIT SET OF BANDS, and the two custom properties
|
|
1480
|
+
in this rule are the interface to it: --dg-w is the width the chart laid itself
|
|
1481
|
+
out for and --dg-h its height, both written by the generator (see diagram()), so
|
|
1482
|
+
the svg keeps its OWN coordinate system and its text stays at its declared size
|
|
1483
|
+
instead of being scaled by whatever box it lands in. Below --dg-w the wrapper
|
|
1484
|
+
SCROLLS HORIZONTALLY — one strip, exactly the mechanism the tab strip already
|
|
1485
|
+
uses at every width — so a narrow viewport never squeezes the chart, and nothing
|
|
1486
|
+
inside it can be painted outside the box that holds it. */
|
|
1487
|
+
.diagram{ border:1px solid var(--line); border-radius:var(--r-2); background:var(--n-950); padding:var(--sp-4); overflow-x:auto; overscroll-behavior-inline:contain; scrollbar-width:thin; scrollbar-color:var(--n-600) transparent; }
|
|
1488
|
+
.diagram svg{ display:block; width:var(--dg-w); min-width:var(--dg-w); height:auto; }
|
|
1489
|
+
.dg-box-strip{ fill:none; stroke:var(--n-600); stroke-width:1; stroke-dasharray:3 3; }
|
|
1490
|
+
.dg-box{ fill:var(--n-850); stroke:var(--n-500); stroke-width:1; }
|
|
1491
|
+
.dg-box-human{ fill:var(--warn-ink); stroke:var(--warn); }
|
|
1492
|
+
.dg-box-refuse{ fill:var(--danger-ink); stroke:var(--danger); }
|
|
1493
|
+
/* Text styles for the diagram, EMITTED FROM DG_TEXT so the size the boxes were
|
|
1494
|
+
computed at and the size the glyphs are drawn at are the same number. The checker
|
|
1495
|
+
re-reads these four declarations and re-derives the fit from them. */
|
|
1496
|
+
${Object.entries(DG_TEXT).map(([k, v]) => `.${k}{ fill:${v.fill}; font-family:var(--mono); font-size:${v.size}px; }`).join('\n')}
|
|
1497
|
+
.dg-line{ stroke:var(--n-500); stroke-width:1; fill:none; }
|
|
1498
|
+
.dg-line-acc{ stroke:var(--accent); stroke-width:1; fill:none; }
|
|
1499
|
+
.dg-line-warn{ stroke:var(--warn); stroke-width:1; fill:none; }
|
|
1500
|
+
.dg-line-loop{ stroke:var(--violet); stroke-width:1; fill:none; stroke-dasharray:4 3; }
|
|
1501
|
+
.dg-head{ fill:var(--n-500); }
|
|
1502
|
+
.dg-head-acc{ fill:var(--accent); }
|
|
1503
|
+
.dg-head-warn{ fill:var(--warn); }
|
|
1504
|
+
.dg-head-loop{ fill:var(--violet); }
|
|
1505
|
+
|
|
1506
|
+
/* ---- misc --------------------------------------------------------------- */
|
|
1507
|
+
footer.colophon{ border-top:1px solid var(--line-strong); margin-top:var(--sp-8); padding-top:var(--sp-5); font-size:var(--fs-3xs); color:var(--fg-faint); }
|
|
1508
|
+
footer.colophon p{ margin-bottom:var(--sp-2); max-width:100ch; }
|
|
1509
|
+
.kv{ display:grid; grid-template-columns:auto 1fr; gap:var(--sp-1) var(--sp-4); font-size:var(--fs-2xs); }
|
|
1510
|
+
.kv dt{ color:var(--fg-faint); } .kv dd{ margin:0; color:var(--fg-2); }
|
|
1511
|
+
.callout{ border:1px solid var(--warn-dim); border-left:2px solid var(--warn); background:rgba(240,179,87,0.05); border-radius:var(--r-2); padding:var(--sp-4); margin-bottom:var(--sp-5); font-size:var(--fs-2xs); color:var(--fg-2); }
|
|
1512
|
+
.callout strong{ color:var(--warn); }
|
|
1513
|
+
.ok-line{ color:var(--ok); }
|
|
1514
|
+
.bad-line{ color:var(--danger); }
|
|
1515
|
+
|
|
1516
|
+
/* Below ~820px the two-column I/O grid and the label-column gate lists stop
|
|
1517
|
+
fitting, so both collapse to a single column. Type is already fluid via clamp,
|
|
1518
|
+
so no font-size override is needed here — a fixed override at one breakpoint is
|
|
1519
|
+
what makes text lurch at 819px. The tab strip needs no rule either: it is a
|
|
1520
|
+
single scrollable row at every width. */
|
|
1521
|
+
@media (max-width:820px){
|
|
1522
|
+
.wrap{ padding:0 var(--sp-4) var(--sp-10); }
|
|
1523
|
+
.panel{ padding:var(--sp-4); }
|
|
1524
|
+
/* minmax(0,1fr) here too: a single-column grid whose item is a long citation is the
|
|
1525
|
+
same overflow risk as a multi-column one, just easier to miss. */
|
|
1526
|
+
.io{ grid-template-columns:minmax(0,1fr); }
|
|
1527
|
+
.step{ grid-template-columns:var(--sp-6) minmax(0,1fr); }
|
|
1528
|
+
.gate-list li, .refuse-list li{ grid-template-columns:minmax(0,1fr); gap:var(--sp-1); }
|
|
1529
|
+
.g-shape{ justify-self:start; }
|
|
1530
|
+
.masthead-top{ flex-direction:column; }
|
|
1531
|
+
}
|
|
1532
|
+
@media (prefers-reduced-motion: reduce){
|
|
1533
|
+
*,*::before,*::after{ animation-duration:0.001ms !important; animation-iteration-count:1 !important; transition-duration:0.001ms !important; scroll-behavior:auto !important; }
|
|
1534
|
+
}
|
|
1535
|
+
@media print{
|
|
1536
|
+
.tabstrip{ position:static; } .js .panel{ display:block !important; }
|
|
1537
|
+
body{ background:#fff; color:#000; }
|
|
1538
|
+
}
|
|
1539
|
+
</style>
|
|
1540
|
+
</head>
|
|
1541
|
+
<body>
|
|
1542
|
+
<a class="skip" href="#tab-overview">Skip to the workflow overview</a>
|
|
1543
|
+
<div class="wrap">
|
|
1544
|
+
|
|
1545
|
+
<header class="masthead">
|
|
1546
|
+
<div class="masthead-top">
|
|
1547
|
+
<div>
|
|
1548
|
+
<h1><span class="tick">▚</span> recursive-mode — the workflow</h1>
|
|
1549
|
+
<p class="mast-sub">Twelve phase artifacts, five DSH lifecycle seams, two hook points that can actually refuse, three human gates, and a lock chain with seven refusals. Every factual block below carries the <code>file:line</code> it was transcribed from.</p>
|
|
1550
|
+
</div>
|
|
1551
|
+
</div>
|
|
1552
|
+
<div class="mast-meta">
|
|
1553
|
+
<span class="chip chip-acc"><b>12</b> phase artifacts</span>
|
|
1554
|
+
<span class="chip"><b>13</b> tools (12 without agentTeams)</span>
|
|
1555
|
+
<span class="chip"><b>8</b> guard rules</span>
|
|
1556
|
+
<span class="chip chip-warn"><b>3</b> human gates + run-start</span>
|
|
1557
|
+
<span class="chip"><b>5</b> hook points</span>
|
|
1558
|
+
<span class="chip"><b>24</b> RM#### codes</span>
|
|
1559
|
+
<span class="chip">source: <b>${esc(REPO)}</b></span>
|
|
1560
|
+
</div>
|
|
1561
|
+
</header>
|
|
1562
|
+
|
|
1563
|
+
<nav class="tabstrip" aria-label="Workflow views">
|
|
1564
|
+
<p class="tabstrip-label" id="tabstrip-hint">Views — use ← → Home End to move between tabs</p>
|
|
1565
|
+
<div role="tablist" aria-labelledby="tabstrip-hint">
|
|
1566
|
+
${tabs.map((t, i) => ` <button role="tab" id="tab-${t.id}" aria-controls="panel-${t.id}" aria-selected="${i === 0 ? 'true' : 'false'}" tabindex="${i === 0 ? '0' : '-1'}"><span class="tlabel">${esc(t.label)}</span><span class="tsub">${esc(t.sub)}</span></button>`).join('\n')}
|
|
1567
|
+
</div>
|
|
1568
|
+
</nav>
|
|
1569
|
+
|
|
1570
|
+
<main id="main">
|
|
1571
|
+
|
|
1572
|
+
<!-- ===================== OVERVIEW ===================== -->
|
|
1573
|
+
<section class="panel is-active" id="panel-overview" role="tabpanel" aria-labelledby="tab-overview" tabindex="0">
|
|
1574
|
+
<div class="panel-head">
|
|
1575
|
+
<h2>The flow, end to end</h2>
|
|
1576
|
+
<p class="lede">Read top to bottom. Each card is one artifact on disk; the left rail is the sequence; the <span style="color:var(--violet)">violet</span> links are the places the flow legitimately runs BACKWARDS. ${cite(SRC.seq)}</p>
|
|
1577
|
+
</div>
|
|
1578
|
+
|
|
1579
|
+
<div class="legend">
|
|
1580
|
+
<div><span class="swatch sw-auto"></span> automatic step</div>
|
|
1581
|
+
<div><span class="swatch sw-human"></span> DECISION — a human must answer</div>
|
|
1582
|
+
<div><span class="swatch sw-refuse"></span> REFUSAL — the call is stopped</div>
|
|
1583
|
+
<div><span class="swatch sw-loop"></span> backward edge</div>
|
|
1584
|
+
<div><span class="swatch sw-late"></span> late phase (dashed = optional phase)</div>
|
|
1585
|
+
</div>
|
|
1586
|
+
|
|
1587
|
+
<div class="callout">
|
|
1588
|
+
<strong>Reading the shapes, not the colours.</strong> Every gate below carries a word — <b>DECISION</b>, <b>REFUSAL</b> or <b>AUTO</b> — and a border treatment, so nothing here depends on colour alone. The human steps are also the only cards with a filled amber rail dot. Layout follows the workflow, not a hardware pipeline: this is a gated process with decision points, so the diagram is a vertical sequence with side rails, not a data bus.
|
|
1589
|
+
</div>
|
|
1590
|
+
|
|
1591
|
+
${dg.markup}
|
|
1592
|
+
|
|
1593
|
+
<h2 style="font-size:var(--fs-lg);margin:var(--sp-8) 0 var(--sp-4)">The twelve artifacts, in order</h2>
|
|
1594
|
+
|
|
1595
|
+
<div class="rail-pair" style="margin-bottom:var(--sp-5)">
|
|
1596
|
+
<div class="subcard" style="border-left:2px solid var(--accent)">
|
|
1597
|
+
<h3>Index 0 — the spec</h3>
|
|
1598
|
+
<p class="note" style="margin-top:0"><code>00-requirements.md</code> is the one artifact that carries a HUMAN decision before anything else can happen. It is index 0 of twelve. ${cite(SRC.seq)}</p>
|
|
1599
|
+
</div>
|
|
1600
|
+
<div class="subcard" style="border-left:2px solid var(--warn)">
|
|
1601
|
+
<h3>Index 1 — the isolation</h3>
|
|
1602
|
+
<p class="note" style="margin-top:0"><code>00-worktree.md</code> shares phase <b>number</b> 0 but is a separate artifact, and supplies the executable diff basis every later audited phase reuses. ${cite(SRC.seq + ', ' + SRC.tplWorktree)}</p>
|
|
1603
|
+
</div>
|
|
1604
|
+
</div>
|
|
1605
|
+
|
|
1606
|
+
<div class="flow">
|
|
1607
|
+
${PHASES.map(phaseRow).join('\n')}
|
|
1608
|
+
</div>
|
|
1609
|
+
|
|
1610
|
+
<h2 style="font-size:var(--fs-lg);margin:var(--sp-8) 0 var(--sp-4)">Two phases that SHARE a number</h2>
|
|
1611
|
+
<div class="tablewrap">
|
|
1612
|
+
<table>
|
|
1613
|
+
<caption>${cite(SRC.phaseNumber + ', ' + SRC.phaseOrderRule)} — <code>phaseNumberForArtifact</code> reads the LEADING DIGITS of the file name, so the pair below is ONE phase as far as the ordering guard is concerned.</caption>
|
|
1614
|
+
<thead><tr><th>Sharing</th><th>Artifacts</th><th>Consequence</th></tr></thead>
|
|
1615
|
+
<tbody>
|
|
1616
|
+
<tr><td><span class="seq">0</span></td><td><code>00-requirements.md</code> · <code>00-worktree.md</code></td><td>The phase-order rule ABSTAINS for either while phase 0 is active, so both are writable.</td></tr>
|
|
1617
|
+
<tr><td><span class="seq">1</span></td><td><code>01-as-is.md</code> · <code>01.5-root-cause.md</code></td><td>The leading digits of <code>01.5-root-cause.md</code> are <code>01</code> → <code>Number("01") = 1</code>. Same phase.</td></tr>
|
|
1618
|
+
<tr><td><span class="seq">3</span></td><td><code>03-implementation-summary.md</code> · <code>03.5-code-review.md</code></td><td>Same phase; the TDD-evidence baseline therefore applies to a lock of EITHER.</td></tr>
|
|
1619
|
+
</tbody>
|
|
1620
|
+
</table>
|
|
1621
|
+
</div>
|
|
1622
|
+
|
|
1623
|
+
<h2 style="font-size:var(--fs-lg);margin:var(--sp-8) 0 var(--sp-4)">The late phases</h2>
|
|
1624
|
+
<p class="lede" style="margin-bottom:var(--sp-4)"><code>LATE_PHASE_ARTIFACTS</code> is a three-member set declared separately from the sequence, and it is what the cold-face of the workflow leans on: by phase 6 the implementation is FROZEN and these three phases document rather than change. ${cite(LATE.c)}</p>
|
|
1625
|
+
<ul class="io-list" style="border:1px solid var(--line);border-radius:var(--r-2);padding:var(--sp-3) var(--sp-4);background:var(--surface-2)">
|
|
1626
|
+
${LATE.files.map((f) => `<li><span class="doc doc-artifact">${esc(f)}</span>${cite(LATE.c)}</li>`).join('')}
|
|
1627
|
+
</ul>
|
|
1628
|
+
|
|
1629
|
+
<h2 style="font-size:var(--fs-lg);margin:var(--sp-8) 0 var(--sp-4)">Where a step can be refused, at a glance</h2>
|
|
1630
|
+
<div class="tablewrap">
|
|
1631
|
+
<table>
|
|
1632
|
+
<caption>${cite(SRC.builtInRules + ', ' + SRC.baseline)} — the rules that can stop a call. "On" is the tool pattern the rule is scoped to.</caption>
|
|
1633
|
+
<thead><tr><th>Rule</th><th>What it says</th><th>On</th><th>Source</th></tr></thead>
|
|
1634
|
+
<tbody>
|
|
1635
|
+
${GUARDS.map((g) => `<tr><td><code>${esc(g.k)}</code></td><td>${esc(g.what)}</td><td><code>${esc(g.on)}</code></td><td>${cite(g.c)}</td></tr>`).join('')}
|
|
1636
|
+
</tbody>
|
|
1637
|
+
</table>
|
|
1638
|
+
</div>
|
|
1639
|
+
|
|
1640
|
+
<h2 style="font-size:var(--fs-lg);margin:var(--sp-8) 0 var(--sp-4)">Automatic vs human — the whole decision surface</h2>
|
|
1641
|
+
<div class="tablewrap">
|
|
1642
|
+
<table>
|
|
1643
|
+
<caption>A complete list of the places a PERSON must decide. Everything else in this workflow is automatic or a mechanical refusal. ${cite(SRC.startGate + ', ' + SRC.askGates)}</caption>
|
|
1644
|
+
<thead><tr><th>#</th><th>Decision</th><th>Labels</th><th>Recorded in</th><th>Source</th></tr></thead>
|
|
1645
|
+
<tbody>
|
|
1646
|
+
<tr><td><span class="seq">1</span></td><td><b>Start run or Hold</b> — decides whether a run exists at all. Approving arms a goal the harness then drives.</td><td><code>Start run</code> | <code>Hold</code></td><td><code>00-requirements.md</code> as <code>- Run Start: …</code></td><td>${cite(SRC.startLabels + ', ' + SRC.startLine)}</td></tr>
|
|
1647
|
+
<tr><td><span class="seq">2</span></td><td><b>TDD Mode</b> — how phase 3 must evidence its tests.</td><td><code>strict</code> | <code>pragmatic</code></td><td>The artifact's <code>TDD Mode</code> field</td><td>${cite(SRC.askTdd)}</td></tr>
|
|
1648
|
+
<tr><td><span class="seq">3</span></td><td><b>QA sign-off</b> — who signs the manual QA phase.</td><td><code>human</code> | <code>agent-operated</code> | <code>hybrid</code></td><td>The artifact's <code>QA Execution Mode</code> field</td><td>${cite(SRC.askQa)}</td></tr>
|
|
1649
|
+
<tr><td><span class="seq">4</span></td><td><b>Gate block</b> — how a blocked transition is resolved. Raised WITH the refusal, so the choice travels with the problem.</td><td><code>fix</code> | <code>reopen</code> | <code>abandon</code></td><td>The artifact's <code>Gate Resolution</code> field</td><td>${cite(SRC.askGateBlock + ', ' + SRC.blockGoal)}</td></tr>
|
|
1650
|
+
<tr><td><span class="seq">5</span></td><td><b>QA sign-off evidence</b> — for <code>human</code>/<code>hybrid</code> the lock additionally requires a meaningful <code>Approved by</code> and <code>Date</code> in <code>## User Sign-Off</code>.</td><td>a name + a date</td><td><code>05-manual-qa.md</code></td><td>${cite('src/lifecycle.ts:108-114, ' + SRC.lintQaSignoff)}</td></tr>
|
|
1651
|
+
</tbody>
|
|
1652
|
+
</table>
|
|
1653
|
+
</div>
|
|
1654
|
+
|
|
1655
|
+
<h2 style="font-size:var(--fs-lg);margin:var(--sp-8) 0 var(--sp-4)">The lock chain — what a lock actually checks, in order</h2>
|
|
1656
|
+
<ol class="chain">
|
|
1657
|
+
${LOCK_CHAIN.map((s) => `<li><div><span class="ct">${esc(s.t)}</span><span class="cd">${esc(s.d)} ${cite(s.c)}</span></div></li>`).join('\n ')}
|
|
1658
|
+
</ol>
|
|
1659
|
+
<p class="note">The order is load-bearing and stated in the source: lock order stays FIRST, so a run that is both out of order and below standard still reports ORDERING; the linter stays LAST, its documented invariant. ${cite(SRC.lintGate)} A lock is refused, never silently downgraded — <code>lockArtifact</code> has no mode input and the lock chain has never been advisory-gated. ${cite(SRC.memoryGate)}</p>
|
|
1660
|
+
|
|
1661
|
+
<h2 style="font-size:var(--fs-lg);margin:var(--sp-8) 0 var(--sp-4)">Strict vs advisory</h2>
|
|
1662
|
+
<div class="tablewrap">
|
|
1663
|
+
<table>
|
|
1664
|
+
<caption>${cite(SRC.mode + ', ' + SRC.modeDefault + ', ' + SRC.verdictFor + ', ' + SRC.coerce)} — the mode is <code>strict</code> by default, and it decides only what the GUARD does with a policy verdict. The lock chain is unaffected either way.</caption>
|
|
1665
|
+
<thead><tr><th>Mode</th><th>What a policy <code>deny</code> becomes</th><th>What an <code>ask</code> becomes</th></tr></thead>
|
|
1666
|
+
<tbody>
|
|
1667
|
+
<tr><td><code>strict</code> <span class="tag tag-tdd">default</span></td><td><code>deny</code> — and when the refusal was decided from real ordering blockers it also carries the <code>fix | reopen | abandon</code> ask.</td><td><code>deny</code>: <code>ask under strict enforcement denies</code></td></tr>
|
|
1668
|
+
<tr><td><code>advisory</code></td><td><code>ask</code> (reason preserved), then coerced to an <strong>allow with a warning</strong> so an advisory pass is never silent.</td><td><code>allow</code> + <code>warn</code>: <code>ask under advisory enforcement allows</code></td></tr>
|
|
1669
|
+
</tbody>
|
|
1670
|
+
</table>
|
|
1671
|
+
</div>
|
|
1672
|
+
|
|
1673
|
+
<div class="callout" style="margin-top:var(--sp-5)">
|
|
1674
|
+
<strong>A refusal does not surface as a tool error.</strong> A <code>tools/pre-execute</code> deny becomes <code>content: [{ type: 'text', text: 'Error: ' + reason }]</code> and every other field of the decision is dropped — which is why the gate-block ask has to be RENDERED INTO THE SENTENCE to reach the caller at all. ${cite(SRC.denyText)}
|
|
1675
|
+
</div>
|
|
1676
|
+
</section>
|
|
1677
|
+
|
|
1678
|
+
<!-- ===================== PHASE 0 GATE ===================== -->
|
|
1679
|
+
<section class="panel" id="panel-start" role="tabpanel" aria-labelledby="tab-start" tabindex="0" hidden>
|
|
1680
|
+
<div class="panel-head">
|
|
1681
|
+
<h2>Phase 0 — the one gate that decides whether there is a run</h2>
|
|
1682
|
+
<p class="lede">Starting a run is a human decision, not a side effect of scaffolding. This view is the mechanism in full, because it is the only place the workflow can be stopped before it begins. ${cite(SRC.startModule)}</p>
|
|
1683
|
+
</div>
|
|
1684
|
+
|
|
1685
|
+
<div class="detail-grid">
|
|
1686
|
+
<section class="card">
|
|
1687
|
+
<h3>The defect this closes</h3>
|
|
1688
|
+
<p class="note" style="margin-top:0"><code>recursive_init</code> scaffolded a run and the plugin then CREATED AND ARMED a goal for it in the same breath. A goal is not a label: creating one returns an ARMED view and the harness immediately begins driving autonomous goal rounds. Asking for a run spec was therefore enough to start an unattended run. ${cite(SRC.startModule)}</p>
|
|
1689
|
+
</section>
|
|
1690
|
+
<section class="card">
|
|
1691
|
+
<h3>What is withheld</h3>
|
|
1692
|
+
<p class="note" style="margin-top:0">The GOAL — the object that makes the harness drive rounds. The scaffold, the Phase 0 artifacts and every later phase document are still created. A run that is scaffolded and never approved is a spec: <em>readable, editable, lockable, and inert.</em> ${cite(SRC.startModule)}</p>
|
|
1693
|
+
</section>
|
|
1694
|
+
|
|
1695
|
+
<section class="card card-wide">
|
|
1696
|
+
<h3>The gate, as data</h3>
|
|
1697
|
+
<div class="ask">
|
|
1698
|
+
<div class="ask-head"><code>gate id: run-start</code><span class="ask-header">Start run</span></div>
|
|
1699
|
+
<p class="ask-q">Approve phase 0 and start this run? Approving creates an armed goal the harness will keep driving.</p>
|
|
1700
|
+
<ul class="ask-opts">
|
|
1701
|
+
<li><b>Start run</b> — record the approval and arm the run goal.</li>
|
|
1702
|
+
<li><b>Hold</b> — leave the spec inert: no run goal, no autonomous rounds.</li>
|
|
1703
|
+
</ul>
|
|
1704
|
+
${cite(SRC.startGate)}
|
|
1705
|
+
</div>
|
|
1706
|
+
<p class="note">Deliberately NOT in <code>ASK_GATE_IDS</code>. Those three are the WORKFLOW's gates; starting a run is a different kind of decision, and their membership is asserted as exactly three — so widening the workflow's gate list cannot quietly widen what may start a run. ${cite(SRC.startNotAGate)}</p>
|
|
1707
|
+
</section>
|
|
1708
|
+
|
|
1709
|
+
<section class="card card-wide">
|
|
1710
|
+
<h3>The route, in order — the channel first, the relay only as a named fallback</h3>
|
|
1711
|
+
<ol class="chain">
|
|
1712
|
+
<li><div><span class="ct">Ordering guard — refuse to ASK at all</span><span class="cd"><code>runStartSpecGuard</code> runs BEFORE the question is put to anybody. It refuses when <code>00-requirements.md</code> is missing, and when it still classifies as the template scaffold — quoting the placeholder lines and their line numbers verbatim in the refusal (RM4404). A missing artifact is refused too: the gate's own question has no referent without it. ${cite(SRC.startGuard)}</span></div></li>
|
|
1713
|
+
<li><div><span class="ct">The mounted human channel is asked</span><span class="cd">When the composition mounts <code>ctx.userQuestions</code> — the harness's own blocking human channel, the same one plan-mode's exit uses — the question is PUT TO THE PERSON and only their selection is recorded. A caller-supplied answer cannot stand in for it. ${cite(SRC.startModule)}</span></div></li>
|
|
1714
|
+
<li><div><span class="ct">The value is the decision</span><span class="cd">The approving label is written into the Phase 0 requirements document, and the gate REFUSES an answer that is not one of the labels it offered. An unoffered answer is a transcription error wearing the shape of a decision. ${cite(SRC.startModule)}</span></div></li>
|
|
1715
|
+
<li><div><span class="ct">Matched on the VALUE, never on the line's presence</span><span class="cd"><code>readRunStartApproval</code> reads the field's VALUE, so a recorded <code>- Run Start: Hold</code> is refused — a check for "is there a Run Start line?" would read a refusal as consent, the one mistake this module exists to prevent. ${cite(SRC.startValueMatch + ', ' + SRC.startApproval)}</span></div></li>
|
|
1716
|
+
<li><div><span class="ct">Relay — the named fallback</span><span class="cd">Only when the channel cannot deliver the question. Then <code>relay=true</code> with an explicit answer records it, and the result reports <code>source: "relayed"</code> rather than as a person's own selection. A failure meaning the question was <b>cancelled, aborted or timed out is NEVER relayable</b> (RM5503); a channel that answered off-vocabulary ends the call (RM5504) and no relayed answer can replace a decision the person actually made; a composition with NO channel at all falls back to the relayed answer unconditionally (RM5502). ${cite(SRC.startNotApproved + ', ' + SRC.errNoChannel + ', ' + SRC.errUnanswered)}</span></div></li>
|
|
1717
|
+
<li><div><span class="ct">The goal cannot exist without it</span><span class="cd"><code>syncRunGoal</code> refuses to create a goal for a run whose approval record is absent, in EVERY branch that would create one — not only the "no goal yet" branch, because a single unguarded branch is how the defect existed. ${cite(SRC.startModule)}</span></div></li>
|
|
1718
|
+
</ol>
|
|
1719
|
+
</section>
|
|
1720
|
+
|
|
1721
|
+
<section class="card card-wide">
|
|
1722
|
+
<h3>The two shape differences from every other gate</h3>
|
|
1723
|
+
<div class="rail-pair">
|
|
1724
|
+
<div class="subcard">
|
|
1725
|
+
<h3>relay is run-start only</h3>
|
|
1726
|
+
<p class="note" style="margin-top:var(--sp-2)">The other three gates never consult the channel, so accepting <code>relay</code> there would report a fallback that did not happen. Passing it is refused (RM1150). ${cite(SRC.askRelayOnly)}</p>
|
|
1727
|
+
</div>
|
|
1728
|
+
<div class="subcard">
|
|
1729
|
+
<h3>the artifact is fixed</h3>
|
|
1730
|
+
<p class="note" style="margin-top:var(--sp-2)">Every other gate keeps its per-gate default and its override; run-start is ALWAYS recorded in <code>00-requirements.md</code>, because letting a caller aim the approval elsewhere is how an approval ends up in a file no reader looks at. ${cite(SRC.askFixedArtifact)}</p>
|
|
1731
|
+
</div>
|
|
1732
|
+
</div>
|
|
1733
|
+
</section>
|
|
1734
|
+
</div>
|
|
1735
|
+
</section>
|
|
1736
|
+
|
|
1737
|
+
${PHASES.map(phaseDetail).join('\n')}
|
|
1738
|
+
|
|
1739
|
+
<!-- ===================== HOOKS ===================== -->
|
|
1740
|
+
<section class="panel" id="panel-hooks" role="tabpanel" aria-labelledby="tab-hooks" tabindex="0" hidden>
|
|
1741
|
+
<div class="panel-head">
|
|
1742
|
+
<h2>Hooks & seams — where this plugin attaches</h2>
|
|
1743
|
+
<p class="lede">Two layers: the DSH <b>seams</b> the plugin subscribes to, and the plugin's own named <b>hook points</b>, which map onto three of them. Only two hook bindings are registered, and both are GATING. ${cite(SRC.hookPoints)}</p>
|
|
1744
|
+
</div>
|
|
1745
|
+
|
|
1746
|
+
<h3>The five hook points</h3>
|
|
1747
|
+
<div class="tablewrap">
|
|
1748
|
+
<table>
|
|
1749
|
+
<caption>${cite(SRC.hookPoints + ', ' + SRC.hookGating)} — <code>HOOK_POINTS</code>, its mapping onto DSH seams, and which points may stop work.</caption>
|
|
1750
|
+
<thead><tr><th>Point</th><th>Maps onto</th><th>May stop?</th><th>Failure default</th><th>Registered bindings</th></tr></thead>
|
|
1751
|
+
<tbody>
|
|
1752
|
+
${HOOKS.map((h) => `<tr><td><code>${esc(h.point)}</code></td><td>${esc(h.maps)}</td><td>${h.gating ? '<b>GATING</b>' : 'observe only'}</td><td><code>${esc(h.policy)}</code></td><td>${h.registered.length ? h.registered.map((r) => esc(r)).join('<br>') : '<span style="color:var(--fg-faint)">none</span>'}</td></tr>`).join('')}
|
|
1753
|
+
</tbody>
|
|
1754
|
+
</table>
|
|
1755
|
+
</div>
|
|
1756
|
+
|
|
1757
|
+
<div class="callout" style="margin-top:var(--sp-5)">
|
|
1758
|
+
<strong>An observing point cannot deny, and the registry ENFORCES it.</strong> <code>post_generate</code> and <code>post_trigger</code> run after the model message has already streamed, so a deny there could not un-send it — a veto that vetoes nothing. A deny from an observing point is downgraded to <code>continue</code> and REPORTED as an annotation, so the attempt is visible rather than silently ignored or silently obeyed. ${cite(SRC.hookDowngrade)}
|
|
1759
|
+
</div>
|
|
1760
|
+
<div class="callout">
|
|
1761
|
+
<strong>Failure defaults differ by point, on purpose.</strong> A gating point that cannot decide must not proceed (<code>fail_closed</code>); an observing point that fails must not break the turn it was only watching (<code>fail_open</code>). Getting this backwards in either direction is the classic hook bug: one bricks the run, the other hides the failure. ${cite(SRC.hookFailure)}
|
|
1762
|
+
</div>
|
|
1763
|
+
|
|
1764
|
+
<h3 style="margin-top:var(--sp-6)">The DSH seams, with what each does</h3>
|
|
1765
|
+
<div class="tablewrap">
|
|
1766
|
+
<table>
|
|
1767
|
+
<caption>Every attachment point the plugin registers, and when it fires. ${cite(SRC.toolsReg + ', ' + SRC.preExecute)}</caption>
|
|
1768
|
+
<thead><tr><th>Seam</th><th>Fires</th><th>What it does</th><th>Source</th></tr></thead>
|
|
1769
|
+
<tbody>
|
|
1770
|
+
${SEAMS.map((s) => `<tr><td><code>${s.seam}</code></td><td>${esc(s.when)}</td><td>${esc(s.act)}</td><td>${cite(s.c)}</td></tr>`).join('')}
|
|
1771
|
+
</tbody>
|
|
1772
|
+
</table>
|
|
1773
|
+
</div>
|
|
1774
|
+
|
|
1775
|
+
<h3 style="margin-top:var(--sp-6)">What fires at a normal step</h3>
|
|
1776
|
+
<ol class="chain">
|
|
1777
|
+
<li><div><span class="ct">1 · <code>agent/pre-step</code></span><span class="cd">The scaffold is repaired once per root, then THIS phase's lint rules are injected — at most once per <code>(root, runId, phase)</code>, and only while the phase doc's status is <code>DRAFT</code>. The listener delegates first so later listeners keep veto power. ${cite(SRC.preStep + ', ' + SRC.reminderGate)}</span></div></li>
|
|
1778
|
+
<li><div><span class="ct">2 · the model asks for a tool</span><span class="cd"><code>tools/pre-execute</code> resolves the root from the session cwd and the active run id FROM THE FILESYSTEM, deliberately with no cache — a run created moments ago must be visible immediately, and a cached run id would silently reintroduce the empty-runId bug. ${cite(SRC.preExecute)}</span></div></li>
|
|
1779
|
+
<li><div><span class="ct">3 · the <code>pre_trigger</code> chain runs</span><span class="cd"><code>exit-plan-mode-gate</code> at priority 5 runs BEFORE <code>builtin-tool-guard</code> at priority 0, so a workflow-shaped refusal is the reason the caller sees rather than something the generic guard restates. ${cite(SRC.guardRegister + ', ' + SRC.planGate)}</span></div></li>
|
|
1780
|
+
<li><div><span class="ct">4 · the decision is logged and returned verbatim</span><span class="cd">EVERY decision is logged, allows included, so the rolling trace shows what the guard decided and why. The guard's own object is returned verbatim — that is what keeps the pinned guard contract byte-identical. ${cite(SRC.guardLog)}</span></div></li>
|
|
1781
|
+
<li><div><span class="ct">5 · <code>fs/observed</code></span><span class="cd">After a successful write, a lock tamper is recorded. Synchronous, observe-only, and contractually forbidden from throwing. ${cite(SRC.fsObserved)}</span></div></li>
|
|
1782
|
+
<li><div><span class="ct">6 · <code>session/event</code></span><span class="cd">A delegated child's settlement is captured at DELIVERY time, not by scanning session history — synchronous reads of arbitrary history are deprecated and lint-enforced. ${cite(SRC.sessionEvent + ', ' + SRC.settlementSeam)}</span></div></li>
|
|
1783
|
+
</ol>
|
|
1784
|
+
</section>
|
|
1785
|
+
|
|
1786
|
+
<!-- ===================== GUARDS ===================== -->
|
|
1787
|
+
<section class="panel" id="panel-guards" role="tabpanel" aria-labelledby="tab-guards" tabindex="0" hidden>
|
|
1788
|
+
<div class="panel-head">
|
|
1789
|
+
<h2>Guards & refusals — what says no, and where</h2>
|
|
1790
|
+
<p class="lede">A refusal happens at two layers, and both describe the same violation: the GUARD refuses pre-dispatch at <code>tools/pre-execute</code>, and the TOOL refuses again inside <code>lockArtifact</code>. ${cite(SRC.blockGoal)}</p>
|
|
1791
|
+
</div>
|
|
1792
|
+
|
|
1793
|
+
<h3>The built-in rule list</h3>
|
|
1794
|
+
<div class="tablewrap">
|
|
1795
|
+
<table>
|
|
1796
|
+
<caption>${cite(SRC.builtInRules)} — built in this order. Every <code>deny</code> precedes the catch-all <code>allow</code>, which is what makes "deny wins over allow" a fact about the list rather than a hope. Predicates ABSTAIN (<code>null</code>) when their condition does not apply, so a matched-but-clean write falls through to the allow.</caption>
|
|
1797
|
+
<thead><tr><th>Rule</th><th>Reason (verbatim)</th><th>Tool pattern</th><th>Mode behaviour</th><th>Source</th></tr></thead>
|
|
1798
|
+
<tbody>
|
|
1799
|
+
${GUARDS.map((g) => `<tr><td><code>${esc(g.k)}</code></td><td>${esc(g.what)}</td><td><code>${esc(g.on)}</code></td><td>${esc(g.mode)}${g.modeC ? cite(g.modeC) : ''}</td><td>${cite(g.c)}</td></tr>`).join('')}
|
|
1800
|
+
</tbody>
|
|
1801
|
+
</table>
|
|
1802
|
+
</div>
|
|
1803
|
+
|
|
1804
|
+
<h3 style="margin-top:var(--sp-6)">Narrowing is mechanical, not a convention</h3>
|
|
1805
|
+
<p class="note">A phase baseline rule whose verdict is ranked ABOVE the strictest global verdict for the same pattern is DROPPED before it can be evaluated — so no per-phase baseline can turn a global <code>deny</code> into an <code>allow</code>. The rank order is <code>deny < ask < allow</code>, lower being stricter, and that ordering IS the narrowing rule. No baseline rule uses the bare catch-all <code>*</code>, because such a rule would outrank every specific global rule at once — including <code>recursive_lock*</code> — and deny the phase its own tools. ${cite(SRC.verdictRank + ', ' + SRC.narrowing)}</p>
|
|
1806
|
+
|
|
1807
|
+
<h3 style="margin-top:var(--sp-6)">The phase-order rule's four outcomes</h3>
|
|
1808
|
+
<div class="tablewrap">
|
|
1809
|
+
<table>
|
|
1810
|
+
<caption>${cite(SRC.phaseOrderRule)} — scope is a DIRECT CHILD of the active run directory, which is what keeps the rule off support files: <code>evidence/</code>, <code>scratch/</code>, <code>addenda/</code>, <code>subagents/</code> and a plain <code><run>/notes.md</code> are not phases.</caption>
|
|
1811
|
+
<thead><tr><th>Target relative to the active phase</th><th>Verdict</th><th>Why</th></tr></thead>
|
|
1812
|
+
<tbody>
|
|
1813
|
+
<tr><td>the ACTIVE artifact, or one sharing its phase number</td><td><span class="tag tag-tdd">ABSTAIN → allowed</span></td><td>The active artifact stays writable at every phase. This allow half is load-bearing: an enforcement rule in this area was once the bug, denying the run's OWN artifacts in every phase. ${cite(SRC.phaseOrderRule + ', ' + SRC.phaseOrderAllow)}</td></tr>
|
|
1814
|
+
<tr><td>an EARLIER phase</td><td><span class="tag tag-tdd">ABSTAIN</span></td><td>Such an artifact is LOCKED by construction, so the locked-write rule above decides it and its label is preserved. ${cite(SRC.phaseOrderRule)}</td></tr>
|
|
1815
|
+
<tr><td>a LATER phase</td><td><span class="tag tag-refuse">DENY</span></td><td>Working ahead. The hole this closed: ordering enforced on <code>recursive_lock</code> ONLY let an agent write <code>08-memory-impact.md</code> while the run sat at phase 0 — and a live run did exactly that, twelve artifacts written out of order with not one locked. ${cite(SRC.phaseOrderRule + ', ' + SRC.phaseOrderHole)}</td></tr>
|
|
1816
|
+
<tr><td>no active phase / another run / a support file / a non-<code>.md</code></td><td><span class="tag">ABSTAIN</span></td><td>Never guess a phase; another run's tree is a different question. ${cite(SRC.phaseOrderRule)}</td></tr>
|
|
1817
|
+
</tbody>
|
|
1818
|
+
</table>
|
|
1819
|
+
</div>
|
|
1820
|
+
|
|
1821
|
+
<h3 style="margin-top:var(--sp-6)">The memory-read rule — the narrowest of the three</h3>
|
|
1822
|
+
<p class="note">Three rules carry the SAME write-tool patterns, and the engine decides equally specific rules by FILE ORDER: <code>locked-write</code>, then <code>phase-order</code>, then <code>memory-read</code>. That order is the precedence, and it is why an out-of-order write is never reported as a memory refusal. ${cite(SRC.builtInRules)}</p>
|
|
1823
|
+
<div class="tablewrap">
|
|
1824
|
+
<table>
|
|
1825
|
+
<caption>${cite(SRC.memoryReadRule)} — a write to a PHASE-0 artifact is denied while the run holds no memory read receipt for it. Every other target abstains.</caption>
|
|
1826
|
+
<thead><tr><th>Target</th><th>Verdict</th><th>Why</th></tr></thead>
|
|
1827
|
+
<tbody>
|
|
1828
|
+
<tr><td><code>00-requirements.md</code> / <code>00-worktree.md</code>, unlocked, with NO read receipt</td><td><span class="tag tag-refuse">DENY</span></td><td>The first write to a phase-0 artifact is gated on memory having been read. The gate is decided from the RECEIPT, never from the artifact text — a caller controls the text and could forge it.</td></tr>
|
|
1829
|
+
<tr><td>the same, when an empty memory plane matched nothing</td><td><span class="tag tag-tdd">ABSTAIN</span></td><td><code>recordMemoryRead</code> writes a receipt on every phase entry with <code>injected: false</code> when nothing matched, so an EMPTY PLANE SATISFIES the gate while a never-entered phase does not — and the two cannot be confused, because one of them has a row.</td></tr>
|
|
1830
|
+
<tr><td>a target of the same phase number greater than 0</td><td><span class="tag">ABSTAIN</span></td><td>Only phase 0 opens a run.</td></tr>
|
|
1831
|
+
<tr><td>a LATER phase's artifact / a LOCKED target / not a direct child of the run</td><td><span class="tag">ABSTAIN</span></td><td>The other rules own those cases, and a completed run is not re-gated. Keeping the gate off the general write path is asserted by a spec that walks the run's support files and shows they stay writable.</td></tr>
|
|
1832
|
+
</tbody>
|
|
1833
|
+
</table>
|
|
1834
|
+
</div>
|
|
1835
|
+
|
|
1836
|
+
<h3 style="margin-top:var(--sp-6)">Every numbered code</h3>
|
|
1837
|
+
<div class="tablewrap">
|
|
1838
|
+
<table>
|
|
1839
|
+
<caption>${cite(SRC.errClasses + ', ' + SRC.errRegistry)} — <code>RM<group><serial></code>, where the THIRD character is the class group: 1 input, 2 value, 3 workspace, 4 state, 5 runtime, 6 capability. A test asserts the third character matches the entry's own class, so the grouping cannot drift. Every error renders as ONE sentence: <code><code> <class>: <problem> - <detail>. Next: <the exact call that resolves it>.</code></caption>
|
|
1840
|
+
<thead><tr><th>Code</th><th>Class</th><th>Problem</th><th>Belongs to</th></tr></thead>
|
|
1841
|
+
<tbody>
|
|
1842
|
+
${ERRORS.map((e) => `<tr><td><code>${esc(e.code)}</code></td><td class="k-${esc(e.k)}">${esc(e.k)}</td><td>${esc(e.p)}</td><td>${esc(e.where)}</td></tr>`).join('')}
|
|
1843
|
+
</tbody>
|
|
1844
|
+
</table>
|
|
1845
|
+
</div>
|
|
1846
|
+
</section>
|
|
1847
|
+
|
|
1848
|
+
<!-- ===================== LOOPS ===================== -->
|
|
1849
|
+
<section class="panel" id="panel-loops" role="tabpanel" aria-labelledby="tab-loops" tabindex="0" hidden>
|
|
1850
|
+
<div class="panel-head">
|
|
1851
|
+
<h2>The backward loop — seven places the flow is not a straight line</h2>
|
|
1852
|
+
<p class="lede">This is a real property of the workflow, not a caveat. A review that comes back REVISE sends work BACK; a reopen moves the run backwards in the sequence; and the phase dependency is modelled as a GRAPH precisely because an artifact can depend on something LATER than itself. ${cite(SRC.graphWhy)}</p>
|
|
1853
|
+
</div>
|
|
1854
|
+
|
|
1855
|
+
${LOOPS.map((l) => `
|
|
1856
|
+
<article class="loop" id="loop-${esc(l.n)}">
|
|
1857
|
+
<h3><span class="lid">${esc(l.n)}</span>${esc(l.title)}</h3>
|
|
1858
|
+
<p>${esc(l.what)}</p>
|
|
1859
|
+
<p>${cite(l.c)}</p>
|
|
1860
|
+
</article>`).join('')}
|
|
1861
|
+
|
|
1862
|
+
<h3 style="margin-top:var(--sp-6)">Why the graph model exists at all</h3>
|
|
1863
|
+
<p class="note">Three queries in the plugin are graph operations being run over a linear array: <code>getStaleDownstreamPhases</code> is a REACHABILITY query, <code>getPrerequisites</code> is an IN-EDGE query, and <code>getNextLegalPhase</code> is a topological walk. An array can answer all three only while the dependency happens to be linear — and the moment an artifact depends on something LATER than itself, the array model answers <em>wrongly</em> rather than not at all. ${cite(SRC.graphWhy)}</p>
|
|
1864
|
+
<p class="note">It is a hand-rolled graph deliberately: the item's own note records that Effect ships a stable, runtime-free <code>Graph</code> doing exactly this, and adopting it would trade the plugin's zero-dependency posture and its parity goldens for a few hundred lines it does not need. ${cite(SRC.graphWhy)}</p>
|
|
1865
|
+
<p class="note"><span class="unv">⚠ Not verified here:</span> whether a back-edge occurs in practice depends on an addendum being filed with a forward citation. The code supports it and reports it; this page does not claim any particular run produced one.</p>
|
|
1866
|
+
</section>
|
|
1867
|
+
|
|
1868
|
+
<!-- ===================== CLOSEOUT ===================== -->
|
|
1869
|
+
<section class="panel" id="panel-closeout" role="tabpanel" aria-labelledby="tab-closeout" tabindex="0" hidden>
|
|
1870
|
+
<div class="panel-head">
|
|
1871
|
+
<h2>Closeout & receipts</h2>
|
|
1872
|
+
<p class="lede">Two things share the word "closeout" in this repo and they behave differently: a <b>scaffolder</b> that writes stub receipts, and a <b>linter</b> that only reports. The shipped tool is the linter. ${cite(SRC.closeoutReport)}</p>
|
|
1873
|
+
</div>
|
|
1874
|
+
|
|
1875
|
+
<div class="callout">
|
|
1876
|
+
<strong>The design, in the owner's words.</strong> <em>"closeout.ts is supposed to verify whether the phase artifact docs contain the scaffold information or not, then tell the agent to add it if not. It is not supposed to just insert section titles into the docs. It is like a linter checking if the agent missed anything. It is not supposed to edit files by itself."</em> ${cite(SRC.closeoutReport)}
|
|
1877
|
+
</div>
|
|
1878
|
+
|
|
1879
|
+
<div class="rail-pair">
|
|
1880
|
+
<div class="subcard">
|
|
1881
|
+
<h3>closeout-report.ts — REPORT ONLY</h3>
|
|
1882
|
+
<p class="note" style="margin-top:var(--sp-2)">Opens no file for writing. The previous implementation composed a stub receipt over the phase artifact, and a probe showed what that costs: against a DRAFT <code>05-manual-qa.md</code> it RETURNED SUCCESS while the agent's real QA content was GONE. A closeout runs BEFORE a phase locks, so that was the ordinary case, not an edge. ${cite(SRC.closeoutReport)}</p>
|
|
1883
|
+
<p class="note">It reports three finding kinds — <code>missing-artifact</code>, <code>missing-section</code>, <code>gate-not-passing</code> — plus the advisory prerequisite list, which is NOT a refusal. ${cite(SRC.closeoutReport)}</p>
|
|
1884
|
+
</div>
|
|
1885
|
+
<div class="subcard" style="border-left:2px solid var(--danger)">
|
|
1886
|
+
<h3>closeout.ts — the scaffolder, and its two refusals</h3>
|
|
1887
|
+
<p class="note" style="margin-top:var(--sp-2)">Refuses when an earlier artifact is not LOCKED (<code>Prerequisite blockers: …</code>), and — since FU-8 — refuses when the artifact it would write is ALREADY LOCKED. That second one was a measured defect: a closeout after the phase had locked rewrote the file to <code>Status: DRAFT</code> while its RECEIPT still said LOCKED, leaving a run whose file and receipt disagree. It refuses rather than skipping quietly, and names the remedy, because a silent skip would look like a successful closeout that changed nothing. ${cite(SRC.closeoutPrereq + ', ' + SRC.closeoutLocked)}</p>
|
|
1888
|
+
</div>
|
|
1889
|
+
</div>
|
|
1890
|
+
|
|
1891
|
+
<h3 style="margin-top:var(--sp-6)">The receipt, and what verifying the chain checks</h3>
|
|
1892
|
+
<p class="note">A receipt records <code>artifact</code>, <code>artifact_path</code>, <code>artifact_hash</code>, <code>locked_at</code>, <code>prerequisite_hashes</code>, <code>previous_receipt_hash</code> and <code>receipt_hash</code>, at <code><run>/locks/<stem>.receipt.json</code>. ${cite(SRC.receipts)}</p>
|
|
1893
|
+
<p class="note">The verification is READ-ONLY deliberately — the chain is evidence, and a verification that rewrites what it verifies is worthless. Four things are checked, and one thing is explicitly NOT claimed: <code>previous_receipt_hash</code> chains to the SAME artifact's previous receipt, and there is exactly one receipt file per artifact, so the receipt it names has been overwritten and its LINKAGE cannot be verified from disk. Rather than assert a linkage it cannot prove, it checks receipt integrity, prerequisite agreement, gaps, and well-formedness of the previous-hash field. ${cite(SRC.validateChain)}</p>
|
|
1894
|
+
<div class="tablewrap" style="margin-top:var(--sp-4)">
|
|
1895
|
+
<table>
|
|
1896
|
+
<caption>${cite(SRC.chainBreaks)} — the four break kinds a chain verification can report.</caption>
|
|
1897
|
+
<thead><tr><th>Break kind</th><th>What it means</th></tr></thead>
|
|
1898
|
+
<tbody>
|
|
1899
|
+
<tr><td><code>receipt-hash-mismatch</code></td><td>The stored <code>receipt_hash</code> does not match the hash recomputed from the receipt's own fields. The detail goes further and separates the two causes a reader cares about: the ARTIFACT was edited after locking, or the RECEIPT was edited after writing. ${cite(SRC.validateChain)}</td></tr>
|
|
1900
|
+
<tr><td><code>prerequisite-hash-mismatch</code></td><td>An upstream artifact changed after this one locked — the phase is stale. ${cite(SRC.validateChain)}</td></tr>
|
|
1901
|
+
<tr><td><code>missing-prerequisite-receipt</code></td><td>This receipt cites a prerequisite that has no receipt at all. ${cite(SRC.validateChain)}</td></tr>
|
|
1902
|
+
<tr><td><code>malformed-previous-hash</code></td><td>The field is not a sha256 hex digest or null — checked even when the integrity check already failed, because a tampered field is exactly when a malformed one is worth naming too. ${cite(SRC.validateChain)}</td></tr>
|
|
1903
|
+
</tbody>
|
|
1904
|
+
</table>
|
|
1905
|
+
</div>
|
|
1906
|
+
|
|
1907
|
+
<h3 style="margin-top:var(--sp-6)">The lock-status vocabulary</h3>
|
|
1908
|
+
<div class="tablewrap">
|
|
1909
|
+
<table>
|
|
1910
|
+
<caption>${cite(SRC.lockStatus)} — the classifier every other guard reads.</caption>
|
|
1911
|
+
<thead><tr><th>Status</th><th>Condition</th></tr></thead>
|
|
1912
|
+
<tbody>
|
|
1913
|
+
<tr><td><code>MISSING</code></td><td>No file, or the file cannot be read.</td></tr>
|
|
1914
|
+
<tr><td><code>DRAFT</code></td><td>The file exists but carries no <code>Status: LOCKED</code> line.</td></tr>
|
|
1915
|
+
<tr><td><code>STALE_LOCK</code></td><td>LOCKED, but <code>LockHash</code> or <code>LockedAt</code> is missing, or the stored hash does not equal the hash recomputed from the content.</td></tr>
|
|
1916
|
+
<tr><td><code>LOCKED</code></td><td>LOCKED and the stored hash matches the recomputed one.</td></tr>
|
|
1917
|
+
</tbody>
|
|
1918
|
+
</table>
|
|
1919
|
+
</div>
|
|
1920
|
+
<p class="note">The hash is SHA-256 hex over UTF-8 bytes of the content LF-normalized with every <code>LockHash:</code> line STRIPPED — which is what lets the lock fields be written without invalidating the hash they carry. ${cite(SRC.lockHash)}</p>
|
|
1921
|
+
</section>
|
|
1922
|
+
|
|
1923
|
+
<!-- ===================== TOOLS ===================== -->
|
|
1924
|
+
<section class="panel" id="panel-tools" role="tabpanel" aria-labelledby="tab-tools" tabindex="0" hidden>
|
|
1925
|
+
<div class="panel-head">
|
|
1926
|
+
<h2>The thirteen tools</h2>
|
|
1927
|
+
<p class="lede">Thirteen tool FILES ship; twelve are registered eagerly. <code>recursive_audit_team</code> is registered only when the composition mounts <code>ctx.agentTeams</code> — and it needed a late-attach path, because a live verification pass found the service mounted AFTER the plugin applied, so the plugin shipped 13 tool files and offered 12. ${cite(SRC.auditTeamReg)}</p>
|
|
1928
|
+
</div>
|
|
1929
|
+
<div class="tablewrap">
|
|
1930
|
+
<table>
|
|
1931
|
+
<caption>Name and declared purpose, read from the tool definitions. ${cite(SRC.toolsReg)}</caption>
|
|
1932
|
+
<thead><tr><th>#</th><th>Tool</th><th>Declared purpose</th><th>Source</th></tr></thead>
|
|
1933
|
+
<tbody>
|
|
1934
|
+
${TOOLS.map((t, i) => `<tr><td><span class="seq">${i + 1}</span></td><td><code>${esc(t.n)}</code>${t.reg === 'conditional' ? '<br><span class="tag tag-optional">conditional</span>' : ''}</td><td>${esc(t.p)}${t.note ? `<br><span class="note" style="display:block;margin-top:var(--sp-2)">${t.note}</span>` : ''}</td><td>${cite(t.c)}</td></tr>`).join('')}
|
|
1935
|
+
</tbody>
|
|
1936
|
+
</table>
|
|
1937
|
+
</div>
|
|
1938
|
+
</section>
|
|
1939
|
+
|
|
1940
|
+
<!-- ===================== CODES ===================== -->
|
|
1941
|
+
<section class="panel" id="panel-codes" role="tabpanel" aria-labelledby="tab-codes" tabindex="0" hidden>
|
|
1942
|
+
<div class="panel-head">
|
|
1943
|
+
<h2>Error codes</h2>
|
|
1944
|
+
<p class="lede">All ${ERRORS.length} entries of the <code>TOOL_ERRORS</code> registry. Every <code>recursive_*</code> tool error must come from here: a tool that invents its own sentence is a tool whose refusals cannot be grepped. ${cite(SRC.errRegistry)}</p>
|
|
1945
|
+
</div>
|
|
1946
|
+
<div class="tablewrap">
|
|
1947
|
+
<table>
|
|
1948
|
+
<caption>${cite(SRC.errRegistry)} — the third character is the class group, and a test asserts it matches the entry's own class.</caption>
|
|
1949
|
+
<thead><tr><th>Code</th><th>Class</th><th>Problem</th><th>Belongs to</th></tr></thead>
|
|
1950
|
+
<tbody>
|
|
1951
|
+
${ERRORS.map((e) => `<tr><td><code>${esc(e.code)}</code></td><td class="k-${esc(e.k)}">${esc(e.k)}</td><td>${esc(e.p)}</td><td>${esc(e.where)}</td></tr>`).join('')}
|
|
1952
|
+
</tbody>
|
|
1953
|
+
</table>
|
|
1954
|
+
</div>
|
|
1955
|
+
<p class="note" style="margin-top:var(--sp-4)">Two codes deserver a second look because they encode a defect rather than a syntax error: <code>RM4404</code> exists because <code>recursive_ask gate=run-start</code> used to raise "start this run or hold?" over a Phase 0 document that was still the scaffold <code>recursive_init</code> wrote, and nothing put that document in front of the person either; and <code>RM5503</code> used to LIE — its text asserted "so no person was asked" for EVERY cause, so a gate that failed 22.9 s into a call prescribed, in its <code>Next:</code> clause, the very call that had just failed. ${cite(SRC.errSpecUnfilled + ', ' + SRC.errUnanswered)}</p>
|
|
1956
|
+
</section>
|
|
1957
|
+
|
|
1958
|
+
<!-- ===================== VERIFICATION ===================== -->
|
|
1959
|
+
<section class="panel" id="panel-verify" role="tabpanel" aria-labelledby="tab-verify" tabindex="0" hidden>
|
|
1960
|
+
<div class="panel-head">
|
|
1961
|
+
<h2>Verification — what is checked, and what is not</h2>
|
|
1962
|
+
<p class="lede">This page is generated by <code>scripts/gen-workflow-map.mjs</code>. Run it with <code>--verify</code> and it re-reads <code>src/</code> and re-derives every claim-bearing list below, failing loudly on a mismatch instead of shipping a plausible diagram.</p>
|
|
1963
|
+
</div>
|
|
1964
|
+
|
|
1965
|
+
<h3>Checked mechanically against <code>src/</code></h3>
|
|
1966
|
+
<div class="tablewrap">
|
|
1967
|
+
<table>
|
|
1968
|
+
<caption>Each check reads the source file and compares a derived value to the fact rendered on this page.</caption>
|
|
1969
|
+
<thead><tr><th>Check</th><th>Derived from</th></tr></thead>
|
|
1970
|
+
<tbody>
|
|
1971
|
+
<tr><td>The twelve artifact names, IN ORDER</td><td><code>src/lock.ts</code> — <code>PHASE_SEQUENCE</code></td></tr>
|
|
1972
|
+
<tr><td>The late-phase set (3 members, same order)</td><td><code>src/phase-rules.ts</code> — <code>LATE_PHASE_ARTIFACTS</code></td></tr>
|
|
1973
|
+
<tr><td>The audited-phase set (9 members)</td><td><code>src/phase-rules.ts</code> — <code>AUDITED_PHASE_FILES</code></td></tr>
|
|
1974
|
+
<tr><td>The optional-phase set (7 members)</td><td><code>src/lock.ts</code> — <code>OPTIONAL_PHASES</code></td></tr>
|
|
1975
|
+
<tr><td>Every phase's required sections, in order</td><td><code>src/phase-rules.ts</code> — <code>SECTION_MAP</code></td></tr>
|
|
1976
|
+
<tr><td>The audit headings (9) and the input-artifact map</td><td><code>src/phase-rules.ts</code> — <code>AUDIT_REQUIRED_HEADINGS</code>; <code>src/ts-lint.ts</code> — <code>getPhaseExpectedInputArtifactNames</code></td></tr>
|
|
1977
|
+
<tr><td>The thirteen tool names</td><td><code>src/recursive_*.tool.ts</code> — the registered <code>name:</code> literals</td></tr>
|
|
1978
|
+
<tr><td>Every RM#### code and its class group</td><td><code>src/errors.ts</code> — <code>TOOL_ERRORS</code></td></tr>
|
|
1979
|
+
<tr><td>The guard rule labels, <b>in built-in rule order</b>, and the reason strings</td><td><code>src/policy-globs.ts</code> — <code>builtInToolPolicyRules</code>; <code>src/phase-rules.ts</code> — <code>phaseBaselineRules</code></td></tr>
|
|
1980
|
+
<tr><td>The five hook points and their gating/observing split</td><td><code>src/hooks.ts</code> — <code>HOOK_POINTS</code>, <code>GATING_POINTS</code>, <code>OBSERVING_POINTS</code></td></tr>
|
|
1981
|
+
<tr><td>Both <code>pre_trigger</code> bindings and their priorities; the guard's own hook-name constant</td><td><code>src/index.ts</code> — the two <code>hooks.register</code> calls and <code>BUILTIN_GUARD_HOOK_NAME</code></td></tr>
|
|
1982
|
+
<tr><td>The DSH seam event strings</td><td><code>src/index.ts</code> — the <code>.on('…')</code> call sites and <code>ctx.tools.register</code></td></tr>
|
|
1983
|
+
<tr><td>The run-start gate id, both labels, and that <code>relay</code> is refused for every other gate</td><td><code>src/run-start.ts</code>, <code>src/recursive_ask.tool.ts</code></td></tr>
|
|
1984
|
+
<tr><td>Every human-gate id and its option labels</td><td><code>src/recursive_ask.tool.ts</code> — <code>ASK_GATES</code></td></tr>
|
|
1985
|
+
<tr><td>Every conditional input ("only when present") is the one the linter actually pushes</td><td><code>src/ts-lint.ts</code> — <code>candidates.push(…)</code> inside <code>getPhaseExpectedInputArtifactNames</code></td></tr>
|
|
1986
|
+
<tr><td><b>Citation coverage.</b> A citation cannot be satisfied by coincidence: each one is resolved from a NAMED ANCHOR searched in its own file, an anchor that is missing, ambiguous, or moved is a hard failure, an undeclared duplicate anchor fails rather than silently citing the first hit, and every rendered citation is re-checked to be inside the file it names and within that file's length.</td><td>the generator's <code>ANCHORS</code> table against <code>src/</code></td></tr>
|
|
1987
|
+
<tr><td>Each phase panel carries at least 8 citations, and the page renders a substantial number in total</td><td>the generated HTML</td></tr>
|
|
1988
|
+
<tr><td><b>Every WCAG contrast pair is MEASURED</b>, not asserted: ${CONTRAST_REQUIREMENTS.length} foreground/background pairs read from the palette object that also renders the CSS, each against the threshold it owes (4.5:1 for text, 3:1 for non-text state borders). Purely decorative presentation is listed too, with the exemption STATED — an exemption that is written down can be argued with; a silent one cannot.</td><td>the palette object in the generator</td></tr>
|
|
1989
|
+
<tr><td>No hardcoded hex anywhere in the stylesheet — every colour is a declared token value, so the audited palette IS the shipped palette</td><td>the generated HTML</td></tr>
|
|
1990
|
+
<tr><td>The radius ladder is strictly increasing (outer > inner), every radius token used is declared and every declared one is used, and no dangling token remains</td><td>the generated HTML</td></tr>
|
|
1991
|
+
<tr><td>Accessibility affordances the accessibility skill requires: a <code>:focus-visible</code> style, a skip link, <code>prefers-reduced-motion</code>, ≥44px tab hit areas, <code>text-wrap</code>, tabular numerals, font smoothing, exact transition properties, no <code>transition: all</code> declaration, and a text label on every gate so colour is never the only signal</td><td>the generated HTML</td></tr>
|
|
1992
|
+
<tr><td>The tab strip scrolls in ONE row and does not wrap (checked against the parsed <code>[role="tablist"]</code> rule, not against a substring anywhere in the sheet)</td><td>the generated HTML</td></tr>
|
|
1993
|
+
<tr><td>Every <code>id</code> a tab's <code>aria-controls</code> names exists, every panel is a <code>tabpanel</code> labelled by its own tab, every panel is addressed by exactly one tab, and the roving tabindex is complete (one <code>0</code>, the rest <code>-1</code>, one <code>aria-selected="true"</code>)</td><td>the generated HTML itself</td></tr>
|
|
1994
|
+
<tr><td>The tab count equals 1 overview + 12 phases + 8 other views</td><td>the generated HTML itself</td></tr>
|
|
1995
|
+
</tbody>
|
|
1996
|
+
</table>
|
|
1997
|
+
</div>
|
|
1998
|
+
<h3 style="margin-top:var(--sp-6)">The measured contrast audit</h3>
|
|
1999
|
+
<p class="note">Rendered from the same computation <code>--verify</code> runs, so the numbers on this page ARE the audit rather than a summary of it. "Exempt" is a stated decision, not a silent omission: WCAG sets no threshold for purely decorative presentation, and every one of those rows is a region fill, a divider, or a rule border whose meaning is carried by a label beside it.</p>
|
|
2000
|
+
<div class="tablewrap">
|
|
2001
|
+
<table>
|
|
2002
|
+
<caption>${CONTRAST_REQUIREMENTS.length} foreground/background pairs. Text owes 4.5:1 (WCAG 2.2 AA, 1.4.3); a border that carries STATE owes 3:1 (1.4.11).</caption>
|
|
2003
|
+
<thead><tr><th>Pair</th><th>Ratio</th><th>Needed</th><th>What it is</th></tr></thead>
|
|
2004
|
+
<tbody>
|
|
2005
|
+
${CONTRAST_REQUIREMENTS.map((r) => {
|
|
2006
|
+
const ratio = Math.round(contrast(tone(r.fg), tone(r.bg)) * 100) / 100
|
|
2007
|
+
const pass = ratio >= r.need
|
|
2008
|
+
const verdict = r.need <= 1 ? '<span class="tag">exempt</span>' : pass ? '<span class="tag tag-tdd">PASS</span>' : '<span class="tag tag-refuse">FAIL</span>'
|
|
2009
|
+
return ` <tr><td><code>${esc(r.fg)}</code> on <code>${esc(r.bg)}</code></td><td class="tnum">${ratio.toFixed(2)}:1 ${verdict}</td><td class="tnum">${r.need.toFixed(1)}:1</td><td>${esc(r.what)}</td></tr>`
|
|
2010
|
+
}).join('\n')}
|
|
2011
|
+
</tbody>
|
|
2012
|
+
</table>
|
|
2013
|
+
</div>
|
|
2014
|
+
<p class="note">Run <code>node scripts/gen-workflow-map.mjs --verify</code> to see every check reported one per line, with this table printed above them. A single failure exits non-zero and writes NOTHING, so this page cannot ship in a state where a fact on it has stopped being true.</p>
|
|
2015
|
+
|
|
2016
|
+
<h3 style="margin-top:var(--sp-6)">NOT verified — stated rather than filled in</h3>
|
|
2017
|
+
<ul class="refuse-list">
|
|
2018
|
+
<li><span class="g-shape g-shape-refuse">UNVERIFIED</span><span><b>The rendered pixels.</b> No browser was available to this generator, so the visual result — the contrast as actually rendered, wrapping at 700 px, sticky-tab behaviour, the appearance of the focus ring, whether the diagram's labels collide at some width — is NOT confirmed. The CSS is written to stated rules and its colours are measured, but it has not been SEEN. Treat every layout claim on this page as a design intent, not an observation.</span></li>
|
|
2019
|
+
<li><span class="g-shape g-shape-refuse">UNVERIFIED</span><span><b>Keyboard and screen-reader behaviour.</b> The tablist's roles, <code>aria-selected</code>, roving tabindex and <code>aria-controls</code> targets are asserted against the markup, and the arrow-key handler is present in the source — but no browser and no assistive technology ran this page, so "the arrow keys work" and "the tab order reads correctly" are NOT demonstrated here.</span></li>
|
|
2020
|
+
<li><span class="g-shape g-shape-refuse">UNVERIFIED</span><span><b>Per-phase "inputs" beyond the artifact list.</b> The scaffold writes a generic <code>- (list upstream artifacts re-read for this phase)</code> placeholder for every later phase, so there is no per-phase PROSE list of inputs in the code to transcribe. What IS in the code is <code>getPhaseExpectedInputArtifactNames</code>, and that is what the input column shows — plus the file-level sources a phase names (git state, DECISIONS.md, STATE.md, the memory plane). ${cite(SRC.tplLater + ', ' + SRC.inputMap)}</span></li>
|
|
2021
|
+
<li><span class="g-shape g-shape-refuse">UNVERIFIED</span><span><b>Which hooks fire in a given real session.</b> The page states which seam each listener attaches to and what it does; it does not claim a particular live run took a particular path, and it makes no claim about run outcomes.</span></li>
|
|
2022
|
+
<li><span class="g-shape g-shape-refuse">UNVERIFIED</span><span><b>Whether any addendum back-edge exists in the current workspace.</b> <code>backEdges()</code> can report one; nothing on this page asserts that one has occurred.</span></li>
|
|
2023
|
+
<li><span class="g-shape g-shape-refuse">UNVERIFIED</span><span><b>The source tree was MOVING while this page was generated.</b> <code>src/policy-globs.ts</code>, <code>src/runtime.ts</code> and <code>src/recursive_phase.tool.ts</code> were being edited by another agent at the time. This is exactly why citations are resolved from anchors at generate time rather than typed in: an earlier draft of this page carried line numbers that the other agent's edit INVALIDATED, and they would have shipped wrong. Every citation here was resolved from the current file contents. If the tree moves again, run the generator again — <code>--verify</code> will fail loudly on any anchor that no longer resolves.</span></li>
|
|
2024
|
+
<li><span class="g-shape g-shape-refuse">UNVERIFIED</span><span><b>Anything about the plugin's runtime behaviour.</b> This page is a map of the CODE. It reports what the sources say the workflow does; it does not demonstrate that a live run follows it. Nothing here is evidence about a run.</span></li>
|
|
2025
|
+
</ul>
|
|
2026
|
+
|
|
2027
|
+
<h3 style="margin-top:var(--sp-6)">Regenerating</h3>
|
|
2028
|
+
<pre class="pre">node scripts/gen-workflow-map.mjs # write workflow-map/recursive-mode-workflow.html
|
|
2029
|
+
node scripts/gen-workflow-map.mjs --verify # re-derive the facts from src/, write nothing
|
|
2030
|
+
node scripts/gen-workflow-map.mjs --out elsewhere.html</pre>
|
|
2031
|
+
</section>
|
|
2032
|
+
|
|
2033
|
+
</main>
|
|
2034
|
+
|
|
2035
|
+
<footer class="colophon">
|
|
2036
|
+
<p><b>Provenance.</b> Generated from the <code>${esc(REPO)}</code> working tree. Every <code>file:line</code> caption on this page names the source it was transcribed from; the paths are relative to the repository root. No claim on this page was invented to fill a gap — where the code does not say something, the page says so in the Verification view.</p>
|
|
2037
|
+
<p><b>Scope.</b> Read-only with respect to <code>src/</code> and <code>tests/</code>. The generator writes exactly one file.</p>
|
|
2038
|
+
<p><b>Self-contained.</b> One HTML file: inline CSS, inline JS, no CDN, no external font, no build step. The type is a system monospace stack. No animation is used; a <code>prefers-reduced-motion</code> rule is present for the two colour transitions that do exist.</p>
|
|
2039
|
+
</footer>
|
|
2040
|
+
</div>
|
|
2041
|
+
|
|
2042
|
+
<script>
|
|
2043
|
+
(function () {
|
|
2044
|
+
'use strict';
|
|
2045
|
+
var root = document.documentElement;
|
|
2046
|
+
root.classList.add('js');
|
|
2047
|
+
|
|
2048
|
+
var tabs = Array.prototype.slice.call(document.querySelectorAll('[role="tab"]'));
|
|
2049
|
+
var panels = tabs.map(function (t) { return document.getElementById(t.getAttribute('aria-controls')); });
|
|
2050
|
+
if (tabs.length === 0) return;
|
|
2051
|
+
|
|
2052
|
+
function select(index, focus) {
|
|
2053
|
+
if (index < 0) index = tabs.length - 1;
|
|
2054
|
+
if (index >= tabs.length) index = 0;
|
|
2055
|
+
tabs.forEach(function (tab, i) {
|
|
2056
|
+
var on = i === index;
|
|
2057
|
+
tab.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
2058
|
+
tab.tabIndex = on ? 0 : -1;
|
|
2059
|
+
var panel = panels[i];
|
|
2060
|
+
if (!panel) return;
|
|
2061
|
+
panel.hidden = !on;
|
|
2062
|
+
panel.classList.toggle('is-active', on);
|
|
2063
|
+
});
|
|
2064
|
+
if (focus) tabs[index].focus();
|
|
2065
|
+
// Keep the deep link in step with the view, so a tab is shareable and the
|
|
2066
|
+
// back button works, without a router or a dependency.
|
|
2067
|
+
var id = tabs[index].id.replace(/^tab-/, '');
|
|
2068
|
+
if (history.replaceState) history.replaceState(null, '', '#' + id);
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
tabs.forEach(function (tab, i) {
|
|
2072
|
+
tab.addEventListener('click', function () { select(i, false); });
|
|
2073
|
+
tab.addEventListener('keydown', function (event) {
|
|
2074
|
+
var k = event.key;
|
|
2075
|
+
if (k === 'ArrowRight' || k === 'ArrowDown') { event.preventDefault(); select(i + 1, true); }
|
|
2076
|
+
else if (k === 'ArrowLeft' || k === 'ArrowUp') { event.preventDefault(); select(i - 1, true); }
|
|
2077
|
+
else if (k === 'Home') { event.preventDefault(); select(0, true); }
|
|
2078
|
+
else if (k === 'End') { event.preventDefault(); select(tabs.length - 1, true); }
|
|
2079
|
+
});
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
// Open the tab named in the hash, falling back to the overview.
|
|
2083
|
+
var wanted = (location.hash || '').replace(/^#/, '');
|
|
2084
|
+
var start = 0;
|
|
2085
|
+
if (wanted) {
|
|
2086
|
+
for (var i = 0; i < tabs.length; i += 1) {
|
|
2087
|
+
if (tabs[i].id === 'tab-' + wanted) { start = i; break; }
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
select(start, false);
|
|
2091
|
+
|
|
2092
|
+
// An in-page link to a panel (a loop reference from the overview) should open
|
|
2093
|
+
// the view that holds it rather than leaving the reader on the overview.
|
|
2094
|
+
document.addEventListener('click', function (event) {
|
|
2095
|
+
var link = event.target && event.target.closest ? event.target.closest('a[href^="#"]') : null;
|
|
2096
|
+
if (!link) return;
|
|
2097
|
+
var target = document.getElementById(link.getAttribute('href').slice(1));
|
|
2098
|
+
if (!target) return;
|
|
2099
|
+
var panel = target.closest('.panel');
|
|
2100
|
+
if (!panel) return;
|
|
2101
|
+
var index = panels.indexOf(panel);
|
|
2102
|
+
if (index < 0) return;
|
|
2103
|
+
select(index, false);
|
|
2104
|
+
// Let the browser do the scroll after the panel is shown.
|
|
2105
|
+
window.requestAnimationFrame(function () {
|
|
2106
|
+
target.scrollIntoView({ block: 'start', behavior: 'auto' });
|
|
2107
|
+
});
|
|
2108
|
+
});
|
|
2109
|
+
})();
|
|
2110
|
+
</script>
|
|
2111
|
+
</body>
|
|
2112
|
+
</html>
|
|
2113
|
+
`
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
/* ==========================================================================
|
|
2117
|
+
THE DIAGRAM — LAYOUT BY ARITHMETIC, NOT BY EYE
|
|
2118
|
+
==========================================================================
|
|
2119
|
+
|
|
2120
|
+
WHY THIS IS NOT A HAND-PLACED SVG ANY MORE. It was one, and it shipped four
|
|
2121
|
+
collisions that a reader saw in a browser and the author could not see at all,
|
|
2122
|
+
because nothing in the file ever compared two coordinates:
|
|
2123
|
+
|
|
2124
|
+
1. the phase-0 gate card ended at y=84 while the strip heading was drawn at
|
|
2125
|
+
y=88 — a 6px overlap of heading text across the card's lower third;
|
|
2126
|
+
2. the twelve nodes were 62 user units wide against labels measuring 61-63, so
|
|
2127
|
+
each label overran its own box and landed on its neighbour's, and nodes
|
|
2128
|
+
8-11 sat at x=690..900, INSIDE the hook column that starts at x=668;
|
|
2129
|
+
3. the backward-edge arc ran at y=268 and its REVISE label at y=264 — the same
|
|
2130
|
+
band as the prose line at y=262, so the label was painted across a sentence;
|
|
2131
|
+
4. the hook channel cards (y=38..138) and the sequence row (y=96..136) occupied
|
|
2132
|
+
the same horizontal band in the same x-range: the grey panel sliced the amber
|
|
2133
|
+
one.
|
|
2134
|
+
|
|
2135
|
+
Every one of those is a coordinate that was never checked against another
|
|
2136
|
+
coordinate. So the diagram is CHARTED before it is drawn: `diagram()` reserves a box
|
|
2137
|
+
for every element it is about to emit — cards, text, rules, arrowheads — and THROWS
|
|
2138
|
+
if two reserved boxes of ink intersect. The page cannot be generated with the defect
|
|
2139
|
+
in it, and the width of every text box comes from a declared per-character metric
|
|
2140
|
+
rather than from a guess, so "does this label fit?" is arithmetic, not opinion.
|
|
2141
|
+
|
|
2142
|
+
Two rules make that worth anything:
|
|
2143
|
+
· INK MAY NOT OVERLAP INK, except that a box may CONTAIN text and a box may
|
|
2144
|
+
contain another box. Everything else is a hard failure at build time.
|
|
2145
|
+
· NOTHING IS PLACED WITHOUT A RESERVED BOX — there is no escape hatch that writes
|
|
2146
|
+
coordinates without recording them, because an unrecorded coordinate is exactly
|
|
2147
|
+
how the four defects above got in.
|
|
2148
|
+
========================================================================== */
|
|
2149
|
+
|
|
2150
|
+
/**
|
|
2151
|
+
* The four diagram text styles, as ONE declaration used by BOTH the layout arithmetic
|
|
2152
|
+
* below and the stylesheet that draws them. That is the point: the sheet used to say
|
|
2153
|
+
* 9px for `dg-t-sm` while the diagram's own boxes were computed at 9.5px, so the rule
|
|
2154
|
+
* "every label fits its box" was true of a font nobody rendered. One declaration
|
|
2155
|
+
* removes the class of defect, and `check-workflow-map.mjs` re-reads the emitted CSS
|
|
2156
|
+
* and fails if the two ever disagree again.
|
|
2157
|
+
*/
|
|
2158
|
+
const DG_TEXT = {
|
|
2159
|
+
'dg-t-a': { size: 11, fill: 'var(--accent)' },
|
|
2160
|
+
'dg-t-h': { size: 10, fill: 'var(--warn)' },
|
|
2161
|
+
'dg-t': { size: 11, fill: 'var(--n-200)' },
|
|
2162
|
+
'dg-t-sm': { size: 9.5, fill: 'var(--n-400)' },
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
/**
|
|
2166
|
+
* The declared advance width of ONE monospace character, as a fraction of the font
|
|
2167
|
+
* size. MEASURED, not assumed: Chromium reports 0.5498 em for this page's whole stack
|
|
2168
|
+
* (ui-monospace → Consolas on Windows, Menlo on macOS, Liberation Mono on Linux) and
|
|
2169
|
+
* 0.6001 em for Courier New, which is in the stack as the last resort. Both SVG text
|
|
2170
|
+
* and `code` in this page render in that stack, so this is the widest width the
|
|
2171
|
+
* renderer can produce for a character, rounded UP rather than to the nearest
|
|
2172
|
+
* thousandth: laying out against the widest member of the stack makes the fit a
|
|
2173
|
+
* guarantee rather than a hope. `check-workflow-map.mjs` re-runs this arithmetic
|
|
2174
|
+
* against the emitted markup, and a headless Chromium re-measures the real glyphs
|
|
2175
|
+
* against the same boxes, so a metric that stops being true is a failing check rather
|
|
2176
|
+
* than a silent clip.
|
|
2177
|
+
*/
|
|
2178
|
+
const CHAR_W = 0.602
|
|
2179
|
+
/** Reserved box height of one text line, as a multiple of the font size. */
|
|
2180
|
+
const TEXT_LH = 1.4
|
|
2181
|
+
|
|
2182
|
+
function diagram() {
|
|
2183
|
+
/* ---- the chart's own geometry ---------------------------------------- */
|
|
2184
|
+
const GUTTER = 48 // left column: nothing is drawn in it except the return arc
|
|
2185
|
+
const PAD = 8 // between a box edge and the text inside it
|
|
2186
|
+
const LINE_PAD = 2.5 // half the reserved height of a rule
|
|
2187
|
+
|
|
2188
|
+
const CLS = { a: 'dg-t-a', h: 'dg-t-h', t: 'dg-t', s: 'dg-t-sm' }
|
|
2189
|
+
const FS = Object.fromEntries(Object.entries(DG_TEXT).map(([k, v]) => [k, v.size]))
|
|
2190
|
+
|
|
2191
|
+
const tw = (s, fs) => String(s).length * CHAR_W * fs
|
|
2192
|
+
// Rounded to hundredths, because the geometry is: an unrounded line height makes two
|
|
2193
|
+
// adjacent lines "intersect" by 1e-13 of a unit, and a layout rule that fires on
|
|
2194
|
+
// floating-point noise is a rule that gets switched off.
|
|
2195
|
+
const lh = (fs) => Math.round(fs * TEXT_LH * 100) / 100
|
|
2196
|
+
const round = (n) => Math.round(n * 100) / 100
|
|
2197
|
+
|
|
2198
|
+
const boxes = [] // every reserved box, in emission order
|
|
2199
|
+
const contains = [] // [container index, contained index] — the only allowed overlaps
|
|
2200
|
+
const svgParts = [] // the SVG body
|
|
2201
|
+
|
|
2202
|
+
const bounds = (b) => [b.x, b.y, b.x + b.w, b.y + b.h]
|
|
2203
|
+
const holds = (outer, inner) => {
|
|
2204
|
+
const [ox0, oy0, ox1, oy1] = bounds(outer)
|
|
2205
|
+
const [ix0, iy0, ix1, iy1] = bounds(inner)
|
|
2206
|
+
return ix0 >= ox0 && iy0 >= oy0 && ix1 <= ox1 && iy1 <= oy1
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
/**
|
|
2210
|
+
* The one gate every element passes through: reserve a box, or throw.
|
|
2211
|
+
*
|
|
2212
|
+
* THE INVARIANT. Two reserved boxes may overlap in exactly two ways, and both are
|
|
2213
|
+
* structural rather than accidental:
|
|
2214
|
+
* · a container CONTAINS its contents (a card and the text inside it, the strip
|
|
2215
|
+
* and its twelve nodes) — the outer box holds the inner one completely;
|
|
2216
|
+
* · a rule or an arrowhead may touch the edge of the box it belongs to, which the
|
|
2217
|
+
* 2.5-unit rule padding allows.
|
|
2218
|
+
* ANY other intersection — a partial one, i.e. "these two are in the same band and
|
|
2219
|
+
* neither holds the other" — is the defect this refuses to emit. A cross-band
|
|
2220
|
+
* partial overlap is precisely what all four reported defects were.
|
|
2221
|
+
*/
|
|
2222
|
+
function reserve(box, kind, label, inside) {
|
|
2223
|
+
// ROUND ON ENTRY, THEN TEST. Rounding after the test makes the rule disagree with
|
|
2224
|
+
// the manifest it prints: two boxes could pass as "touching" while both reporting
|
|
2225
|
+
// the same edge. The geometry that is CHECKED is the geometry that is EMITTED.
|
|
2226
|
+
const b = { x: round(box.x), y: round(box.y), w: round(box.w), h: round(box.h), kind, label }
|
|
2227
|
+
if (!(b.w > 0) || !(b.h > 0)) {
|
|
2228
|
+
throw new Error('diagram: ' + kind + ' "' + label + '" has an empty box: ' + JSON.stringify(b))
|
|
2229
|
+
}
|
|
2230
|
+
for (let i = 0; i < boxes.length; i++) {
|
|
2231
|
+
const o = boxes[i]
|
|
2232
|
+
const ix = Math.min(b.x + b.w, o.x + o.w) - Math.max(b.x, o.x)
|
|
2233
|
+
const iy = Math.min(b.y + b.h, o.y + o.h) - Math.max(b.y, o.y)
|
|
2234
|
+
// EPS: boxes that merely TOUCH are not an overlap, and two adjacent rows
|
|
2235
|
+
// computed from a repeated addition can differ by a thousandth of a unit. The
|
|
2236
|
+
// four defects this rule exists for were 4-14 units deep, so the tolerance
|
|
2237
|
+
// cannot hide one: it is three orders of magnitude below the smallest of them.
|
|
2238
|
+
const EPS = 0.05
|
|
2239
|
+
if (ix <= EPS || iy <= EPS) continue
|
|
2240
|
+
const nested = holds(o, b) || holds(b, o)
|
|
2241
|
+
if (nested) {
|
|
2242
|
+
// Declared in [contained, container] order, whichever way round it is: a box
|
|
2243
|
+
// that already holds this one (the label drawn before its card, the strip's own
|
|
2244
|
+
// children) makes this box the contained one.
|
|
2245
|
+
contains.push(holds(o, b) ? [boxes.length, i] : [i, boxes.length])
|
|
2246
|
+
continue
|
|
2247
|
+
}
|
|
2248
|
+
if (inside !== undefined && i === inside && (kind === 'rule' || kind === 'ink')) {
|
|
2249
|
+
// A rule or an arrowhead sharing an index with the rule it joins: the arc's
|
|
2250
|
+
// corner. Declared with the neighbour as the container, so the exemption is a
|
|
2251
|
+
// fact in the manifest and not a hole in the check.
|
|
2252
|
+
contains.push([boxes.length, i])
|
|
2253
|
+
continue
|
|
2254
|
+
}
|
|
2255
|
+
throw new Error(
|
|
2256
|
+
'diagram: OVERLAP — ' + kind + ' "' + label + '" ' + JSON.stringify([b.x, b.y, b.w, b.h]) +
|
|
2257
|
+
' intersects ' + o.kind + ' "' + o.label + '" ' + JSON.stringify([o.x, o.y, o.w, o.h]) +
|
|
2258
|
+
' by ' + round(ix) + 'x' + round(iy) + ' units — neither box contains the other')
|
|
2259
|
+
}
|
|
2260
|
+
boxes.push(b)
|
|
2261
|
+
return boxes.length - 1
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
function textEl(x, lineTop, fontCls, raw, inside) {
|
|
2265
|
+
const s = String(raw)
|
|
2266
|
+
const fs = FS[fontCls]
|
|
2267
|
+
const t = round(lineTop)
|
|
2268
|
+
reserve({ x, y: t, w: tw(s, fs), h: lh(fs) }, 'text', s, inside)
|
|
2269
|
+
// The baseline is a ROUNDED offset, not `fs*1.1` in floating point: the checker
|
|
2270
|
+
// re-derives it from the manifest, and a value it cannot re-derive is a value it
|
|
2271
|
+
// cannot check. One rounding, on the finished number.
|
|
2272
|
+
const baseline = round(t + round(fs * 1.1 * 100) / 100)
|
|
2273
|
+
svgParts.push(`<text class="${fontCls}" x="${round(x)}" y="${baseline}">${esc(s)}</text>`)
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
function card(x, y, w, h, klass, label) {
|
|
2277
|
+
const i = reserve({ x, y, w, h }, 'box', label)
|
|
2278
|
+
svgParts.push(`<rect class="${klass}" x="${round(x)}" y="${round(y)}" width="${round(w)}" height="${round(h)}" rx="1"/>`)
|
|
2279
|
+
return i
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
function rule(x, y, w, h, klass, label, inside) {
|
|
2283
|
+
const i = reserve({ x, y, w, h }, 'rule', label, inside)
|
|
2284
|
+
svgParts.push(`<path class="${klass}" d="M${round(x)} ${round(y)} L${round(x + w)} ${round(y + h)}"/>`)
|
|
2285
|
+
return i
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
const ruleH = (x, y, w, klass, label, inside) => rule(x, y - LINE_PAD, w, LINE_PAD * 2, klass, label, inside)
|
|
2289
|
+
const ruleV = (x, y, h, klass, label, inside) => rule(x - LINE_PAD, y, LINE_PAD * 2, h, klass, label, inside)
|
|
2290
|
+
|
|
2291
|
+
function head(pts, polyCls, label, inside) {
|
|
2292
|
+
const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1])
|
|
2293
|
+
reserve({ x: Math.min(...xs), y: Math.min(...ys), w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) }, 'ink', label, inside)
|
|
2294
|
+
svgParts.push(`<polygon class="${polyCls}" points="${pts.map((p) => round(p[0]) + ',' + round(p[1])).join(' ')}"/>`)
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
/** Greedy word wrap, in CHARACTERS, so the reserved box is exact by construction. */
|
|
2298
|
+
function wrap(s, fs, maxW) {
|
|
2299
|
+
const lim = Math.max(4, Math.floor(maxW / (CHAR_W * fs)))
|
|
2300
|
+
const out = []
|
|
2301
|
+
for (const para of String(s).split('\n')) {
|
|
2302
|
+
let line = ''
|
|
2303
|
+
for (const word of para.split(/\s+/)) {
|
|
2304
|
+
const cand = line ? line + ' ' + word : word
|
|
2305
|
+
if (cand.length <= lim) { line = cand; continue }
|
|
2306
|
+
if (line) out.push(line)
|
|
2307
|
+
line = word
|
|
2308
|
+
}
|
|
2309
|
+
if (line) out.push(line)
|
|
2310
|
+
}
|
|
2311
|
+
return out
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
/** A block of prose: each wrapped line in its OWN reserved row. */
|
|
2315
|
+
function para(x, top, maxW, fontCls, s) {
|
|
2316
|
+
const fs = FS[fontCls]
|
|
2317
|
+
const step = lh(fs)
|
|
2318
|
+
let y = round(top)
|
|
2319
|
+
for (const line of wrap(s, fs, maxW)) { textEl(x, y, fontCls, line); y = round(y + step) }
|
|
2320
|
+
return y
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
/* ---- BAND 1: the title ----------------------------------------------- */
|
|
2324
|
+
let y = 0
|
|
2325
|
+
textEl(GUTTER, y, CLS.a, 'THE WORKFLOW, CHARTED — every band below reserves its space before it is drawn')
|
|
2326
|
+
y += lh(FS[CLS.a]) + 12
|
|
2327
|
+
|
|
2328
|
+
/* ---- BAND 2: three columns, three x-ranges, side by side ---------------
|
|
2329
|
+
The three used to share one x-range AND one y-range. Now each owns a column
|
|
2330
|
+
and the sequence strip is the only thing that can be wide. */
|
|
2331
|
+
const midTop = y
|
|
2332
|
+
let midBottom = y
|
|
2333
|
+
|
|
2334
|
+
/* column 1 — the phase-0 gate. The card ENDS WHERE THE NEXT BAND STARTS: this
|
|
2335
|
+
card used to run to y=84 with the strip heading drawn across it at y=88. */
|
|
2336
|
+
const col1 = GUTTER
|
|
2337
|
+
const cardW = Math.ceil(Math.max(
|
|
2338
|
+
tw('recursive_ask gate=run-start', FS[CLS.h]),
|
|
2339
|
+
tw('labels: Start run | Hold', FS[CLS.s]),
|
|
2340
|
+
) + 2 * PAD + 4)
|
|
2341
|
+
const cardH = 46
|
|
2342
|
+
const gateCard = card(col1, midTop, cardW, cardH, 'dg-box dg-box-human', 'phase-0 gate card')
|
|
2343
|
+
textEl(col1 + PAD, midTop + 7, CLS.h, 'recursive_ask gate=run-start', gateCard)
|
|
2344
|
+
textEl(col1 + PAD, midTop + 7 + lh(FS[CLS.h]) + 2, CLS.s, 'labels: Start run | Hold', gateCard)
|
|
2345
|
+
midBottom = Math.max(midBottom, para(col1, midTop + cardH + 8, cardW, CLS.s,
|
|
2346
|
+
'refuses to ASK until 00-requirements.md is filled (RM4404). No approval means syncRunGoal creates no goal, so nothing runs.'))
|
|
2347
|
+
|
|
2348
|
+
/* column 2 — the twelve artifacts, as ONE strip. It is laid out at its natural
|
|
2349
|
+
width and the WRAPPER scrolls (see `.diagram`), exactly as the tab strip does,
|
|
2350
|
+
so a label is never squeezed and two labels are never in the same box.
|
|
2351
|
+
NODE_W IS DERIVED FROM THE LONGEST LABEL, never chosen: 03-implementation-summary
|
|
2352
|
+
is 25 characters and needs 165 units at the declared metric, so a hand-picked
|
|
2353
|
+
126-unit node — which is what the old chart had, at 62 — would have clipped it
|
|
2354
|
+
again. The engine refuses the build rather than let that through. */
|
|
2355
|
+
const stripLabels = PHASES.map((p) => p.file.replace(/\.md$/, ''))
|
|
2356
|
+
const NODE_GAP = 6
|
|
2357
|
+
const NODE_W = Math.ceil(Math.max(...stripLabels.map((s) => tw(s, FS[CLS.t]))) + 2 * PAD + 4)
|
|
2358
|
+
const NODE_H = 44
|
|
2359
|
+
const STRIP_PAD = 10
|
|
2360
|
+
const stripW = PHASES.length * NODE_W + (PHASES.length - 1) * NODE_GAP + 2 * STRIP_PAD
|
|
2361
|
+
const stripH = 18 + lh(FS[CLS.a]) + 10 + NODE_H
|
|
2362
|
+
const stripX = col1 + cardW + 44
|
|
2363
|
+
const strip = card(stripX, midTop, stripW, stripH, 'dg-box-strip', 'the twelve-artifact strip')
|
|
2364
|
+
textEl(stripX + STRIP_PAD, midTop + 8, CLS.a, 'THE TWELVE PHASE ARTIFACTS — PHASE_SEQUENCE, index 0 → 11', strip)
|
|
2365
|
+
const nodesTop = midTop + 18 + lh(FS[CLS.a]) + 10
|
|
2366
|
+
PHASES.forEach((p, i) => {
|
|
2367
|
+
const nx = stripX + STRIP_PAD + i * (NODE_W + NODE_GAP)
|
|
2368
|
+
const node = card(nx, nodesTop, NODE_W, NODE_H, p.human ? 'dg-box dg-box-human' : 'dg-box', 'node ' + p.file)
|
|
2369
|
+
// The FULL artifact name minus its extension: every one of the twelve fits its
|
|
2370
|
+
// node, and the name is the string the rest of the page cites. (The old chart
|
|
2371
|
+
// sliced this to nine characters, which is where "00-requir" came from.)
|
|
2372
|
+
textEl(nx + PAD, nodesTop + 7, CLS.t, stripLabels[i], node)
|
|
2373
|
+
textEl(nx + PAD, nodesTop + 7 + lh(FS[CLS.t]) + 1, CLS.s, 'phase ' + p.phaseN, node)
|
|
2374
|
+
})
|
|
2375
|
+
midBottom = Math.max(midBottom, nodesTop + NODE_H)
|
|
2376
|
+
|
|
2377
|
+
/* column 3 — the hook channel, in its OWN column, right of the strip. It used to
|
|
2378
|
+
start at x=668 while the strip ran to x=900: the grey panel sliced the amber
|
|
2379
|
+
one, and the strip's last four labels were painted inside these cards. */
|
|
2380
|
+
const col3 = stripX + stripW + 44
|
|
2381
|
+
textEl(col3, midTop, CLS.a, 'HOOK CHANNEL — pre_trigger (gating)')
|
|
2382
|
+
const hookW = Math.ceil(Math.max(
|
|
2383
|
+
tw('phase-order | locked | lock-order | tdd', FS[CLS.s]),
|
|
2384
|
+
tw('denies exit_plan_mode while a phase waits', FS[CLS.s]),
|
|
2385
|
+
tw('exit-plan-mode-gate prio 5', FS[CLS.t]),
|
|
2386
|
+
) + 2 * PAD + 4)
|
|
2387
|
+
const hookH = 44
|
|
2388
|
+
let hy = midTop + lh(FS[CLS.a]) + 6
|
|
2389
|
+
const hook1 = card(col3, hy, hookW, hookH, 'dg-box', 'hook: exit-plan-mode-gate')
|
|
2390
|
+
textEl(col3 + PAD, hy + 7, CLS.t, 'exit-plan-mode-gate prio 5', hook1)
|
|
2391
|
+
textEl(col3 + PAD, hy + 7 + lh(FS[CLS.t]) + 1, CLS.s, 'denies exit_plan_mode while a phase waits', hook1)
|
|
2392
|
+
hy += hookH + 8
|
|
2393
|
+
const hook2 = card(col3, hy, hookW, hookH, 'dg-box', 'hook: builtin-tool-guard')
|
|
2394
|
+
textEl(col3 + PAD, hy + 7, CLS.t, 'builtin-tool-guard prio 0', hook2)
|
|
2395
|
+
textEl(col3 + PAD, hy + 7 + lh(FS[CLS.t]) + 1, CLS.s, 'phase-order | locked | lock-order | tdd', hook2)
|
|
2396
|
+
hy += hookH + 10
|
|
2397
|
+
midBottom = Math.max(midBottom, para(col3, hy, hookW, CLS.s,
|
|
2398
|
+
'The first decisive hook short-circuits. Observe-only points are downgraded, not obeyed.'))
|
|
2399
|
+
|
|
2400
|
+
y = midBottom + 22
|
|
2401
|
+
|
|
2402
|
+
/* ---- BAND 3: the lock chain, one row, then its note ------------------ */
|
|
2403
|
+
textEl(GUTTER, y, CLS.a, 'recursive_lock — the chain, in checks order')
|
|
2404
|
+
y += lh(FS[CLS.a]) + 8
|
|
2405
|
+
const LOCK = [
|
|
2406
|
+
['1 lock order', false], ['2 quiescence', false], ['3 phase-8 memory', true],
|
|
2407
|
+
['4 lint standard', false], ['5 hash + receipt', false],
|
|
2408
|
+
]
|
|
2409
|
+
const lockW = 128, lockH = 32, lockGap = 8
|
|
2410
|
+
LOCK.forEach(([label, refuse], i) => {
|
|
2411
|
+
const lx = GUTTER + i * (lockW + lockGap)
|
|
2412
|
+
const step = card(lx, y, lockW, lockH, refuse ? 'dg-box dg-box-refuse' : 'dg-box', 'lock step ' + label)
|
|
2413
|
+
textEl(lx + PAD, y + 9, CLS.s, label, step)
|
|
2414
|
+
if (i > 0) ruleH(lx - lockGap, y + lockH / 2, lockGap, 'dg-line-acc', 'lock link ' + i)
|
|
2415
|
+
})
|
|
2416
|
+
y += lockH + 10
|
|
2417
|
+
y = para(GUTTER, y, 460, CLS.s,
|
|
2418
|
+
'Ordering is FIRST and the linter is LAST: a run that is both out of order and below standard reports ORDERING.')
|
|
2419
|
+
|
|
2420
|
+
/* ---- BAND 4: the backward edge. The arc owns the GUTTER; its label and the
|
|
2421
|
+
prose own reserved rows to the right of it. They are never in the same band —
|
|
2422
|
+
which is exactly what the old chart did, printing REVISE across a sentence. */
|
|
2423
|
+
y += 22
|
|
2424
|
+
const backTop = y
|
|
2425
|
+
textEl(GUTTER, backTop, CLS.s, 'BACKWARD EDGE — a REVISE verdict or a reopen sends work BACK, not forward')
|
|
2426
|
+
let by = backTop + lh(FS[CLS.s]) + 6
|
|
2427
|
+
by = para(GUTTER, by, 560, CLS.s,
|
|
2428
|
+
'A REVISE verdict or a reopen sends work back to repair: REVISE returns the follow-up to the SAME child, and a reopen invalidates the downstream receipts.')
|
|
2429
|
+
by = para(GUTTER, by, 560, CLS.s,
|
|
2430
|
+
'nextLegalPhase returns null — NOT the next node — when the first unlocked node has an unlocked prerequisite. With a back-edge, "continue" and "blocked" are different answers, and only null is correct.')
|
|
2431
|
+
const arcTop = backTop + 4
|
|
2432
|
+
const arcBottom = by - 6
|
|
2433
|
+
const riser = ruleV(14, arcTop + 8, arcBottom - arcTop - 8, 'dg-line-loop', 'backward-edge riser')
|
|
2434
|
+
// The run starts AT the riser (same corner, same path) — the one place two rules may
|
|
2435
|
+
// touch by their 2.5-unit stroke padding, and it is declared here rather than excused
|
|
2436
|
+
// by widening the tolerance: the arc is still made of reserved boxes.
|
|
2437
|
+
ruleH(14, arcBottom, GUTTER - 20, 'dg-line-loop', 'backward-edge run', riser)
|
|
2438
|
+
head([[14, arcTop], [10, arcTop + 8], [18, arcTop + 8]], 'dg-head-loop', 'backward-edge arrowhead', riser)
|
|
2439
|
+
y = by + 22
|
|
2440
|
+
|
|
2441
|
+
/* ---- BAND 5: the footer legend -------------------------------------- */
|
|
2442
|
+
textEl(GUTTER, y, CLS.s, 'DECISION = amber box, filled rail dot · REFUSAL = hatched · everything else: AUTO')
|
|
2443
|
+
y += lh(FS[CLS.s]) + 6
|
|
2444
|
+
textEl(GUTTER, y, CLS.s, 'Every element above reserved its box first: the generator REFUSES to emit the chart when two of them intersect.')
|
|
2445
|
+
y += lh(FS[CLS.s])
|
|
2446
|
+
|
|
2447
|
+
const W = Math.ceil(Math.max(
|
|
2448
|
+
col3 + hookW + 24,
|
|
2449
|
+
stripX + stripW + 24,
|
|
2450
|
+
GUTTER + 460 + 24,
|
|
2451
|
+
GUTTER + 560 + 24,
|
|
2452
|
+
))
|
|
2453
|
+
const H = Math.ceil(y + 16)
|
|
2454
|
+
|
|
2455
|
+
/* ---- the manifest, shipped inside the markup -------------------------
|
|
2456
|
+
The reserved boxes ARE the invariant, so they leave the generator with the page:
|
|
2457
|
+
`check-workflow-map.mjs` re-reads them from this attribute and re-verifies the
|
|
2458
|
+
geometry — including that no two boxes intersect — by parsing the markup rather
|
|
2459
|
+
than by looking at it. `charW` and `lineHeight` ship with them so the arithmetic
|
|
2460
|
+
is auditable instead of implied, and an independent headless pass can compare the
|
|
2461
|
+
reserved boxes against what a real layout engine actually renders. */
|
|
2462
|
+
const manifest = {
|
|
2463
|
+
v: 1,
|
|
2464
|
+
charW: CHAR_W,
|
|
2465
|
+
lineHeight: TEXT_LH,
|
|
2466
|
+
viewBox: [0, 0, W, H],
|
|
2467
|
+
fit: boxes.filter((b) => b.kind === 'text').map((b) => [b.label, b.x, b.y, b.w, b.h]),
|
|
2468
|
+
boxes: boxes.map((b) => [b.kind, b.x, b.y, b.w, b.h, b.label]),
|
|
2469
|
+
// The declared overlaps, by index pair: [inside, container]. A box that CONTAINS
|
|
2470
|
+
// another is an overlap by construction, so the relation is exported rather than
|
|
2471
|
+
// left for the checker to re-infer — an inferred exception is not a checked one.
|
|
2472
|
+
contains,
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
return {
|
|
2476
|
+
width: W,
|
|
2477
|
+
height: H,
|
|
2478
|
+
markup: `
|
|
2479
|
+
<div class="diagram" role="img" aria-label="Overview diagram: the Phase 0 human gate, the twelve-artifact sequence as one horizontally scrollable strip, the two gating hooks in their own column, the lock chain, and the backward edge drawn in its own band. The same information is given as text in the sequence below." data-layout="${esc(JSON.stringify(manifest)).replace(/\n/g, ' ')}">
|
|
2480
|
+
<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" preserveAspectRatio="xMinYMin meet">
|
|
2481
|
+
${svgParts.map((s) => ' ' + s).join('\n')}
|
|
2482
|
+
</svg>
|
|
2483
|
+
</div>`,
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
|
|
2488
|
+
/* VERIFY */
|
|
2489
|
+
/* ========================================================================== */
|
|
2490
|
+
|
|
2491
|
+
function readRepo(rel) {
|
|
2492
|
+
const p = join(ROOT, ...rel.split('/'))
|
|
2493
|
+
if (!existsSync(p)) throw new Error('missing source file: ' + rel)
|
|
2494
|
+
return readFileSync(p, 'utf8')
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
/** Parse a `[...]` or `new Set([...])` array literal of single-quoted strings. */
|
|
2498
|
+
function stringList(src, startMarker, { set = false, typed = false } = {}) {
|
|
2499
|
+
const at = src.indexOf(startMarker)
|
|
2500
|
+
if (at < 0) throw new Error('marker not found: ' + startMarker)
|
|
2501
|
+
let open, close
|
|
2502
|
+
if (typed) {
|
|
2503
|
+
// `export const X: readonly HookPoint[] = ['` — the literal starts after the LAST `[`
|
|
2504
|
+
// of the declaration, but a type annotation may itself contain `[]`, so anchor on `= [`.
|
|
2505
|
+
open = src.indexOf('= [', at)
|
|
2506
|
+
close = src.indexOf(']', open)
|
|
2507
|
+
} else {
|
|
2508
|
+
open = set ? src.indexOf('([', at) : src.indexOf('[', at)
|
|
2509
|
+
close = src.indexOf(']', open)
|
|
2510
|
+
}
|
|
2511
|
+
if (open < 0 || close < 0) throw new Error('array literal not found after: ' + startMarker)
|
|
2512
|
+
return [...src.slice(open, close).matchAll(/'([^']+)'/g)].map((m) => m[1])
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
/** Parse `'file.md': [ 'a', 'b', ... ],` entries out of SECTION_MAP. */
|
|
2516
|
+
function sectionMap(src) {
|
|
2517
|
+
const at = src.indexOf('const SECTION_MAP: Record<string, string[]> = {')
|
|
2518
|
+
if (at < 0) throw new Error('SECTION_MAP not found')
|
|
2519
|
+
const end = src.indexOf('\n}', at)
|
|
2520
|
+
const body = src.slice(at, end)
|
|
2521
|
+
const out = {}
|
|
2522
|
+
for (const m of body.matchAll(/'([^']+\.md)':\s*\[([\s\S]*?)\]/g)) {
|
|
2523
|
+
out[m[1]] = [...m[2].matchAll(/'([^']+)'/g)].map((x) => x[1])
|
|
2524
|
+
}
|
|
2525
|
+
return out
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
function verify(html) {
|
|
2529
|
+
const problems = []
|
|
2530
|
+
const ok = []
|
|
2531
|
+
const check = (label, actual, expected) => {
|
|
2532
|
+
const a = JSON.stringify(actual)
|
|
2533
|
+
const e = JSON.stringify(expected)
|
|
2534
|
+
if (a === e) ok.push(label)
|
|
2535
|
+
else problems.push(label + '\n expected: ' + e + '\n actual: ' + a)
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
const lockSrc = readRepo('src/lock.ts')
|
|
2539
|
+
const rulesSrc = readRepo('src/phase-rules.ts')
|
|
2540
|
+
const errorsSrc = readRepo('src/errors.ts')
|
|
2541
|
+
const hooksSrc = readRepo('src/hooks.ts')
|
|
2542
|
+
const startSrc = readRepo('src/run-start.ts')
|
|
2543
|
+
const indexSrc = readRepo('src/index.ts')
|
|
2544
|
+
const globsSrc = readRepo('src/policy-globs.ts')
|
|
2545
|
+
const lintSrc = readRepo('src/ts-lint.ts')
|
|
2546
|
+
|
|
2547
|
+
// 1. sequence
|
|
2548
|
+
check('PHASE_SEQUENCE: 12 artifacts, same order', stringList(lockSrc, 'export const PHASE_SEQUENCE'), PHASES.map((p) => p.file))
|
|
2549
|
+
check('LATE_PHASE_ARTIFACTS', stringList(rulesSrc, 'export const LATE_PHASE_ARTIFACTS'), LATE.files)
|
|
2550
|
+
check('AUDITED_PHASE_FILES', stringList(rulesSrc, 'export const AUDITED_PHASE_FILES = new Set(', { set: true }), PHASES.filter((p) => p.audited).map((p) => p.file))
|
|
2551
|
+
check('OPTIONAL_PHASES', stringList(lockSrc, 'export const OPTIONAL_PHASES = new Set(', { set: true }), PHASES.filter((p) => p.optional).map((p) => p.file))
|
|
2552
|
+
check('TRACEABILITY_REQUIRED_FILES', stringList(rulesSrc, 'export const TRACEABILITY_REQUIRED_FILES = new Set(', { set: true }), PHASES.filter((p) => p.traceability).map((p) => p.file))
|
|
2553
|
+
check('AUDIT_REQUIRED_HEADINGS: 9 entries', stringList(rulesSrc, 'export const AUDIT_REQUIRED_HEADINGS'), ['Audit Context', 'Effective Inputs Re-read', 'Earlier Phase Reconciliation', 'Subagent Contribution Verification', 'Worktree Diff Audit', 'Gaps Found', 'Repair Work Performed', 'Requirement Completion Status', 'Audit Verdict'])
|
|
2554
|
+
|
|
2555
|
+
// 2. sections, per phase — and the declared range LENGTHS, so the citation
|
|
2556
|
+
// rendered on the page ends where the real list ends.
|
|
2557
|
+
const sm = sectionMap(rulesSrc)
|
|
2558
|
+
for (const p of PHASES) check('SECTION_MAP[' + p.file + ']', sm[p.file], p.sections)
|
|
2559
|
+
check('declared section lengths match the parsed source',
|
|
2560
|
+
SECTION_FILES.filter((f) => (sm[f] || []).length !== SECTION_LENGTHS[f]),
|
|
2561
|
+
[])
|
|
2562
|
+
check('the rendered section range ends on the list it cites',
|
|
2563
|
+
SECTION_FILES.filter((f) => {
|
|
2564
|
+
// `src/phase-rules.ts:A-B` — the range's SPAN must equal the parsed list length.
|
|
2565
|
+
const span = sectionsCite(f).split(':').pop()
|
|
2566
|
+
const [a, b] = span.split('-').map(Number)
|
|
2567
|
+
return b - a + 1 !== (sm[f] || []).length
|
|
2568
|
+
}),
|
|
2569
|
+
[])
|
|
2570
|
+
|
|
2571
|
+
// 3. input artifact map. Two shapes exist in the source: a braced `{ candidates = [...] }`
|
|
2572
|
+
// body, and a one-line `else if (...) candidates = ['…']` — so the `{` and the
|
|
2573
|
+
// newline are both optional. A conditional push (a "when present" extra) is read
|
|
2574
|
+
// as data on the phase it belongs to, never guessed.
|
|
2575
|
+
const inputMap = {}
|
|
2576
|
+
// NOTE the leading `\}?`: an `else if` branch in this file closes the previous
|
|
2577
|
+
// braced branch on its OWN line (` } else if (…`), so a regex anchored on `^if`
|
|
2578
|
+
// silently skipped every branch that followed a braced one — two phases went
|
|
2579
|
+
// missing from the map while the check still "passed" on a subset.
|
|
2580
|
+
for (const m of lintSrc.matchAll(/^\s*\}?\s*(?:else )?if \(fileName === '([^']+)'\) ?\{?\s*candidates = \[([^\]]*)\]/gm)) {
|
|
2581
|
+
inputMap[m[1]] = [...m[2].matchAll(/'([^']+)'/g)].map((x) => x[1])
|
|
2582
|
+
}
|
|
2583
|
+
const inputExtras = {}
|
|
2584
|
+
{
|
|
2585
|
+
const body = (lintSrc.match(/export function getPhaseExpectedInputArtifactNames[\s\S]*?\n\}/) || [''])[0]
|
|
2586
|
+
let current = ''
|
|
2587
|
+
for (const line of body.split('\n')) {
|
|
2588
|
+
const decl = line.match(/fileName === '([^']+)'/)
|
|
2589
|
+
if (decl) current = decl[1]
|
|
2590
|
+
const push = line.match(/candidates\.push\('([^']+)'\)/)
|
|
2591
|
+
if (push && current) {
|
|
2592
|
+
if (!inputExtras[current]) inputExtras[current] = []
|
|
2593
|
+
inputExtras[current].push(push[1])
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
check('the linter declares inputs for the phases that have upstream artifacts',
|
|
2598
|
+
Object.keys(inputMap).sort(),
|
|
2599
|
+
PHASES.slice(1).filter((p) => p.file !== '06-decisions-update.md' && p.file !== '08-memory-impact.md').map((p) => p.file).sort())
|
|
2600
|
+
for (const [file, list] of Object.entries(inputMap)) {
|
|
2601
|
+
const p = PHASES.find((x) => x.file === file)
|
|
2602
|
+
const expected = p.inputs.filter((i) => i.artifact && !/ — only when present$/.test(i.doc)).map((i) => i.doc)
|
|
2603
|
+
check('inputs for ' + file + ' (declared candidates)', list, expected)
|
|
2604
|
+
}
|
|
2605
|
+
check('the "when present" extras are the ones the page marks as conditional',
|
|
2606
|
+
Object.values(inputExtras).flat().sort(),
|
|
2607
|
+
PHASES.flatMap((p) => p.inputs.filter((i) => / — only when present$/.test(i.doc)).map((i) => i.doc.replace(/ — only when present$/, ''))).sort())
|
|
2608
|
+
check('the input map is the whole declaration body, not a prefix',
|
|
2609
|
+
Object.keys(inputMap).length, 9)
|
|
2610
|
+
check('the two "all present artifacts except itself" phases are exactly 06 and 08',
|
|
2611
|
+
/fileName === '06-decisions-update\.md'\) candidates = RUN_ARTIFACT_SEQUENCE\.filter/.test(lintSrc) &&
|
|
2612
|
+
/fileName === '08-memory-impact\.md'\) candidates = RUN_ARTIFACT_SEQUENCE\.filter/.test(lintSrc), true)
|
|
2613
|
+
|
|
2614
|
+
// 4. tools
|
|
2615
|
+
const toolFiles = ['recursive_status', 'recursive_init', 'recursive_lock', 'recursive_lint', 'recursive_closeout', 'recursive_scratch', 'recursive_worktree', 'recursive_phase', 'recursive_review', 'recursive_delegate', 'recursive_ask', 'recursive_preview', 'recursive_audit_team']
|
|
2616
|
+
check('13 tool files declare 13 names', toolFiles.map((f) => {
|
|
2617
|
+
const s = readRepo('src/' + f + '.tool.ts')
|
|
2618
|
+
const m = s.match(/name: '(recursive_[a-z_]+)'/)
|
|
2619
|
+
return m ? m[1] : 'MISSING'
|
|
2620
|
+
}), TOOLS.map((t) => t.n))
|
|
2621
|
+
|
|
2622
|
+
// 5. errors — order is the REGISTRY's order (it groups by class on purpose), so
|
|
2623
|
+
// the comparison sorts both sides rather than reordering the page.
|
|
2624
|
+
const codes = [...errorsSrc.matchAll(/code: '(RM\d{4})'/g)].map((m) => m[1])
|
|
2625
|
+
check('the RM#### codes in TOOL_ERRORS', [...codes].sort(), [...ERRORS.map((e) => e.code)].sort())
|
|
2626
|
+
const klass = {}
|
|
2627
|
+
for (const m of errorsSrc.matchAll(/code: '(RM\d{4})',\s*\n\s*klass: '(\w+)'/g)) klass[m[1]] = m[2]
|
|
2628
|
+
check('each code\'s third character matches its class group', codes.map((c) => ({ c, group: c[3] })).map(({ c, group }) => ({ '1': 'input', '2': 'value', '3': 'workspace', '4': 'state', '5': 'runtime', '6': 'capability' }[group])), codes.map((c) => klass[c]))
|
|
2629
|
+
|
|
2630
|
+
// 6. hooks
|
|
2631
|
+
check('HOOK_POINTS', stringList(hooksSrc, 'export const HOOK_POINTS'), HOOKS.map((h) => h.point))
|
|
2632
|
+
check('GATING_POINTS', stringList(hooksSrc, 'export const GATING_POINTS', { typed: true }), HOOKS.filter((h) => h.gating).map((h) => h.point))
|
|
2633
|
+
check('OBSERVING_POINTS', stringList(hooksSrc, 'export const OBSERVING_POINTS', { typed: true }), HOOKS.filter((h) => !h.gating).map((h) => h.point))
|
|
2634
|
+
check('gating and observing partition the five points',
|
|
2635
|
+
[...new Set([...HOOKS.filter((h) => h.gating).map((h) => h.point), ...HOOKS.filter((h) => !h.gating).map((h) => h.point)])].length,
|
|
2636
|
+
HOOKS.length)
|
|
2637
|
+
// The guard registers under a CONSTANT name, so the binding is asserted from the
|
|
2638
|
+
// constant's own value plus the register call, not from a guessed literal.
|
|
2639
|
+
check('the exit-plan-mode-gate binding (name + priority)',
|
|
2640
|
+
[...indexSrc.matchAll(/name: '(exit-plan-mode-gate)',\s*\n\s*priority: (\d+)/g)].map((m) => m[1] + '@' + m[2]),
|
|
2641
|
+
['exit-plan-mode-gate@5'])
|
|
2642
|
+
check('the built-in guard registers at priority 0 under BUILTIN_GUARD_HOOK_NAME',
|
|
2643
|
+
/BUILTIN_GUARD_HOOK_NAME = '([^']+)'/.test(indexSrc) &&
|
|
2644
|
+
new RegExp("name: BUILTIN_GUARD_HOOK_NAME,\\s*\\n\\s*priority: 0").test(indexSrc), true)
|
|
2645
|
+
check('the built-in guard constant is the name the page shows',
|
|
2646
|
+
(indexSrc.match(/BUILTIN_GUARD_HOOK_NAME = '([^']+)'/) || [])[1], 'builtin-tool-guard')
|
|
2647
|
+
check('exactly two pre_trigger bindings are registered',
|
|
2648
|
+
(indexSrc.match(/hooks\.register\('pre_trigger', \{/g) || []).length, 2)
|
|
2649
|
+
check('seam event strings present in index.ts', ['tools/pre-execute', 'fs/observed', 'session/event', 'agent/pre-step'].filter((s) => indexSrc.includes("on('" + s + "'")), ['tools/pre-execute', 'fs/observed', 'session/event', 'agent/pre-step'])
|
|
2650
|
+
check('systemPrompt section name', [/name: 'recursive:policy'/.test(indexSrc) ? 'recursive:policy' : 'MISSING'], ['recursive:policy'])
|
|
2651
|
+
|
|
2652
|
+
// 7. guard labels + run-start
|
|
2653
|
+
check('guard rule labels, in built-in rule order', [...globsSrc.matchAll(/label: '([a-z-]+)'/g)].map((m) => m[1]), ['lock-order', 'locked-write', 'phase-order', 'memory-read'])
|
|
2654
|
+
check('phase-rules tdd-evidence label', [/label: 'tdd-evidence'/.test(rulesSrc) ? 'tdd-evidence' : 'MISSING'], ['tdd-evidence'])
|
|
2655
|
+
check('run-start gate id + labels', [startSrc.match(/RUN_START_GATE_ID = '([^']+)'/)[1], startSrc.match(/RUN_START_APPROVE = '([^']+)'/)[1], startSrc.match(/RUN_START_HOLD = '([^']+)'/)[1]], ['run-start', 'Start run', 'Hold'])
|
|
2656
|
+
const askSrc = readRepo('src/recursive_ask.tool.ts')
|
|
2657
|
+
check('the workflow gate ids and their headers are the ones shown', [
|
|
2658
|
+
...askSrc.matchAll(/id: '(tdd-mode|qa-signoff|gate-block)',\s*\n\s*header: '([^']+)'/g),
|
|
2659
|
+
].map((m) => m[1] + ' / ' + m[2]), ['tdd-mode / TDD Mode', 'qa-signoff / QA sign-off', 'gate-block / Gate block'])
|
|
2660
|
+
check('run-start is deliberately not one of the three workflow gates', /Deliberately NOT in ASK_GATE_IDS/.test(startSrc), true)
|
|
2661
|
+
check('the relay flag is refused for every gate but run-start', /args\.relay === true && !isRunStartGate\(gateId\)/.test(askSrc), true)
|
|
2662
|
+
/** The option labels of one ASK_GATES entry: its `id:` through its `marker:` line. */
|
|
2663
|
+
const gateOptions = (id) => {
|
|
2664
|
+
const block = (askSrc.match(new RegExp("'" + id + "': \\{[\\s\\S]*?marker: '[^']+',")) || [''])[0]
|
|
2665
|
+
return [...block.matchAll(/label: '([^']+)'/g)].map((m) => m[1])
|
|
2666
|
+
}
|
|
2667
|
+
check('the gate-block options are fix | reopen | abandon', gateOptions('gate-block'), ['fix', 'reopen', 'abandon'])
|
|
2668
|
+
check('the tdd-mode options are strict | pragmatic', gateOptions('tdd-mode'), ['strict', 'pragmatic'])
|
|
2669
|
+
check('the qa-signoff options are human | agent-operated | hybrid', gateOptions('qa-signoff'), ['human', 'agent-operated', 'hybrid'])
|
|
2670
|
+
|
|
2671
|
+
// 8. the HTML's own structure: every aria-controls resolves, exactly one panel per tab
|
|
2672
|
+
const tabIds = [...html.matchAll(/<button role="tab" id="tab-([a-z0-9-]+)" aria-controls="panel-([a-z0-9-]+)"/g)]
|
|
2673
|
+
const panelIds = new Set([...html.matchAll(/class="panel[^"]*" id="panel-([a-z0-9-]+)"/g)].map((m) => m[1]))
|
|
2674
|
+
check('every tab aria-controls names an existing panel', tabIds.map((m) => m[2]).filter((id) => !panelIds.has(id)), [])
|
|
2675
|
+
check('every panel is a tabpanel labelled by its own tab',
|
|
2676
|
+
[...html.matchAll(/id="panel-([a-z0-9-]+)" role="tabpanel" aria-labelledby="tab-([a-z0-9-]+)"/g)].length, tabIds.length)
|
|
2677
|
+
check('every panel is addressed by exactly one tab',
|
|
2678
|
+
[...panelIds].filter((p) => tabIds.filter((m) => m[2] === p).length !== 1), [])
|
|
2679
|
+
check('tab count == 1 overview + 12 phases + 8 other views', tabIds.length, 21)
|
|
2680
|
+
check('exactly one TAB starts aria-selected=true', [...html.matchAll(/<button role="tab"[^>]*aria-selected="true"/g)].length, 1)
|
|
2681
|
+
check('exactly one TAB starts tabindex=0', [...html.matchAll(/<button role="tab"[^>]*tabindex="0"/g)].length, 1)
|
|
2682
|
+
check('every other tab starts at tabindex=-1 (the roving tabindex)',
|
|
2683
|
+
[...html.matchAll(/<button role="tab"[^>]*tabindex="-1"/g)].length, tabIds.length - 1)
|
|
2684
|
+
check('the tablist is labelled', /role="tablist" aria-labelledby="tabstrip-hint"/.test(html), true)
|
|
2685
|
+
check('every phase file has its own panel', PHASES.map((p) => p.file.replace(/[^a-z0-9]+/gi, '-')).filter((s) => !panelIds.has('phase-' + s)), [])
|
|
2686
|
+
check('every phase artifact is named somewhere on the page', PHASES.filter((p) => !html.includes(p.file)), [])
|
|
2687
|
+
check('no external resource references', /(<link[^>]+href="(?!#)|<script[^>]+src=)/.test(html), false)
|
|
2688
|
+
|
|
2689
|
+
// 9. CONTRAST — every declared pair, at the threshold it owes. This is the
|
|
2690
|
+
// accessibility claim the page makes, so it is MEASURED rather than asserted.
|
|
2691
|
+
const contrastResults = CONTRAST_REQUIREMENTS.map((r) => ({
|
|
2692
|
+
what: r.what, pair: r.fg + ' on ' + r.bg, need: r.need,
|
|
2693
|
+
ratio: Math.round(contrast(tone(r.fg), tone(r.bg)) * 100) / 100,
|
|
2694
|
+
}))
|
|
2695
|
+
check('every WCAG pair meets its threshold',
|
|
2696
|
+
contrastResults.filter((r) => r.ratio < r.need).map((r) => r.pair + ' = ' + r.ratio + ' (needs ' + r.need + ')'),
|
|
2697
|
+
[])
|
|
2698
|
+
|
|
2699
|
+
// 10. the CSS really consumes the audited tokens — a palette audited in the
|
|
2700
|
+
// checker but hardcoded in the sheet would hollow out the whole claim.
|
|
2701
|
+
check('every hex in the sheet is a declared token value',
|
|
2702
|
+
[...new Set([...html.matchAll(/#[0-9a-fA-F]{6}\b/g)].map((m) => m[0].toLowerCase()))].filter((h) => !Object.values(C).map((v) => v.toLowerCase()).includes(h)),
|
|
2703
|
+
[])
|
|
2704
|
+
|
|
2705
|
+
// 11. the radius ladder is concentric, and no token was left dangling
|
|
2706
|
+
const declared = new Set([...html.matchAll(/--(r-\d|r-pill):/g)].map((m) => m[1]))
|
|
2707
|
+
const used = new Set([...html.matchAll(/var\(--(r-\d|r-pill)\)/g)].map((m) => m[1]))
|
|
2708
|
+
const ladder = ['r-1', 'r-2', 'r-3'].map((n) => Number((html.match(new RegExp('--' + n + ':(\\d+)px')) || [0, '0'])[1]))
|
|
2709
|
+
check('radius ladder is strictly increasing (outer > inner)', ladder.join('<') === [...ladder].sort((a, b) => a - b).join('<') && new Set(ladder).size === 3, true)
|
|
2710
|
+
check('every radius token used is declared', [...used].filter((u) => !declared.has(u)), [])
|
|
2711
|
+
check('every radius token declared is used', [...declared].filter((d) => !used.has(d)), [])
|
|
2712
|
+
check('no dangling --r token (the old shorthand was removed)', /var\(--r\)/.test(html), false)
|
|
2713
|
+
|
|
2714
|
+
// 12. responsiveness / motion / interaction affordances the design skills require
|
|
2715
|
+
const tablistRule = (html.match(/\[role="tablist"\]\{[\s\S]*?\n\}/) || [''])[0]
|
|
2716
|
+
const tabRule = (html.match(/\[role="tab"\]\{[\s\S]*?\n\}/) || [''])[0]
|
|
2717
|
+
check('the tab strip scrolls in one row rather than wrapping', /overflow-x:auto/.test(tablistRule), true)
|
|
2718
|
+
check('the tab strip does not wrap', /flex-wrap:wrap/.test(tablistRule), false)
|
|
2719
|
+
check('the tab hit area is at least 44px', /min-height:44px/.test(tabRule), true)
|
|
2720
|
+
check('fluid type uses clamp', (html.match(/clamp\(/g) || []).length >= 5, true)
|
|
2721
|
+
check('prefers-reduced-motion is honoured', /@media \(prefers-reduced-motion: reduce\)/.test(html), true)
|
|
2722
|
+
check('tabular numerals are applied', /font-variant-numeric:tabular-nums/.test(html), true)
|
|
2723
|
+
check('font smoothing is applied', /-webkit-font-smoothing:antialiased/.test(html), true)
|
|
2724
|
+
check('text-wrap is used for headings and body', /text-wrap:balance/.test(html) && /text-wrap:pretty/.test(html), true)
|
|
2725
|
+
// NOTE, twice over. (a) The `[^;{}]*` stops a `transition-property:` declaration from
|
|
2726
|
+
// spanning to an unrelated `all`. (b) The required `[;{]` before the property means
|
|
2727
|
+
// this matches a CSS DECLARATION only — the page's own prose is allowed to say the
|
|
2728
|
+
// words "transition: all" while explaining that it uses none, and a check that
|
|
2729
|
+
// forbids the documentation from naming the thing it avoids is a broken check.
|
|
2730
|
+
check('no `transition: all` declaration', /[;{]\s*transition:[^;{}]*\ball\b/.test(html), false)
|
|
2731
|
+
check('transitions name exact properties', /transition-property:/.test(html), true)
|
|
2732
|
+
check('no declaration sets animation', /[;{]\s*animation:/.test(html), false)
|
|
2733
|
+
check('a visible focus style exists', /:focus-visible\{/.test(html), true)
|
|
2734
|
+
check('a skip link exists and targets the overview', /class="skip" href="#tab-overview"/.test(html), true)
|
|
2735
|
+
check('every gate carries a text label, not only a colour', (html.match(/g-shape">(DECISION|REFUSAL|AUTO|ABSTAIN|UNVERIFIED)/g) || []).length >= 12, true)
|
|
2736
|
+
|
|
2737
|
+
// 13. CITATION COVERAGE — the property this whole page rests on. Every rendered
|
|
2738
|
+
// citation must name a real source file AND a line, that line must exist, and a
|
|
2739
|
+
// phase panel must not be able to lose its provenance during an edit.
|
|
2740
|
+
//
|
|
2741
|
+
// ⚠ THE SPLIT ACCEPTS A COMMA **OR A NEWLINE**: a citation naming several sources
|
|
2742
|
+
// wraps across lines in the caption, and treating a wrapped entry as ONE entry made
|
|
2743
|
+
// this check report a list of perfectly good citations as broken. The check was
|
|
2744
|
+
// wrong, not the citations.
|
|
2745
|
+
//
|
|
2746
|
+
// ⚠ AND THE SHAPE TEST IS DELIBERATELY NOT `[a-z-]+`: real source files carry
|
|
2747
|
+
// underscores (`recursive_status.tool.ts`, `run-start.ts`), so a character class
|
|
2748
|
+
// that omits one is a check that fails on correct input. What a citation must
|
|
2749
|
+
// satisfy is (1) `path:line`, (2) a path that EXISTS and is readable, and (3) a
|
|
2750
|
+
// line inside it — which is what is asserted here instead.
|
|
2751
|
+
const citations = [...html.matchAll(/<span class="cite">([^<]*)<\/span>/g)].map((m) => m[1])
|
|
2752
|
+
const citationParts = [...new Set(citations.flatMap((c) => c.split(/,\s*|\s*\n\s*/)).map((c) => c.trim()).filter(Boolean))]
|
|
2753
|
+
const readOk = (file) => { try { return sourceLines(file).length } catch { return null } }
|
|
2754
|
+
check('every rendered citation has the shape `path:line`',
|
|
2755
|
+
citationParts.filter((c) => !/^src\/\S+\.ts:\d+(-\d+)?$/.test(c)),
|
|
2756
|
+
[])
|
|
2757
|
+
check('every cited path is a file that exists and can be read',
|
|
2758
|
+
citationParts.filter((c) => readOk(c.slice(0, c.lastIndexOf(':'))) === null),
|
|
2759
|
+
[])
|
|
2760
|
+
check('every citation line exists inside the file it names',
|
|
2761
|
+
citationParts.filter((c) => {
|
|
2762
|
+
const file = c.slice(0, c.indexOf(':'))
|
|
2763
|
+
const end = Number(c.split('-').pop().split(':').pop())
|
|
2764
|
+
try { return !(end >= 1 && end <= sourceLines(file).length) } catch { return true }
|
|
2765
|
+
}),
|
|
2766
|
+
[])
|
|
2767
|
+
check('at least one citation per phase panel (>= 8 each)',
|
|
2768
|
+
PHASES.filter((p) => {
|
|
2769
|
+
const panel = (html.match(new RegExp('id="panel-phase-' + p.file.replace(/[^a-z0-9]+/gi, '-') + '"[\\s\\S]*?\\n</section>')) || [''])[0]
|
|
2770
|
+
return panel === '' || (panel.match(/class="cite"/g) || []).length < 8
|
|
2771
|
+
}).map((p) => p.file),
|
|
2772
|
+
[])
|
|
2773
|
+
check('a good number of citations are rendered in total', citations.length >= 150, true)
|
|
2774
|
+
check('the page states what it could not verify', /class="unv">/.test(html), true)
|
|
2775
|
+
|
|
2776
|
+
return { problems, ok, contrastResults }
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
/* ========================================================================== */
|
|
2780
|
+
/* MAIN */
|
|
2781
|
+
/* ========================================================================== */
|
|
2782
|
+
|
|
2783
|
+
const argv = process.argv.slice(2)
|
|
2784
|
+
const verifyOnly = argv.includes('--verify')
|
|
2785
|
+
const outAt = argv.indexOf('--out')
|
|
2786
|
+
const out = outAt >= 0 && argv[outAt + 1] ? resolve(argv[outAt + 1]) : DEFAULT_OUT
|
|
2787
|
+
|
|
2788
|
+
const html = render()
|
|
2789
|
+
const { problems, ok, contrastResults } = verify(html)
|
|
2790
|
+
|
|
2791
|
+
console.log('gen-workflow-map — ' + (verifyOnly ? 'verify only' : 'generate') + '\n')
|
|
2792
|
+
console.log(' WCAG contrast audit (measured from the palette this page ships):')
|
|
2793
|
+
for (const r of contrastResults) {
|
|
2794
|
+
const verdict = r.ratio >= r.need ? 'PASS' : 'FAIL'
|
|
2795
|
+
console.log(' ' + verdict + ' ' + String(r.ratio).padStart(6) + ':1 (needs ' + r.need.toFixed(1) + ') ' + r.pair.padEnd(22) + r.what)
|
|
2796
|
+
}
|
|
2797
|
+
console.log('')
|
|
2798
|
+
|
|
2799
|
+
for (const label of ok) console.log(' PASS ' + label)
|
|
2800
|
+
if (problems.length > 0) {
|
|
2801
|
+
console.log('')
|
|
2802
|
+
for (const p of problems) console.log(' FAIL ' + p)
|
|
2803
|
+
console.log('\n' + problems.length + ' check(s) FAILED. Nothing written.')
|
|
2804
|
+
process.exit(1)
|
|
2805
|
+
}
|
|
2806
|
+
console.log('\n ' + ok.length + ' / ' + ok.length + ' checks passed.')
|
|
2807
|
+
if (!verifyOnly) {
|
|
2808
|
+
mkdirSync(dirname(out), { recursive: true })
|
|
2809
|
+
writeFileSync(out, html, 'utf8')
|
|
2810
|
+
const rel = out.startsWith(ROOT) ? out.slice(ROOT.length + 1).replace(/\\/g, '/') : out
|
|
2811
|
+
console.log(' wrote ' + rel + ' (' + html.length.toLocaleString('en-US') + ' bytes, ' + html.split('\n').length.toLocaleString('en-US') + ' lines)')
|
|
2812
|
+
}
|