SLOPSHOPPER

dossier

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

newpanecommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dossier
│ ┃ Dossier tasks ✕ › fix the failing auth test and add an audit log call │ ┃ No live dossier wave. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dossier-tasks │ ⎿ dossier: Dossier tasks pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dossier tasks
No live dossier wave.
README

dossier

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:

  • README — install, commands, gates, configuration
  • ARCHITECTURE.md — priorities, tenets, enforcement map, testing standard
  • RESEARCH.md — decisions and their rejected alternatives
  • INSPIRATIONS.md — sources we borrow from, what we refused, when we last looked

Local to this plugin:

Source 9 files
hooks/register.tsx 178 lines
1import { 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}
178
engine/converge.ts 175 lines
1import { 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}
175
engine/ledger.ts 494 lines
1import { 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}
494
engine/progress.ts 158 lines
1import { 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}
158
hooks/gates.ts 295 lines
1import { 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}
295
engine/text.ts 64 lines
1const 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}
64
engine/guards.ts 114 lines
1import { 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}
114
engine/pyregex.ts 112 lines
1import { 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}
112
types/index.d.ts 8 lines
1export type DossierWave = { slug: string; text: string; milestone?: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    dossier: { wave: DossierWave | null }
6  }
7}
8