Engineering workflow in one resumable DOSSIER.md: dependency-ordered tasks, invariants, drift detection, cross-repo state, a rolling status log, and scout +…

Wave-scoped engineering workflow. One resumable DOSSIER.md drives a wave of work — tasks, bugs, invariants, cross-repo state, evidence log — with in-session quality gates.
Docs live at the repository root, which is not shipped to the install path — these are absolute so they resolve from the plugin cache too:
Local to this plugin:
hooks/register.tsx 178 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { DossierWave } from '../types'
4import { contractPaths, DATE_PREFIX, field } from '../engine/converge.ts'
5import { headerToken } from '../engine/ledger.ts'
6import { needsTree, ownerLine, progress, progressLine, taskRows } from '../engine/progress.ts'
7import { BUILTINS, bugGate, editGate, type Io, ledgerRoot, promptGate, sessionGate, skillGate, skillOf, stopGate, verifyGate } from './gates.ts'
8
9function ioOf($: EngineInterface): Io {
10 return {
11 isDir: async (path) => {
12 try {
13 return (await $.fs.stat(path)).kind === 'dir'
14 } catch {
15 return false
16 }
17 },
18 read: async (path) => String(await $.fs.read(path)),
19 exists: (path) => $.fs.exists(path),
20 list: async (path) => (await $.fs.list(path)).map((entry) => entry.name),
21 env: (name) => {
22 if (name === 'DOSSIER_MARKER_GUARD') return $.env.get('DOSSIER_MARKER_GUARD')
23 if (name === 'DOSSIER_INVARIANT_GUARD') return $.env.get('DOSSIER_INVARIANT_GUARD')
24 return $.env.get('DOSSIER_FAKEIMPL_CMD')
25 },
26 run: (argv, init) => $.process.run(argv, init),
27 cli: `${$.plugin.root}/cli/ds`,
28 }
29}
30
31const seen = new Set<string>()
32const verified = new Set<string>()
33const bugsSeen = new Set<string>()
34const PANE = 'dossier-tasks'
35const wave = atom({ plugin: 'dossier', key: 'wave' } as const, null)
36const GLYPH: Record<string, string> = { x: '✓', '~': '▸', '.': '·', '!': '‼', '?': '?' }
37
38async function readOr($: EngineInterface, path: string): Promise<string | undefined> {
39 try {
40 return String(await $.fs.read(path))
41 } catch {
42 return undefined
43 }
44}
45
46async function liveWave($: EngineInterface, cwd: string): Promise<DossierWave | null> {
47 const root = await ledgerRoot(ioOf($), cwd)
48 const dossiers = `${root}/.scratchpad/dossier`
49 let names: string[]
50 try {
51 names = (await $.fs.list(dossiers)).filter((entry) => entry.kind === 'dir').map((entry) => entry.name)
52 } catch {
53 return null
54 }
55 for (const name of names.sort().reverse()) {
56 const ledger = await readOr($, `${dossiers}/${name}/DOSSIER.md`)
57 if (ledger === undefined || headerToken(ledger) !== 'live') continue
58 let contract: string | undefined
59 for (const path of contractPaths([cwd, root], `${dossiers}/${name}`)) contract ??= await readOr($, path)
60 const milestone = contract ? field(contract, 'milestone') || undefined : undefined
61 return { slug: name.replace(DATE_PREFIX, ''), text: ledger, ...(milestone ? { milestone } : {}) }
62 }
63 return null
64}
65
66async function refresh($: EngineInterface, cwd: string): Promise<void> {
67 const next = await liveWave($, cwd)
68 await update($, wave, (previous) => (previous?.text === next?.text ? previous : next))
69 $.ui.status(next ? progressLine(next.slug, progress(next.text, next.milestone)) : undefined)
70}
71
72export const register: Register = (on) => {
73 on('session.start', async ($, e, next) => {
74 await $.command.register({ name: PANE, description: 'Show the live dossier tasks and their needs in a pane' })
75 await refresh($, e.cwd)
76 return next(e)
77 })
78
79 on('turn.complete', async ($, e, next) => {
80 await refresh($, await $.session.cwd())
81 return next(e)
82 })
83
84 on('command.run', { command: PANE }, async ($) => {
85 await refresh($, await $.session.cwd())
86 await $.ui.open({ id: PANE, title: 'Dossier tasks' })
87 return { text: 'Dossier tasks pane opened.' }
88 })
89
90 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
91 const { Box, Text } = $.ui.resolve(e)
92 const live = await read($, wave)
93 if (!live) return <Text dimColor>No live dossier wave.</Text>
94 const view = progress(live.text, live.milestone)
95 const width = Math.max(20, e.props.bodyColumns)
96 return (
97 <Box flexDirection="column">
98 <Text bold>{progressLine(live.slug, view)}</Text>
99 <Text dimColor>{ownerLine(view)}</Text>
100 {needsTree(taskRows(live.text)).map(({ row, depth, also }) => {
101 const head = `${' '.repeat(depth)}${GLYPH[row.state] ?? row.state} ${row.id}${row.who === 'H' ? ' [you]' : ''} `
102 const tail = also.length ? ` +${also.join(' +')}` : ''
103 const room = Math.max(4, width - head.length - tail.length)
104 const task = row.task.length > room ? `${row.task.slice(0, room - 1)}…` : row.task
105 return (
106 <Text key={row.id} dimColor={row.state === 'x'} bold={row.state === '~'}>
107 {`${head}${task}${tail}`}
108 </Text>
109 )
110 })}
111 </Box>
112 )
113 })
114
115 on('classic.PreToolUse', async ($, e, next) => {
116 const input = e as unknown as Record<string, unknown>
117 const context: string[] = []
118 if (input.tool === 'Skill') {
119 context.push((await skillGate(ioOf($), await ledgerRoot(ioOf($), await $.session.cwd()), skillOf(input), seen)) ?? '')
120 } else if (input.tool === 'Edit' || input.tool === 'Write') {
121 const root = await ledgerRoot(ioOf($), await $.session.cwd())
122 const verdict = await editGate(ioOf($), root, input)
123 if (verdict.deny) return { deny: verdict.deny }
124 context.push(verdict.context ?? '', (await verifyGate(ioOf($), root, input, verified)) ?? '')
125 }
126 const result = await next(e)
127 const added = context.filter(Boolean)
128 if (!added.length) return result
129 return { ...result, additionalContext: [...(result.additionalContext ?? []), ...added] }
130 })
131
132 on('classic.PostToolUse', async ($, e, next) => {
133 const result = await next(e)
134 if (e.tool_name !== 'Edit' && e.tool_name !== 'Write') return result
135 const input = (e.tool_input ?? {}) as Record<string, unknown>
136 const path = typeof input.file_path === 'string' ? input.file_path : ''
137 const context = path ? await bugGate(ioOf($), path, bugsSeen) : undefined
138 if (!context) return result
139 return { ...result, additionalContext: [...(result.additionalContext ?? []), context] }
140 })
141
142 on('classic.UserPromptExpansion', async ($, e, next) => {
143 const result = await next(e)
144 if (!BUILTINS.has(e.command_name) || !e.cwd) return result
145 const context = await skillGate(ioOf($), await ledgerRoot(ioOf($), e.cwd), e.command_name, seen)
146 if (!context) return result
147 return { ...result, additionalContext: [...(result.additionalContext ?? []), context] }
148 })
149
150 on('classic.SessionStart', async ($, e, next) => {
151 const result = await next(e)
152 const root = await ledgerRoot(ioOf($), e.cwd)
153 const verdict = await sessionGate(ioOf($), root, { source: e.source, session_title: e.session_title ?? '' })
154 if (verdict.toast) $.ui.toast(verdict.toast)
155 const away = root === e.cwd ? [] : [`dossier ledger: ${root}/.scratchpad — this session works in ${e.cwd}; read and write the ledger by that absolute path.`]
156 const context = [...(verdict.context ? [verdict.context] : []), ...(verdict.context ? away : [])]
157 return {
158 ...result,
159 ...(context.length ? { additionalContext: [...(result.additionalContext ?? []), ...context] } : {}),
160 ...(verdict.title && !result.sessionTitle ? { sessionTitle: verdict.title } : {}),
161 }
162 })
163
164 on('classic.UserPromptSubmit', async ($, e, next) => {
165 const result = await next(e)
166 const context = await promptGate(ioOf($), await ledgerRoot(ioOf($), e.cwd), e.cwd)
167 if (!context) return result
168 return { ...result, additionalContext: [...(result.additionalContext ?? []), context] }
169 })
170
171 on('classic.Stop', async ($, e, next) => {
172 const result = await next(e)
173 if (result.block) return result
174 const block = await stopGate(ioOf($), e.cwd)
175 return block ? { ...result, block } : result
176 })
177}
178engine/converge.ts 175 lines1import { CELL, splitLines, strip, unicodeRegex } from './text.ts'
2
3const ROW = unicodeRegex('^\\|(.+)\\|\\s*$')
4const EXPECT = unicodeRegex('^(?:exit\\s+\\d+|stdout:\\s*\\S[^\\n]*)$')
5const EXIT = unicodeRegex('^exit\\s+(\\d+)$')
6const DETAIL_MAX = 120
7const RUNTIME_SUFFIXES = new Set(['.py', '.sh', '.js', '.ts', '.go', '.rs', '.rb'])
8const DOC_SUFFIXES = new Set(['.md', '.json', '.txt', '.yml', '.yaml'])
9const TEST_DIRS = new Set(['test', 'tests', '__tests__', 'spec'])
10
11export const DATE_PREFIX = unicodeRegex('^\\d{4}-\\d{2}-\\d{2}-')
12
13export function contractPaths(repos: readonly string[], waveDir: string): string[] {
14 const name = waveDir.split('/').filter(Boolean).pop() ?? ''
15 const stems = [...new Set([name, name.replace(DATE_PREFIX, '')])]
16 return [...repos.flatMap((repo) => stems.map((stem) => `${repo}/.dossier/${stem}.md`)), `${waveDir}/CONTRACT.md`]
17}
18
19function rstrip(text: string): string {
20 return text.replace(unicodeRegex('\\s+$'), '')
21}
22
23export function cells(line: string): string[] {
24 const match = ROW.exec(rstrip(line))
25 if (!match) return []
26 return (match[1] ?? '').split(CELL).map((cell) => strip(cell.replaceAll('\\|', '|')))
27}
28
29export function shlexWords(text: string): string[] | undefined {
30 const words: string[] = []
31 let word = ''
32 let started = false
33 let quote = ''
34 for (let i = 0; i < text.length; i++) {
35 const char = text[i] ?? ''
36 if (quote === "'") {
37 if (char === "'") quote = ''
38 else word += char
39 continue
40 }
41 if (quote === '"') {
42 if (char === '"') quote = ''
43 else if (char === '\\' && (text[i + 1] === '"' || text[i + 1] === '\\')) word += text[++i]
44 else word += char
45 continue
46 }
47 if (char === '\\') {
48 if (i + 1 >= text.length) return undefined
49 word += text[++i]
50 started = true
51 } else if (char === "'" || char === '"') {
52 quote = char
53 started = true
54 } else if (/[ \t\r\n]/.test(char)) {
55 if (started) words.push(word)
56 word = ''
57 started = false
58 } else {
59 word += char
60 started = true
61 }
62 }
63 if (quote) return undefined
64 if (started) words.push(word)
65 return words
66}
67
68export function isCommand(text: string): boolean {
69 if (!(text.startsWith('`') && text.endsWith('`') && text.length > 2)) return false
70 return (shlexWords(text.slice(1, -1))?.length ?? 0) > 0
71}
72
73export function numberedRows(text: string): string[][] {
74 const at = text.indexOf('## done-when')
75 if (at < 0) return []
76 const body = text.slice(at + '## done-when'.length).split('\n## ')[0] ?? ''
77 return splitLines(body)
78 .map(cells)
79 .filter((row) => row.length > 0 && /^\p{Nd}+$/u.test(row[0] ?? ''))
80}
81
82export function field(text: string, name: string): string {
83 for (const line of splitLines(text)) {
84 const row = cells(line)
85 if (row.length >= 2 && (row[0] ?? '').toLowerCase() === name) return row[1] ?? ''
86 }
87 return ''
88}
89
90export function hasConsumer(text: string): boolean {
91 return splitLines(text).some((line) => {
92 const row = cells(line)
93 return row.length >= 2 && (row[0] ?? '').toLowerCase() === 'consumer' && Boolean(row[1])
94 })
95}
96
97export function readableExpect(expect: string): boolean {
98 return EXPECT.test(strip(expect))
99}
100
101export function met(expect: string, code: number, out: string): boolean {
102 const wanted = strip(expect)
103 if (wanted.startsWith('stdout:')) {
104 const text = strip(wanted.slice('stdout:'.length))
105 if (text === '(nothing)') return code === 0 && strip(out) === ''
106 return code === 0 && out.includes(text)
107 }
108 const exit = EXIT.exec(wanted)
109 return exit ? code === Number(exit[1]) : false
110}
111
112export function detail(stream: string): string {
113 for (const raw of splitLines(stream)) {
114 const line = strip(raw)
115 if (line) return [...line].slice(0, DETAIL_MAX).join('')
116 }
117 return ''
118}
119
120function escaped(point: number): string {
121 if (point < 0x100) return `\\x${point.toString(16).padStart(2, '0')}`
122 if (point < 0x10000) return `\\u${point.toString(16).padStart(4, '0')}`
123 return `\\U${point.toString(16).padStart(8, '0')}`
124}
125
126export function pyRepr(text: string): string {
127 const quote = text.includes("'") && !text.includes('"') ? '"' : "'"
128 let out = ''
129 for (const char of text) {
130 const point = char.codePointAt(0) ?? 0
131 if (char === '\\') out += '\\\\'
132 else if (char === quote) out += `\\${quote}`
133 else if (char === '\n') out += '\\n'
134 else if (char === '\r') out += '\\r'
135 else if (char === '\t') out += '\\t'
136 else if (char !== ' ' && /[\p{C}\p{Z}]/u.test(char)) out += escaped(point)
137 else out += char
138 }
139 return `${quote}${out}${quote}`
140}
141
142function suffixOf(name: string): string {
143 const at = name.lastIndexOf('.')
144 return at <= 0 || at === name.length - 1 ? '' : name.slice(at)
145}
146
147function isTest(path: string): boolean {
148 const parts = path.split('/').filter((part) => part !== '' && part !== '.')
149 const name = parts[parts.length - 1] ?? ''
150 const stem = name.slice(0, name.length - suffixOf(name).length)
151 return (
152 parts.some((part) => TEST_DIRS.has(part)) ||
153 name.startsWith('test_') ||
154 ['_test', '.test', '_spec', '.spec'].some((end) => stem.endsWith(end))
155 )
156}
157
158export function layerMix(numstat: string): string {
159 if (!strip(numstat)) return ''
160 let runtime = 0
161 let docs = 0
162 let tests = 0
163 for (const line of splitLines(numstat)) {
164 const parts = line.split('\t')
165 if (parts.length !== 3 || !/^\p{Nd}+$/u.test(parts[0] ?? '') || !/^\p{Nd}+$/u.test(parts[1] ?? '')) continue
166 const changed = Number(parts[0]) + Number(parts[1])
167 const path = parts[2] ?? ''
168 const suffix = suffixOf(path.split('/').pop() ?? '').toLowerCase()
169 if (isTest(path)) tests += changed
170 else if (RUNTIME_SUFFIXES.has(suffix)) runtime += changed
171 else if (DOC_SUFFIXES.has(suffix)) docs += changed
172 }
173 return ` lines since contract: runtime ${runtime} · tests ${tests} · docs ${docs}`
174}
175engine/ledger.ts 494 lines1import { cells } from './converge.ts'
2import { CELL, strip, unicodeRegex } from './text.ts'
3
4const SPACE = '[ \\t\\n\\r\\f\\v]'
5
6function section(tag: string, word: string): RegExp {
7 return new RegExp(`^## (§${tag}([^A-Za-z]|$)|${word}(${SPACE}|$))`)
8}
9
10export const SEC = {
11 any: /^## /,
12 goal: section('G', 'Goal'),
13 constraints: section('C', 'Constraints'),
14 interfaces: section('I', 'Interfaces'),
15 invariants: section('V', 'Invariants'),
16 tasks: section('T', 'Tasks'),
17 bugs: section('B', 'Bugs'),
18 repos: section('X', 'Repos'),
19 status: section('S', 'Status'),
20 closeout: section('Z', 'Closeout'),
21}
22
23export const Z = {
24 complete: new RegExp(`^${SPACE}*complete:${SPACE}+true`),
25 abandoned: new RegExp(`^${SPACE}*abandoned:${SPACE}+true`),
26 successor: new RegExp(`^${SPACE}*successor:${SPACE}+[a-z0-9][a-z0-9-]*(${SPACE}|$)`),
27 slug: /^[a-z0-9][a-z0-9-]*$/,
28}
29
30export const HEADER = unicodeRegex('^`\\d[^`]*`\\s+·\\s+`([^`]*)`(?:\\s+·\\s|\\s*$)')
31const TASK_ROW = /^\|[ \t\n\r\f\v]*T[0-9]+[ \t\n\r\f\v]*\|/
32const BUG_ROW = /^\|[ \t\n\r\f\v]*B[0-9]+[ \t\n\r\f\v]*\|/
33const RULE_ROW = /^[-|:+\s]*$/
34
35export type Result = { text: string } | { error: string }
36
37export function lines(text: string): string[] {
38 if (text === '') return []
39 const out = text.split('\n')
40 if (out[out.length - 1] === '') out.pop()
41 return out
42}
43
44export function unlines(rows: string[]): string {
45 return rows.map((row) => `${row}\n`).join('')
46}
47
48export function trim(text: string): string {
49 return text.replace(/^[ \t]+|[ \t]+$/g, '')
50}
51
52function closureLine(line: string): boolean {
53 return Z.complete.test(line) || Z.abandoned.test(line) || Z.successor.test(line)
54}
55
56function* inSection(rows: string[], pattern: RegExp): Generator<[number, string, boolean]> {
57 let inside = false
58 for (let i = 0; i < rows.length; i++) {
59 const row = rows[i] ?? ''
60 if (pattern.test(row)) {
61 inside = true
62 yield [i, row, false]
63 continue
64 }
65 if (SEC.any.test(row)) inside = false
66 yield [i, row, inside]
67 }
68}
69
70function columns(header: string, names: string[]): Record<string, number> {
71 const cells = header.split(CELL)
72 const found: Record<string, number> = {}
73 for (let i = 1; i < cells.length - 1; i++) {
74 const name = trim(cells[i] ?? '')
75 if (names.includes(name)) found[name] = i
76 }
77 return found
78}
79
80export function rowFlip(text: string, file: string, id: string, state: string, cite: string): Result {
81 const rows = lines(text)
82 let found = 0
83 let header = false
84 let colState = 0
85 let colCite = 0
86 let rowCells = 0
87 let current = ''
88 for (const [, row, inside] of inSection(rows, SEC.tasks)) {
89 if (SEC.tasks.test(row)) {
90 header = false
91 colState = 0
92 colCite = 0
93 continue
94 }
95 if (!inside || !row.startsWith('|')) continue
96 const cells = row.split(CELL)
97 if (!header) {
98 header = true
99 const named = columns(row, ['state', 'cite'])
100 colState = named.state ?? 0
101 colCite = named.cite ?? 0
102 continue
103 }
104 if (trim(cells[1] ?? '') === id) {
105 found++
106 rowCells = cells.length
107 current = colCite ? trim(cells[colCite] ?? '') : ''
108 }
109 }
110 if (!colState || !colCite) {
111 return { error: `Tasks header in ${file} names no state/cite column; refusing to edit by position` }
112 }
113 if (found === 1 && (colState >= rowCells - 1 || colCite >= rowCells - 1)) {
114 return { error: `row ${id} has ${rowCells - 2} cells, fewer than the header names; refusing to write past its end` }
115 }
116 if (found === 0) return { error: `row id ${id} not found in the Tasks section of ${file}` }
117 if (found > 1) return { error: `row id ${id} matches ${found} Tasks rows (ambiguous)` }
118 if (state === 'x') {
119 const effective = (cite || current).replace(/[ \t\n\r\f\v]/g, '')
120 if (effective === '' || effective === '—' || effective === '-') {
121 return {
122 error: `${id} -> x requires a cite (Vm.3); ds vm-checks reads "", "—" and "-" alike as empty, so pass a real cite or set the row cite first`,
123 }
124 }
125 }
126 const out: string[] = []
127 header = false
128 for (const [, row, inside] of inSection(rows, SEC.tasks)) {
129 if (SEC.tasks.test(row)) header = false
130 if (!inside || !row.startsWith('|')) {
131 out.push(row)
132 continue
133 }
134 if (!header) {
135 header = true
136 out.push(row)
137 continue
138 }
139 const cells = row.split(CELL)
140 if (trim(cells[1] ?? '') !== id) {
141 out.push(row)
142 continue
143 }
144 cells[colState] = ` ${state} `
145 if (cite !== '') cells[colCite] = ` ${cite.replaceAll('|', '\\|')} `
146 out.push(`|${cells.slice(1, -1).join('|')}|`)
147 }
148 return { text: unlines(out) }
149}
150
151export function sAppend(text: string, entry: string): string {
152 const out: string[] = []
153 let done = false
154 for (const row of lines(text)) {
155 if (!done && SEC.closeout.test(row)) {
156 out.push(entry, '')
157 done = true
158 }
159 out.push(row)
160 }
161 return done ? unlines(out) : `${unlines(out)}\n${entry}\n`
162}
163
164export function headerIndex(rows: readonly string[]): number {
165 const end = rows.findIndex((row) => row.startsWith('## '))
166 return rows.slice(0, end < 0 ? rows.length : end).findIndex((row) => HEADER.test(row))
167}
168
169export function headerState(text: string, state: string): string | undefined {
170 const rows = lines(text)
171 const at = headerIndex(rows)
172 if (at < 0) return undefined
173 const parts = (rows[at] ?? '').split('`')
174 parts[3] = state
175 rows[at] = parts.join('`')
176 return unlines(rows)
177}
178
179export function headerToken(text: string): string {
180 const rows = lines(text)
181 return strip(HEADER.exec(rows[headerIndex(rows)] ?? '')?.[1] ?? '')
182}
183
184function repoRows(text: string): Generator<[number, string, boolean]> {
185 return inSection(lines(text), SEC.repos)
186}
187
188export function hasRepo(text: string, label: string): boolean {
189 for (const [, row, inside] of repoRows(text)) {
190 if (inside && row.startsWith('|') && trim(row.split(CELL)[1] ?? '') === label) return true
191 }
192 return false
193}
194
195export function xRefresh(text: string, label: string, values: [string, string, string, string]): string {
196 const out: string[] = []
197 for (const [, row, inside] of repoRows(text)) {
198 if (!inside || !row.startsWith('|')) {
199 out.push(row)
200 continue
201 }
202 const cells = row.split(CELL)
203 if (trim(cells[1] ?? '') !== label) {
204 out.push(row)
205 continue
206 }
207 const width = cells.length
208 values.forEach((value, i) => {
209 cells[i + 2] = ` ${value} `
210 })
211 out.push(`|${cells.slice(1, width - 1).join('|')}|`)
212 }
213 return unlines(out)
214}
215
216export function zWrite(
217 text: string,
218 file: string,
219 kind: string,
220 value: string,
221 summary: string,
222 cites: string,
223 stamp: string,
224 after = '',
225): Result {
226 let body: string
227 if (kind === 'complete') body = 'complete: true'
228 else if (kind === 'successor') {
229 if (!Z.slug.test(value)) return { error: `successor takes a slug (${Z.slug.source}), got "${value}"` }
230 body = `successor: ${value}`
231 } else if (kind === 'abandoned') {
232 if (value === '') return { error: 'abandoned requires a reason' }
233 body = `abandoned: true\n\nreason: ${value}`
234 } else return { error: `invalid kind "${kind}" (complete|successor|abandoned)` }
235 const checks: [string, string][] = [
236 ['summary', `summary: ${summary}`],
237 ['key cites', `key cites: ${cites}`],
238 ]
239 if (kind === 'abandoned') checks.push(['reason', `reason: ${value}`])
240 if (after) checks.push(['after', `after: ${after}`])
241 for (const [label, rendered] of checks) {
242 if (lines(rendered).some(closureLine)) {
243 return { error: `${label} reads as a §Z closure key and would flip the INDEX §Z column; reword it` }
244 }
245 }
246 const rows = lines(text)
247 const at = rows.findIndex((row) => SEC.closeout.test(row))
248 if (at < 0) return { error: `no Closeout heading in ${file}` }
249 const head = unlines(rows.slice(0, at))
250 const carried = after ? `\n\nafter: ${after}` : ''
251 return { text: `${head}${rows[at]}\n\n${stamp} — closed\n\n${body}${carried}\n\nsummary: ${summary}\n\nkey cites: ${cites}\n` }
252}
253
254export function missingSections(text: string): string[] {
255 const rows = lines(text)
256 const missing: string[] = []
257 if (!rows.some((row) => row.startsWith('# '))) missing.push('title')
258 const required: [string, RegExp][] = [
259 ['Goal', SEC.goal],
260 ['Constraints', SEC.constraints],
261 ['Interfaces', SEC.interfaces],
262 ['Invariants', SEC.invariants],
263 ['Tasks', SEC.tasks],
264 ['Bugs', SEC.bugs],
265 ['Repos', SEC.repos],
266 ['Status', SEC.status],
267 ['Closeout', SEC.closeout],
268 ]
269 for (const [name, pattern] of required) if (!rows.some((row) => pattern.test(row))) missing.push(name)
270 return missing
271}
272
273function closeoutLines(text: string): string[] {
274 const rows = lines(text)
275 const at = rows.findIndex((row) => SEC.closeout.test(row))
276 return at < 0 ? [] : rows.slice(at)
277}
278
279export function zClosed(text: string): boolean {
280 return closeoutLines(text).some(closureLine)
281}
282
283export function zColumn(text: string): string {
284 const rows = closeoutLines(text)
285 if (rows.some((row) => Z.complete.test(row))) return 'complete'
286 if (rows.some((row) => Z.abandoned.test(row))) return 'abandoned'
287 const hit = rows.map((row) => Z.successor.exec(row)?.[0]).find((match) => match !== undefined)
288 if (hit === undefined) return '—'
289 return `→${hit.replace(/^.*successor:[ \t\n\r\f\v]*/, '').replace(/[ \t\n\r\f\v]+$/, '')}`
290}
291
292export const STAMP = '[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}(?::[0-9]{2})?'
293const STAMPED = new RegExp(`^${STAMP}`)
294const ENTRY = new RegExp(`^(?:${STAMP} )?(ds:[a-z]+) (\\S+) (.*)$`)
295const JOINED = new RegExp(` (?=${STAMP} ds:[a-z]+ )`)
296
297export type Cell = (name: string) => string
298
299export function namedRows(text: string, heading: RegExp): Cell[] {
300 const out: Cell[] = []
301 let inside = false
302 let names: string[] = []
303 for (const row of lines(text)) {
304 if (SEC.any.test(row)) {
305 inside = heading.test(row)
306 names = []
307 continue
308 }
309 const values = inside && !RULE_ROW.test(row) ? cells(row) : []
310 if (!values.length) continue
311 if (!names.length) {
312 names = values.map((name) => name.toLowerCase())
313 continue
314 }
315 const named = names
316 out.push((name) => values[named.indexOf(name)] ?? '')
317 }
318 return out
319}
320
321export function unpushedRepos(text: string): string[] {
322 return namedRows(text, SEC.repos)
323 .filter((row) => !['', 'yes'].includes(row('pushed').toLowerCase()))
324 .map((row) => row('repo'))
325}
326
327export function indexNames(index: string, state: string): string[] {
328 const [names = [], ...body] = lines(index).map(cells).filter((row) => row.length)
329 const at = (name: string): number => names.indexOf(name)
330 return body.filter((row) => row[at('state')] === state).map((row) => `${row[at('date')]}-${row[at('slug')]}`)
331}
332
333export function sectionRows(text: string, heading: RegExp): string[] {
334 const out: string[] = []
335 let inside = false
336 for (const row of lines(text)) {
337 if (SEC.any.test(row)) inside = heading.test(row)
338 else if (inside) out.push(row)
339 }
340 return out
341}
342
343export function statusSection(text: string): string[] {
344 return sectionRows(text, SEC.status)
345}
346
347export function openOps(entries: string[]): Map<string, string> {
348 const open = new Map<string, string>()
349 const pending = new Map<string, string>()
350 for (const entry of entries.flatMap((line) => trim(line).split(JOINED))) {
351 const match = ENTRY.exec(entry)
352 if (!match) continue
353 const [, verb = '', target = '', rest = ''] = match
354 const key = `${verb} ${target}`
355 const events = rest.split(/;[ \t]*/).map((part) => part.trim().split(/[ \t]+/)[0] ?? '')
356 if (events.includes('START')) {
357 open.set(key, entry)
358 if (target === 'pending') pending.set(verb, key)
359 }
360 if (events.includes('DONE')) {
361 open.delete(key)
362 const parked = pending.get(verb)
363 if (parked !== undefined) {
364 open.delete(parked)
365 pending.delete(verb)
366 }
367 }
368 }
369 return open
370}
371
372export function vmFindings(file: string, text: string): string[] {
373 const out: string[] = []
374 const status: string[] = []
375 let sec = ''
376 let header = false
377 let colState = 0
378 let colCite = 0
379 for (const row of lines(text)) {
380 if (SEC.any.test(row)) {
381 if (SEC.status.test(row)) sec = 'S'
382 else if (SEC.tasks.test(row)) {
383 sec = 'T'
384 header = false
385 colState = 0
386 colCite = 0
387 } else sec = 'other'
388 continue
389 }
390 if (sec === 'S') {
391 const line = trim(row)
392 if (line === '' || line.startsWith('<!--')) continue
393 if (!STAMPED.test(line)) {
394 out.push(`WARN Vm.2 ${file}: Status line missing ISO timestamp: ${line}`)
395 }
396 status.push(line)
397 }
398 if (sec === 'T') {
399 if (!row.startsWith('|') || RULE_ROW.test(row)) continue
400 const cells = row.split(CELL)
401 if (!header) {
402 header = true
403 const named = columns(row, ['state', 'cite'])
404 colState = named.state ?? 0
405 colCite = named.cite ?? 0
406 if (!colState || !colCite) {
407 const missing = !colState && !colCite ? 'state or cite' : colState ? 'cite' : 'state'
408 out.push(`WARN Vm.3 ${file}: Tasks header names no ${missing} column, so its rows go unchecked`)
409 }
410 continue
411 }
412 if (!colState || !colCite) continue
413 const id = trim(cells[1] ?? '')
414 const state = trim(cells[colState] ?? '')
415 const cite = trim(cells[colCite] ?? '')
416 if (id === 'id' || id === '') continue
417 if (state === 'x' && (cite === '' || cite === '—' || cite === '-')) {
418 out.push(`CRITICAL Vm.3 ${file}: Tasks row ${id} state=x has empty cite`)
419 }
420 }
421 }
422 for (const key of openOps(status).keys()) out.push(`WARN Vm.6 ${file}: Status ${key} START without DONE`)
423 return out
424}
425
426export type IndexRow = { date: string; slug: string; state: string; row: string }
427
428function taskCount(rows: string[]): { done: number; total: number; noState: boolean } {
429 let header = false
430 let colState = 0
431 let done = 0
432 let total = 0
433 let noState = false
434 for (const [, row, inside] of inSection(rows, SEC.tasks)) {
435 if (SEC.tasks.test(row)) {
436 header = false
437 colState = 0
438 continue
439 }
440 if (!inside) continue
441 if (TASK_ROW.test(row)) total++
442 if (!row.startsWith('|') || RULE_ROW.test(row)) continue
443 if (!header) {
444 header = true
445 colState = columns(row, ['state']).state ?? 0
446 if (!colState) noState = true
447 continue
448 }
449 if (TASK_ROW.test(row) && colState && trim(row.split(CELL)[colState] ?? '') === 'x') done++
450 }
451 return { done, total, noState }
452}
453
454export function indexRow(dir: string, text: string, archived: boolean, mtime: string): IndexRow & { warning?: string } {
455 const chars = [...dir]
456 const date = chars.slice(0, 10).join('')
457 const slug = chars.slice(11).join('')
458 const rows = lines(text)
459 const token = headerToken(text)
460 const { done, total, noState } = taskCount(rows)
461 let bugs = 0
462 for (const [, row, inside] of inSection(rows, SEC.bugs)) if (inside && BUG_ROW.test(row)) bugs++
463 const z = zColumn(text)
464 const closed = z !== '—'
465 let state: string
466 if (archived) state = token === 'done' ? 'done' : 'drift!'
467 else if (closed) state = 'drift!'
468 else if (token === 'live' || token === 'paused') state = token
469 else state = 'drift!'
470 const row = `| ${date} | ${slug} | ${state} | ${done}/${total} | ${bugs} | ${mtime} | ${z} |`
471 return { date, slug, state, row, ...(noState ? { warning: 'Tasks header names no state column, so its rows count as not-done' } : {}) }
472}
473
474const RANK: Record<string, number> = { 'drift!': 0, live: 1, paused: 2 }
475
476function byCodeUnit(a: string, b: string): number {
477 return a < b ? -1 : a > b ? 1 : 0
478}
479
480export function renderIndex(rows: IndexRow[]): string {
481 const sorted = [...rows].sort(
482 (a, b) =>
483 byCodeUnit(b.date, a.date) ||
484 (RANK[a.state] ?? 3) - (RANK[b.state] ?? 3) ||
485 byCodeUnit(a.row, b.row),
486 )
487 let out =
488 '# .scratchpad index\n\n| date | slug | state | T | B | mtime | §Z |\n|------|------|-------|---|---|-------|-----|\n'
489 out += sorted.map((row) => `${row.row}\n`).join('')
490 const drift = rows.filter((row) => row.state === 'drift!')
491 if (drift.length) out += `\n<!-- drift:${drift.length} slugs:${drift.map((row) => `${row.date}-${row.slug}`).join(' ')} -->\n`
492 return out
493}
494engine/progress.ts 158 lines1import { cells } from './converge.ts'
2import { splitLines } from './text.ts'
3
4export type Count = { done: number; total: number }
5
6export type Progress = {
7 all: Count
8 agent: Count
9 operator: Count
10 milestone?: Count & { id: string }
11 active: string[]
12 tail: string[]
13 next: string[]
14 waiting: string[]
15}
16
17export type Row = { id: string; state: string; who: string; task: string; needs: string[]; cite: string; verify: string }
18
19const TASK_ID = /^T\d+$/
20export const DELAYED = /^(T\d+)\+\d+d$/
21
22export function taskRows(text: string): Row[] {
23 const lines = splitLines(text)
24 const start = lines.findIndex((line) => /^##\s+(§T\b|Tasks\b)/.test(line))
25 if (start < 0) return []
26 const rows: Row[] = []
27 let names: string[] = []
28 for (const line of lines.slice(start + 1)) {
29 if (line.startsWith('## ')) break
30 const row = cells(line)
31 if (!row.length) continue
32 if (!names.length) {
33 names = row.map((name) => name.toLowerCase())
34 continue
35 }
36 const at = (name: string): string => row[names.indexOf(name)] ?? ''
37 if (!TASK_ID.test(at('id'))) continue
38 const needs = at('needs')
39 .split(',')
40 .map((need) => need.trim())
41 .filter((need) => need && need !== '—' && need !== '-')
42 rows.push({ id: at('id'), state: at('state'), who: at('who').toUpperCase(), task: at('task'), needs, cite: at('cite'), verify: at('verify') })
43 }
44 return rows
45}
46
47function count(rows: Row[]): Count {
48 return { done: rows.filter((row) => row.state === 'x').length, total: rows.length }
49}
50
51export function progress(text: string, milestone?: string): Progress {
52 const rows = taskRows(text)
53 const done = new Set(rows.filter((row) => row.state === 'x').map((row) => row.id))
54 const ready = (row: Row): boolean => row.state === '.' && row.needs.every((need) => !DELAYED.test(need) && done.has(need))
55 const view: Progress = {
56 all: count(rows),
57 agent: count(rows.filter((row) => row.who === 'A')),
58 operator: count(rows.filter((row) => row.who === 'H')),
59 active: rows.filter((row) => row.state === '~').map((row) => row.id),
60 tail: rows.flatMap((row) => (row.state === 'x' ? [] : row.needs.filter((need) => DELAYED.test(need)).map((need) => `${row.id} ${need}`))),
61 next: rows.filter((row) => row.who === 'A' && ready(row)).map((row) => row.id),
62 waiting: rows.filter((row) => row.who === 'H' && ready(row)).map((row) => row.id),
63 }
64 if (milestone !== undefined && rows.some((row) => row.id === milestone)) {
65 const after = downstream(rows, milestone)
66 view.milestone = { id: milestone, ...count(rows.filter((row) => !after.has(row.id))) }
67 }
68 return view
69}
70
71function downstream(rows: Row[], id: string): Set<string> {
72 const after = new Set<string>()
73 const reaches = (row: Row): boolean =>
74 row.needs.some((need) => {
75 const target = DELAYED.exec(need)?.[1] ?? need
76 return target === id || after.has(target)
77 })
78 let grew = true
79 while (grew) {
80 grew = false
81 for (const row of rows) {
82 if (after.has(row.id) || !reaches(row)) continue
83 after.add(row.id)
84 grew = true
85 }
86 }
87 return after
88}
89
90function percent({ done, total }: Count): string {
91 return `${total ? Math.floor((done * 100) / total) : 0}%`
92}
93
94export function progressLine(slug: string, view: Progress): string {
95 const head = `${slug} ${percent(view.all)} · ${view.all.done}/${view.all.total}`
96 if (view.all.total && view.all.done === view.all.total) return `${head} · ready to close`
97 const later = [...new Set(view.tail.map((entry) => entry.split(' ')[0] ?? ''))]
98 if (later.length && later.length === view.all.total - view.all.done) return `${head} · ready to close · carry ${later.join(' ')}`
99 const parts = [head]
100 if (view.next[0]) parts.push(`next ${view.next[0]}`)
101 if (view.waiting.length) parts.push(`waits on you: ${view.waiting.join(' ')}`)
102 if (!view.next.length && !view.waiting.length && view.active.length) parts.push(`in progress ${view.active.join(' ')}`)
103 return parts.join(' · ')
104}
105
106export type Branch = { row: Row; depth: number; also: string[] }
107
108export function needsTree(rows: readonly Row[]): Branch[] {
109 const ids = new Set(rows.map((row) => row.id))
110 const parents = (row: Row): string[] => row.needs.map((need) => DELAYED.exec(need)?.[1] ?? need).filter((id) => ids.has(id))
111 const out: Branch[] = []
112 const placed = new Set<string>()
113 const walk = (row: Row, depth: number): void => {
114 if (placed.has(row.id)) return
115 placed.add(row.id)
116 out.push({ row, depth, also: parents(row).slice(1) })
117 for (const child of rows) if (parents(child)[0] === row.id) walk(child, depth + 1)
118 }
119 for (const row of rows) if (!parents(row).length) walk(row, 0)
120 for (const row of rows) walk(row, 0)
121 return out
122}
123
124export function ownerLine(view: Progress): string {
125 const parts = [`Agent ${view.agent.done}/${view.agent.total}`, `You ${view.operator.done}/${view.operator.total}`]
126 if (view.milestone) parts.push(`Up to ${view.milestone.id} ${view.milestone.done}/${view.milestone.total}`)
127 if (view.active.length) parts.push(`in progress ${view.active.join(' ')}`)
128 if (view.tail.length) parts.push(`later ${view.tail.map((entry) => entry.replace(' ', ' (') + ')').join(', ')}`)
129 return parts.join(' · ')
130}
131
132export function progressTable(slug: string, view: Progress): string {
133 const rows: [string, Count][] = [
134 ['Agent', view.agent],
135 ['You', view.operator],
136 ]
137 if (view.milestone) rows.push([`Up to ${view.milestone.id}`, view.milestone])
138 const width = Math.max(5, ...rows.map(([label]) => label.length))
139 const out = [`${slug} · ${view.all.done} of ${view.all.total} done · ${percent(view.all)}`, '']
140 out.push(`${'Owner'.padEnd(width)} Done Total Share`)
141 for (const [label, value] of rows) {
142 out.push(`${label.padEnd(width)} ${String(value.done).padEnd(4)} ${String(value.total).padEnd(5)} ${percent(value)}`)
143 }
144 if (view.active.length) out.push('', `In progress, not counted: ${view.active.join(' ')}`)
145 if (view.tail.length) out.push(`After the milestone: ${view.tail.join(', ')}`)
146 return out.join('\n')
147}
148
149const LATER_DAYS = 30
150
151export function laterLine(slug: string, text: string, now: Date): string | undefined {
152 const after = /^after: (.+)$/m.exec(text)?.[1]
153 const closed = /^(\d{4}-\d{2}-\d{2}) \d{2}:\d{2} — closed$/m.exec(text)?.[1]
154 if (!after || !closed) return undefined
155 const age = (now.getTime() - Date.parse(`${closed}T00:00:00Z`)) / 86_400_000
156 return age <= LATER_DAYS ? `later: ${slug} · ${after} · closed ${closed}` : undefined
157}
158hooks/gates.ts 295 lines1import { cells } from '../engine/converge.ts'
2import { editOf, headerDenial, isDossierPath, markerDenial } from '../engine/guards.ts'
3import { indexNames, SEC } from '../engine/ledger.ts'
4import { byCodePoint } from '../engine/text.ts'
5
6export type Io = {
7 isDir: (path: string) => Promise<boolean>
8 read: (path: string) => Promise<string>
9 exists: (path: string) => Promise<boolean>
10 list: (path: string) => Promise<string[]>
11 env: (name: 'DOSSIER_MARKER_GUARD' | 'DOSSIER_INVARIANT_GUARD' | 'DOSSIER_FAKEIMPL_CMD') => Promise<string | undefined>
12 run: (
13 argv: string[],
14 init: { stdin: string; timeoutMs: number; cwd?: string },
15 ) => Promise<{ exitCode: number; stdout: string; stderr: string }>
16 cli?: string
17}
18
19export type Verdict = { deny?: string; context?: string }
20
21const INVARIANT_TIMEOUT_MS = 5000
22const VERIFY_TIMEOUT_MS = 10_000
23const VERIFY_FOOTER = '\nsilence a rule for this write: `# verify-skip: <ruleName>` anywhere in the content.'
24const SESSION_TIMEOUT_MS = 60_000
25const PROMPT_TIMEOUT_MS = 10_000
26const STOP_TIMEOUT_MS = 180_000
27const EXIT_NO_NODE = 69
28export const BUILTINS = new Set(['code-review', 'review', 'security-review', 'simplify'])
29const CLOSE = 'dossier:close'
30const GRILLING = new Set(['mattpocock-skills:grilling', 'grilling'])
31const EMPTY_CELL = new Set(['', '—', '-'])
32export const WHETSTONE = new Set([
33 'whetstone:doubt-pass',
34 'whetstone:flaky-test-audit',
35 'whetstone:merge-resolve',
36 'whetstone:skill-smith',
37 'whetstone:tdd-cycle',
38 'whetstone:tiger-style',
39])
40
41async function invariantGate(io: Io, root: string, filePath: string, chunks: string[]): Promise<Verdict> {
42 if ((await io.env('DOSSIER_INVARIANT_GUARD')) === 'off') return {}
43 if (!(await io.exists(`${root}/.scratchpad/dossier/.invariant-guards.json`))) return {}
44 try {
45 const argv = ['sh', io.cli ?? 'cli/ds', 'invariant-check', root]
46 const done = await io.run(argv, { stdin: JSON.stringify({ file_path: filePath, chunks }), timeoutMs: INVARIANT_TIMEOUT_MS })
47 const text = done.stdout.trim()
48 if (done.exitCode === 1 && text) return { deny: text }
49 if (done.exitCode === 0 && text) return { context: text }
50 } catch {
51 return {}
52 }
53 return {}
54}
55
56const PRIMARY = /^worktree (.+)$/m
57
58const roots = new Map<string, string>()
59
60async function gitOut(io: Io, args: string[]): Promise<string | undefined> {
61 try {
62 const done = await io.run(['git', ...args], { stdin: '', timeoutMs: 5000 })
63 return done.exitCode === 0 ? done.stdout : undefined
64 } catch {
65 return undefined
66 }
67}
68
69export async function ledgerRoot(io: Io, cwd: string): Promise<string> {
70 if (await io.isDir(`${cwd}/.scratchpad/dossier`)) return cwd
71 const known = roots.get(cwd)
72 if (known) return known
73 const top = (await gitOut(io, ['-C', cwd, 'rev-parse', '--show-toplevel']))?.trim()
74 const primary = PRIMARY.exec((await gitOut(io, ['-C', cwd, 'worktree', 'list', '--porcelain'])) ?? '')?.[1]
75 let root = cwd
76 for (const dir of [top, primary]) {
77 if (dir && (await io.isDir(`${dir}/.scratchpad/dossier`))) {
78 root = dir
79 break
80 }
81 }
82 roots.set(cwd, root)
83 return root
84}
85
86export async function editGate(io: Io, root: string, input: Record<string, unknown>): Promise<Verdict> {
87 const edit = editOf(input)
88 if (!edit) return {}
89 if (!(await io.isDir(`${root}/.scratchpad/dossier`))) return {}
90 const guarded = (await io.env('DOSSIER_MARKER_GUARD')) !== 'off'
91 const header = guarded ? headerDenial(edit) : undefined
92 if (header) return { deny: header }
93 if (isDossierPath(edit.filePath)) return {}
94 const marker = guarded ? markerDenial(edit) : undefined
95 if (marker) return { deny: marker }
96 return invariantGate(io, root, edit.filePath, edit.chunks)
97}
98
99export async function verifyGate(io: Io, root: string, input: Record<string, unknown>, fired: Set<string>): Promise<string | undefined> {
100 const edit = editOf(input)
101 const content = edit?.chunks[0]
102 if (!edit || !content || isDossierPath(edit.filePath)) return undefined
103 if (!(await io.isDir(`${root}/.scratchpad/dossier`))) return undefined
104 let done: { exitCode: number; stdout: string }
105 try {
106 const argv = ['sh', io.cli ?? 'cli/ds', 'verify-edit', root]
107 done = await io.run(argv, { stdin: JSON.stringify({ file_path: edit.filePath, content }), timeoutMs: VERIFY_TIMEOUT_MS })
108 } catch {
109 return undefined
110 }
111 if (done.exitCode !== 0 || !done.stdout.trim()) return undefined
112 let hits: unknown
113 try {
114 hits = JSON.parse(done.stdout)
115 } catch {
116 return undefined
117 }
118 if (!Array.isArray(hits)) return undefined
119 const lines: string[] = []
120 for (const hit of hits as { key?: unknown; line?: unknown }[]) {
121 if (typeof hit?.key !== 'string' || typeof hit.line !== 'string' || fired.has(hit.key)) continue
122 fired.add(hit.key)
123 lines.push(hit.line)
124 }
125 return lines.length ? `${lines.join('\n')}${VERIFY_FOOTER}` : undefined
126}
127
128async function inFlight(io: Io, root: string, live: string[]): Promise<string | undefined> {
129 const dossiers = `${root}/.scratchpad/dossier`
130 let names: string[]
131 try {
132 names = (await io.list(dossiers)).sort(byCodePoint)
133 } catch {
134 return undefined
135 }
136 for (const name of names) {
137 if (!live.includes(name)) continue
138 const lockPath = `${dossiers}/${name}/.ds-lock`
139 if (!(await io.exists(lockPath))) continue
140 let text: string
141 try {
142 text = await io.read(lockPath)
143 } catch {
144 return name
145 }
146 try {
147 const data: unknown = JSON.parse(text)
148 if (typeof data !== 'object' || data === null || Array.isArray(data)) return name
149 const lock = data as Record<string, unknown>
150 return `${name}: ${String(lock.skill ?? '?')} ${String(lock.target ?? '?')}`
151 } catch {
152 return name
153 }
154 }
155 return undefined
156}
157
158export async function bugGate(io: Io, filePath: string, seen: Set<string>): Promise<string | undefined> {
159 if (!filePath.endsWith('/DOSSIER.md') || !isDossierPath(filePath)) return undefined
160 let text: string
161 try {
162 text = await io.read(filePath)
163 } catch {
164 return undefined
165 }
166 let inside = false
167 let names: string[] = []
168 const bare: string[] = []
169 for (const line of text.split('\n')) {
170 if (line.startsWith('## ')) {
171 inside = SEC.bugs.test(line)
172 names = []
173 continue
174 }
175 const values = inside ? cells(line) : []
176 if (!values.length || /^-+$/.test(values[0] ?? '')) continue
177 if (!names.length) names = values.map((name) => name.toLowerCase())
178 else if (/^B\d+$/.test(values[0] ?? '') && ['invariant added', 'fix cite'].every((name) => EMPTY_CELL.has(values[names.indexOf(name)] ?? ''))) {
179 bare.push(values[0] ?? '')
180 }
181 }
182 const fresh = bare.filter((id) => !seen.has(`${filePath}#${id}`))
183 for (const id of fresh) seen.add(`${filePath}#${id}`)
184 if (!fresh.length) return undefined
185 return `${fresh.join(', ')} ${fresh.length > 1 ? 'have' : 'has'} no invariant yet — ds:backprop ${fresh[0]} registers one before the fix. Reminder only, never blocking.`
186}
187
188function reminder(name: string, paused: string[], live: string | undefined, flight: string | undefined): string | undefined {
189 if (GRILLING.has(name)) {
190 return (
191 'this repo keeps dossier waves — dossier:grill records each decision through ds grill-add, forks a decision on ' +
192 '"discuss more", and ds:new consumes its draft. Prefer it here. Reminder only, never blocking.'
193 )
194 }
195 if (name === CLOSE && paused.length) {
196 return (
197 `${paused.length} paused dossier(s) alongside this close: ${paused.join(', ')}. Decide each one ` +
198 'before the tree loses its current reader — resume it, or close it with ds:close --abandon ' +
199 '"<reason>". Reminder only, never blocking.'
200 )
201 }
202 if (BUILTINS.has(name) && flight) {
203 return (
204 `live dossier build in flight (${flight}) — fold /${name} findings into the ds:build step 6.5 ` +
205 'artifact trail and record the verdict in §S. Reminder only, never blocking.'
206 )
207 }
208 if (WHETSTONE.has(name) && live) {
209 return (
210 `live dossier (${live}) — after this skill's verdict, record the §S line this skill's own rule ` +
211 "calls for via dossier's cli/ds s-append (first live row = current; main thread writes, never " +
212 'subagents). Reminder only, never blocking.'
213 )
214 }
215 return undefined
216}
217
218export async function skillGate(io: Io, root: string, name: string, seen: Set<string>): Promise<string | undefined> {
219 if (!BUILTINS.has(name) && !WHETSTONE.has(name) && !GRILLING.has(name) && name !== CLOSE) return undefined
220 if (seen.has(name)) return undefined
221 let index: string
222 try {
223 index = await io.read(`${root}/.scratchpad/INDEX.md`)
224 } catch {
225 return undefined
226 }
227 const live = indexNames(index, 'live')
228 const paused = name === CLOSE ? indexNames(index, 'paused') : []
229 const flight = BUILTINS.has(name) && live.length ? await inFlight(io, root, live) : undefined
230 const text = reminder(name, paused, live[0], flight)
231 if (text) seen.add(name)
232 return text
233}
234
235export function skillOf(input: Record<string, unknown>): string {
236 return String(input.skill || input.name || '')
237}
238
239export type SessionVerdict = { context?: string; title?: string; toast?: string }
240
241function parsed(text: string): Record<string, unknown> {
242 try {
243 const value: unknown = JSON.parse(text)
244 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
245 } catch {
246 return {}
247 }
248}
249
250function cliFailure(exitCode: number | undefined): string {
251 const why = exitCode === EXIT_NO_NODE ? 'needs Node.js 22.18+' : `failed${exitCode === undefined ? '' : ` (exit ${exitCode})`}`
252 return `dossier: cli/ds ${why}; stale locks, reconcile and INDEX.md were not refreshed.`
253}
254
255export async function sessionGate(io: Io, root: string, input: { source: string; session_title: string }): Promise<SessionVerdict> {
256 if (!(await io.isDir(`${root}/.scratchpad/dossier`))) return {}
257 let done: { exitCode: number; stdout: string }
258 try {
259 const argv = ['sh', io.cli ?? 'cli/ds', 'session-start']
260 done = await io.run(argv, { stdin: JSON.stringify(input), timeoutMs: SESSION_TIMEOUT_MS, cwd: root })
261 } catch {
262 return { context: cliFailure(undefined) }
263 }
264 if (done.exitCode !== 0) return { context: cliFailure(done.exitCode) }
265 const output = parsed(done.stdout)
266 const nested = output.hookSpecificOutput
267 const specific = typeof nested === 'object' && nested !== null ? (nested as Record<string, unknown>) : {}
268 const text = (value: unknown): string | undefined => (typeof value === 'string' && value ? value : undefined)
269 return { context: text(specific.additionalContext), title: text(specific.sessionTitle), toast: text(output.systemMessage) }
270}
271
272export async function promptGate(io: Io, root: string, from: string = root): Promise<string | undefined> {
273 if (!(await io.isDir(`${root}/.scratchpad/dossier`))) return undefined
274 try {
275 const argv = ['sh', io.cli ?? 'cli/ds', 'convergence-state']
276 const done = await io.run(argv, { stdin: JSON.stringify({ cwd: root, from }), timeoutMs: PROMPT_TIMEOUT_MS, cwd: root })
277 const text = done.stdout.trim()
278 return done.exitCode === 0 && text ? text : undefined
279 } catch {
280 return undefined
281 }
282}
283
284export async function stopGate(io: Io, root: string): Promise<string | undefined> {
285 if (!(await io.env('DOSSIER_FAKEIMPL_CMD'))?.trim()) return undefined
286 try {
287 const argv = ['sh', io.cli ?? 'cli/ds', 'fakeimpl']
288 const done = await io.run(argv, { stdin: '{}', timeoutMs: STOP_TIMEOUT_MS, cwd: root })
289 const output = parsed(done.stdout)
290 return done.exitCode === 0 && output.decision === 'block' && typeof output.reason === 'string' ? output.reason : undefined
291 } catch {
292 return undefined
293 }
294}
295engine/text.ts 64 lines1const WORD_SET = '\\p{L}\\p{N}_'
2const SPACE_SET = '\\t\\n\\v\\f\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000'
3const DIGIT_SET = '\\p{Nd}'
4const WORD = `[${WORD_SET}]`
5const BOUNDARY = `(?:(?<=${WORD})(?!${WORD})|(?<!${WORD})(?=${WORD}))`
6const LINE_BREAK = /\r\n|[\n\r\v\f\x1c-\x1e\x85\u2028\u2029]/
7const EDGE_SPACE = new RegExp(`^[${SPACE_SET}]+|[${SPACE_SET}]+$`, 'gu')
8
9const IN_CLASS: Record<string, string> = { w: WORD_SET, s: SPACE_SET, d: DIGIT_SET }
10const OUTSIDE: Record<string, string> = {
11 w: WORD,
12 s: `[${SPACE_SET}]`,
13 d: `[${DIGIT_SET}]`,
14 b: BOUNDARY,
15 W: `[^${WORD_SET}]`,
16 S: `[^${SPACE_SET}]`,
17 D: `[^${DIGIT_SET}]`,
18 B: `(?:(?<=${WORD})(?=${WORD})|(?<!${WORD})(?!${WORD}))`,
19}
20
21export function unicodeRegex(source: string, flags = ''): RegExp {
22 let out = ''
23 let inClass = false
24 for (let i = 0; i < source.length; i++) {
25 const char = source[i] ?? ''
26 if (char === '\\') {
27 const next = source[i + 1] ?? ''
28 i++
29 const table = inClass ? IN_CLASS : OUTSIDE
30 out += table[next] ?? `\\${next}`
31 continue
32 }
33 if (char === '[' && !inClass) inClass = true
34 else if (char === ']' && inClass) inClass = false
35 out += char
36 }
37 return new RegExp(out, `${flags}u`)
38}
39
40export const CELL = /(?<!\\)\|/
41
42export function strip(text: string): string {
43 return text.replace(EDGE_SPACE, '')
44}
45
46export function splitLines(text: string): string[] {
47 if (!text) return []
48 const lines = text.split(LINE_BREAK)
49 if (lines[lines.length - 1] === '') lines.pop()
50 return lines
51}
52
53
54export function byCodePoint(a: string, b: string): number {
55 const left = [...a]
56 const right = [...b]
57 for (let i = 0; i < Math.min(left.length, right.length); i++) {
58 const x = left[i]?.codePointAt(0) ?? 0
59 const y = right[i]?.codePointAt(0) ?? 0
60 if (x !== y) return x - y
61 }
62 return left.length - right.length
63}
64engine/guards.ts 114 lines1import { compilePython, fnmatch } from './pyregex.ts'
2import { HEADER } from './ledger.ts'
3import { splitLines, strip, unicodeRegex } from './text.ts'
4
5const COMMENT_PREFIX = String.raw`^\s*(?://+|#+|--|/\*+|\*(?!/)|<!--|;)\s*`
6const MARKER_PATTERNS = [
7 unicodeRegex(COMMENT_PREFIX + String.raw`.*\bPH\d+-[A-Z]\d+\b`),
8 unicodeRegex(COMMENT_PREFIX + '.*§[VBTSXGZ]\\d+'),
9]
10const CANONICAL_STATES = new Set(['live', 'done', 'paused'])
11
12export type Edit = { filePath: string; chunks: string[] }
13
14export type Guard = { id?: unknown; pattern?: unknown; message?: unknown; paths?: unknown }
15
16function posix(path: string): string {
17 const absolute = path.startsWith('/')
18 const parts = path.split('/').filter((part) => part !== '' && part !== '.')
19 const joined = parts.join('/')
20 return absolute ? `/${joined}` : joined || '.'
21}
22
23function baseName(path: string): string {
24 return posix(path).split('/').pop() ?? ''
25}
26
27export function editOf(input: Record<string, unknown>): Edit | undefined {
28 if (input.tool !== 'Edit' && input.tool !== 'Write') return undefined
29 const path = input.file_path || input.path
30 if (!path) return undefined
31 const text = input.tool === 'Write' ? input.content : input.new_string
32 if (typeof text !== 'string') return undefined
33 return { filePath: String(path), chunks: [text] }
34}
35
36export function isDossierPath(path: string): boolean {
37 return posix(path).includes('.scratchpad/') || baseName(path) === 'DOSSIER.md'
38}
39
40export function headerDenial({ filePath, chunks }: Edit): string | undefined {
41 if (baseName(filePath) !== 'DOSSIER.md') return undefined
42 for (const line of chunks.flatMap(splitLines)) {
43 const token = strip(HEADER.exec(line)?.[1] ?? '')
44 if (token && !CANONICAL_STATES.has(token)) {
45 return (
46 `dossier: refusing to write non-canonical header state '${token}'.\n` +
47 ' canonical header states: live | done | paused.\n' +
48 ' route header changes through cli/ds header-state ' +
49 '(ds:close flips done; ds:status pause/resume) — not a raw Edit/Write.'
50 )
51 }
52 }
53 return undefined
54}
55
56export function markerDenial({ filePath, chunks }: Edit): string | undefined {
57 const line = chunks.flatMap(splitLines).find((text) => MARKER_PATTERNS.some((pattern) => pattern.test(text)))
58 if (line === undefined) return undefined
59 return [
60 `dossier marker guard: '${filePath}' carries a dossier audit-id marker.`,
61 ` offending line: ${strip(line)}`,
62 '',
63 'Audit-id markers (`PH3-B7`) and §-cites (`§V26`) belong in the',
64 'DOSSIER.md §B/§V ledger + commit trailer, not source. Drop the',
65 'marker; keep any why-tail (workaround ref, invariant, upstream-bug',
66 'link). Bypass: DOSSIER_MARKER_GUARD=off.',
67 ].join('\n')
68}
69
70function inScope(path: string, paths: unknown): boolean {
71 if (!Array.isArray(paths) || paths.length === 0) return true
72 return paths.some((glob) => typeof glob === 'string' && fnmatch(posix(path), glob))
73}
74
75export function parseRegistry(text: string): Guard[] {
76 try {
77 const data: unknown = JSON.parse(text)
78 if (!Array.isArray(data)) return []
79 return data.filter((entry): entry is Guard => typeof entry === 'object' && entry !== null && !Array.isArray(entry))
80 } catch {
81 return []
82 }
83}
84
85export function invariantVerdict({ filePath, chunks }: Edit, registry: Guard[]): { deny?: string; skipped: string[] } {
86 const skipped: string[] = []
87 for (const entry of registry) {
88 if (typeof entry.pattern !== 'string' || !entry.pattern) continue
89 if (!inScope(filePath, entry.paths)) continue
90 const compiled = compilePython(entry.pattern)
91 if (!compiled) {
92 skipped.push(String(entry.id ?? '?'))
93 continue
94 }
95 if (chunks.some((chunk) => compiled.test(chunk))) {
96 const deny =
97 `dossier invariant guard: edit to '${filePath}' violates §V ${String(entry.id ?? '?')}.\n` +
98 ` pattern: ${entry.pattern}\n` +
99 ` ${String(entry.message ?? '')}\n` +
100 ' registered by ds:backprop (recurrence=high). Legit edit? set ' +
101 'DOSSIER_INVARIANT_GUARD=off and log the rationale in §S.'
102 return { deny, skipped }
103 }
104 }
105 return { skipped }
106}
107
108export function skippedAdvisory(skipped: string[]): string {
109 return (
110 `dossier invariant guard: §V ${skipped.join(', ')} not checked — the pattern has no ECMAScript ` +
111 'meaning. Re-register it in JS regex syntax in .scratchpad/dossier/.invariant-guards.json.'
112 )
113}
114engine/pyregex.ts 112 lines1import { unicodeRegex } from './text.ts'
2
3const LEADING_FLAGS = /^\(\?([a-zA-Z]+)\)/
4const FLAGS: Record<string, string> = { i: 'i', s: 's', m: 'm', u: '' }
5const NEGATED_IN_CLASS = new Set(['W', 'S', 'D'])
6
7function translate(source: string, flags: string): string | undefined {
8 const multiline = flags.includes('m')
9 const dotAll = flags.includes('s')
10 let out = ''
11 let inClass = false
12 for (let i = 0; i < source.length; i++) {
13 const char = source[i] ?? ''
14 if (char === '\\') {
15 const point = source.codePointAt(i + 1) ?? 0
16 const next = String.fromCodePoint(point)
17 i += next.length
18 if (inClass && NEGATED_IN_CLASS.has(next)) return undefined
19 if (next === 'A' && !inClass) out += '(?<![\\s\\S])'
20 else if (next === 'Z' && !inClass) out += '(?![\\s\\S])'
21 else if (/^[\p{L}\p{N}]$/u.test(next)) out += `\\${next}`
22 else out += `\\u{${point.toString(16)}}`
23 continue
24 }
25 if (inClass) {
26 if (char === ']') inClass = false
27 out += char
28 continue
29 }
30 if (char === '[') {
31 inClass = true
32 out += char
33 if (source[i + 1] === '^') out += source[++i]
34 if (source[i + 1] === ']') {
35 out += '\\]'
36 i++
37 }
38 continue
39 }
40 if (source.startsWith('(?P<', i)) {
41 out += '(?<'
42 i += 3
43 continue
44 }
45 const backref = /^\(\?P=(\w+)\)/.exec(source.slice(i))
46 if (backref) {
47 out += `\\k<${backref[1]}>`
48 i += backref[0].length - 1
49 continue
50 }
51 if (char === '.') out += dotAll ? '[\\s\\S]' : '[^\\n]'
52 else if (char === '^') out += multiline ? '(?<![^\\n])' : '^'
53 else if (char === '$') out += multiline ? '(?![^\\n])' : '(?=\\n?(?![\\s\\S]))'
54 else out += char
55 }
56 return out
57}
58
59export function compilePython(pattern: string): RegExp | undefined {
60 let flags = ''
61 let body = pattern
62 const leading = LEADING_FLAGS.exec(pattern)
63 if (leading) {
64 for (const flag of leading[1] ?? '') {
65 const js = FLAGS[flag]
66 if (js === undefined) return undefined
67 if (!flags.includes(js)) flags += js
68 }
69 body = pattern.slice(leading[0].length)
70 }
71 const source = translate(body, flags)
72 if (source === undefined) return undefined
73 try {
74 return unicodeRegex(source, flags.includes('i') ? 'i' : '')
75 } catch {
76 return undefined
77 }
78}
79
80function globClass(inner: string): string {
81 const negated = inner.startsWith('!')
82 const body = (negated ? inner.slice(1) : inner).replaceAll('\\', '\\\\').replaceAll(']', '\\]').replaceAll('[', '\\[')
83 if (negated) return `[^${body}]`
84 return body.startsWith('^') ? `[\\${body}]` : `[${body}]`
85}
86
87export function fnmatch(name: string, glob: string): boolean {
88 let out = ''
89 for (let i = 0; i < glob.length; i++) {
90 const char = glob[i] ?? ''
91 if (char === '*') out += '[\\s\\S]*'
92 else if (char === '?') out += '[\\s\\S]'
93 else if (char === '[') {
94 let j = i + 1
95 if (glob[j] === '!') j++
96 if (glob[j] === ']') j++
97 while (j < glob.length && glob[j] !== ']') j++
98 if (j >= glob.length) {
99 out += '\\['
100 continue
101 }
102 out += globClass(glob.slice(i + 1, j))
103 i = j
104 } else out += char.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
105 }
106 try {
107 return new RegExp(`^(?:${out})$`, 'u').test(name)
108 } catch {
109 return false
110 }
111}
112types/index.d.ts 8 lines1export type DossierWave = { slug: string; text: string; milestone?: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 dossier: { wave: DossierWave | null }
6 }
7}
8