@erclx/canon 4.60.0 → 4.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.60.0",
4
+ "version": "4.61.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -7,7 +7,7 @@ description: Why creating and archiving a task file is one skill, the origin inv
7
7
 
8
8
  ## Gap
9
9
 
10
- Without this skill, a task file gets a filename and frontmatter invented on the spot, so the board sorts wrong and the regenerated index reads fields that are not there. A task arrives with no origin, which is either lost context or work nobody decided to do, and by the time anyone notices there is no way to recover which it was. The phase label gets derived from a version file rather than from what the board already means, and a single-digit phase sorts after a double-digit one because nobody padded it.
10
+ Without this skill, a task file gets a filename and frontmatter invented on the spot, so the board sorts wrong and the regenerated index reads fields that are not there. A task arrives with no origin, which is either lost context or work nobody decided to do, and by the time anyone notices there is no way to recover which it was. The phase label gets derived from a version file rather than from what the board already means, and a single-digit phase sorts after a double-digit one because nobody padded it. A label picked off the live board alone repeats one the archive already spent, since the archive holds most of what has ever been allocated and a board-only scan cannot see it.
11
11
 
12
12
  Archiving fails in two ways that both lose work. Moving the file, editing the ordering file, and regenerating the index as three separate acts drifts from the one command the merge hook calls, so the attended and unattended paths stop agreeing. And an all-`[x]` task gets archived while its pull request is still open, because marking outcomes happens on the branch as the first step of shipping. The board is gitignored, so nothing restores a task archived early.
13
13
 
@@ -18,7 +18,7 @@ Placing a row without checking for another writer collides the same way. Two ses
18
18
  - Resolve the board at the main worktree root, since a linked worktree writing to `pwd` creates a second board nothing reads
19
19
  - Read the tasks standard before writing, rather than working the filename and frontmatter from memory
20
20
  - Require an origin at creation, because that is the only moment the invariant is enforceable
21
- - Propose the phase label from what is already on the board and pad it to two digits
21
+ - Read the phase label from `canon tasks next-label` rather than proposing one by hand
22
22
  - Check the roster for a live orchestrator before writing a row, and hand off rather than write when one is found
23
23
  - Confirm the work reached the default branch before archiving
24
24
  - Run the archive command and route on the reason it refuses, since each reason has one resolution
@@ -27,11 +27,11 @@ A task with no origin is either lost context or work nobody decided to do. This
27
27
 
28
28
  Accept work whose origin is the conversation itself only when the user says so explicitly, and record what it was in the intro paragraph instead of writing a link line to a file that does not exist.
29
29
 
30
- ### Step 2: propose the phase label
30
+ ### Step 2: allocate the phase label
31
31
 
32
- List the existing filenames in `.canon/tasks/` and read `index.md` for what each holds. Propose the next label from what is already on the board, and say which neighbors it sits between.
32
+ Run `canon tasks next-label --json` and take its `label` field. The verb reads the live board and its `archive/` sibling together, so the label it returns accounts for what the board alone no longer shows.
33
33
 
34
- Do not derive the label from a version file. `${CLAUDE_SKILL_DIR}/../../standards/versioning.md` permits free renumbering, so the board is the only surface that knows what a label currently means. Pad the phase to two digits in the filename, since index entries sort by filename alone and a bare `v9.0` sorts after `v15.0`. <!-- canon-allow-reference: illustrates the padding rule's own sort collision, not a citation of a real task -->
34
+ Do not derive the label from a version file. `${CLAUDE_SKILL_DIR}/../../standards/versioning.md` permits free renumbering, so the verb's two folders are the only surface that knows what a label currently means.
35
35
 
36
36
  ### Step 3: write the file
37
37
 
@@ -138,7 +138,7 @@ Create:
138
138
  ```plaintext
139
139
  ✅ Created: .canon/tasks/vXX.Y-<slug>.md
140
140
 
141
- <label> sits between <neighbor> and <neighbor>.
141
+ <label>, next after <highest>.
142
142
  <board or backlog, and why it landed there>.
143
143
 
144
144
  **Origin with no task:**
@@ -15,6 +15,8 @@ One failure predates the scan. `setup-init` declines an install that wants the C
15
15
 
16
16
  Drafted frontmatter is a proposal, and a session that writes it before the user sees it turns a review into a cleanup. The convention block has the mirror failure. A session that paraphrases it into `CLAUDE.md` produces a copy that reads correctly and no longer matches its source, so the two drift with nothing reporting it. And a project with no `CLAUDE.md` gets one scaffolded to hold the block, which installs a file the project declined.
17
17
 
18
+ A fourth failure sits after the scan rather than inside it. `## Present candidates` had no branch for a scan that finds zero folders, so an empty result walked straight into the ask with nothing to choose from. `setup-init` folds this skill into the onboarding chain, and a fresh scaffold usually carries no markdown-heavy folder yet, so the ordinary onboarding run reached the undefined case.
19
+
18
20
  ## Must
19
21
 
20
22
  - Bootstrap all-or-nothing per chosen folder, since a folder carrying partial frontmatter hard-errors on regen
@@ -36,6 +38,7 @@ Drafted frontmatter is a proposal, and a session that writes it before the user
36
38
 
37
39
  - A folder with fewer than the sibling threshold stays out of the candidate list unless the user names it explicitly, so the scan proposes and the user overrides
38
40
  - A `CLAUDE.md` already carrying the convention section skips the seed silently rather than appending a second copy
41
+ - A scan that finds no candidate reports a one-line result and returns, skipping the ask and every step between it and the seed offer, rather than opening a prompt with nothing in it
39
42
 
40
43
  ## Out of scope
41
44
 
@@ -29,6 +29,14 @@ For each candidate, capture:
29
29
 
30
30
  ## Present candidates
31
31
 
32
+ If the scan found no candidate, report the flat result and skip ahead:
33
+
34
+ ```plaintext
35
+ No folder has three or more markdown siblings without an index.md already. Nothing to bootstrap.
36
+ ```
37
+
38
+ Skip past the ask below and every step between it and the seed offer, straight to `## Offer the convention seed`. There is no candidate to ask about, so this stops rather than opening a prompt with nothing in it.
39
+
32
40
  Output one line per candidate:
33
41
 
34
42
  ```plaintext
@@ -36,6 +36,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
36
36
  | `canon tasks plan-link` | Write or correct a task's `Plan:` line to point at a plan, by stem and plan path or slug (`--json`) |
37
37
  | `canon tasks outcome` | Mark outcomes `[x]` on a task by position, repeating `--close` (`--json`) |
38
38
  | `canon tasks validate` | Report board rows whose shape, order, plan, task file, group, file set, or blocker does not hold (`--json`) |
39
+ | `canon tasks next-label` | Report the next unused phase label across the board and its archive (`--json`) |
39
40
  | `canon intake list` | Report intake folder counts, or one folder's items, keeping what is unread with `--unread` (`--json`) |
40
41
  | `canon intake answer` | Write selections into one cluster's answer slots, repeating `--set <item>=<answer>` (`--json`) |
41
42
  | `canon teach list` | Report learning workspaces and the ordinal a new one takes, or what one workspace holds and what its records schedule (`--json`) |
@@ -5,6 +5,32 @@ description: Selecting a shipped task by stem or pull request, recording a numbe
5
5
 
6
6
  # Tasks
7
7
 
8
+ ## Next label
9
+
10
+ `canon tasks next-label` reports the next unused phase label, reading `.canon/tasks/` and its `archive/` sibling together. A scan confined to the live board is blind to every label the archive already spent, which is what let two sessions hand out the same label within minutes of each other. It reports and never writes.
11
+
12
+ ```bash
13
+ canon tasks next-label
14
+ canon tasks next-label --json
15
+ ```
16
+
17
+ | Option | Effect |
18
+ | --------------- | ------------------------------------------- |
19
+ | `--json` | Emit a machine-readable record on stdout |
20
+ | `--root <path>` | Board root, defaulting to the main worktree |
21
+
22
+ The record carries `label`, the next free `vXX.Y`, and `highest`, the label it was derived from. `highest` is absent when neither folder carries a label yet, and `label` reads `v01.0` in that case, matching the zero-padded-major shape every other label already takes. <!-- canon-allow-reference: illustrates the verb's answer for a board holding no label yet, not a citation of a real task -->
23
+
24
+ Exit codes: `0` derived, `1` refused with `no-board`.
25
+
26
+ The minor digit rolls from 9 to 0 on the next major rather than growing a second digit, which is the single-digit-minor shape every phase label already takes. `canon tasks archive` moves a task's file from the live folder into the archive without renumbering it, so the same label counts toward the maximum wherever it currently sits, and a label claimed by two different files folds into the same scan without a dedicated check.
27
+
28
+ It reports rather than gates. Two sessions calling it in the same second can still take the same answer, since the board is gitignored files rather than a store with a lock, and `standards/versioning.md` permits free renumbering, so a collision costs a rename rather than anything worse.
29
+
30
+ ```bash
31
+ canon tasks next-label --json | jq -r '.label'
32
+ ```
33
+
8
34
  ## Archive
9
35
 
10
36
  `canon tasks archive` moves a shipped task from `.canon/tasks/` into `.canon/tasks/archive/`, drops its row from `priority.md`, and regenerates the board index. The three run as one unit, so the attended and unattended callers cannot archive differently.
@@ -30,5 +30,7 @@ paths:
30
30
 
31
31
  ## Sharing a capture
32
32
 
33
- - Attach a capture to the pull request by hand when a reviewer needs to see it.
34
- - Do not commit a capture. Do not remove the capture folder from `.gitignore`.
33
+ - Do not commit the sweep. It stays ignored.
34
+ - Commit a flagged case's evidence output so the pull request carries the comparison, rather than attaching it by hand.
35
+ - Commit an evidence case for the first time only after running the capture twice with no code change between the runs and confirming the two outputs are byte-identical.
36
+ - Recommitting an unbounded sweep on every run reaches a gigabyte of repository history inside a hundred merges. A small, committed evidence set is what a reviewer needs and what lets GitHub draw its own before-and-after comparison on the pull request.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.60.0",
4
+ "version": "4.61.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -9,6 +9,7 @@ import {
9
9
  type PlanCitations,
10
10
  planCitations,
11
11
  } from '@/tasks/archive'
12
+ import { type LabelOutcome, nextLabel } from '@/tasks/label'
12
13
  import {
13
14
  type CloseOutcome,
14
15
  closeOutcomes,
@@ -87,6 +88,11 @@ interface OutcomeCommandOptions {
87
88
  readonly root?: string
88
89
  }
89
90
 
91
+ interface NextLabelCommandOptions {
92
+ readonly json?: boolean
93
+ readonly root?: string
94
+ }
95
+
90
96
  export function register(program: Command): void {
91
97
  const tasks = program
92
98
  .command('tasks')
@@ -379,6 +385,40 @@ export function register(program: Command): void {
379
385
  .action(async (task: string | undefined, opts: OutcomeCommandOptions) => {
380
386
  process.exitCode = await runOutcome(task, opts)
381
387
  })
388
+
389
+ tasks
390
+ .command('next-label')
391
+ .description(
392
+ 'Report the next unused phase label across the board and its archive',
393
+ )
394
+ .helpOption('-h, --help', 'Show this help message')
395
+ .option('--json', 'Emit a machine-readable record on stdout')
396
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
397
+ .addHelpText(
398
+ 'after',
399
+ [
400
+ '',
401
+ 'Reads .canon/tasks/ and its archive/ sibling together, since the',
402
+ 'archive holds labels the live board no longer shows and a scan',
403
+ 'confined to the board hands out one already spent.',
404
+ '',
405
+ 'Exit codes:',
406
+ ' 0 the label is derived',
407
+ ' 1 refused with no-board',
408
+ '',
409
+ 'It reports and never writes. Two sessions calling it in the same',
410
+ 'second can still take the same answer, since the board is',
411
+ 'gitignored files rather than a store with a lock.',
412
+ '',
413
+ 'Examples:',
414
+ ' canon tasks next-label',
415
+ ' canon tasks next-label --json',
416
+ '',
417
+ ].join('\n'),
418
+ )
419
+ .action(async (opts: NextLabelCommandOptions) => {
420
+ process.exitCode = await runNextLabel(opts)
421
+ })
382
422
  }
383
423
 
384
424
  function collectPosition(value: string, previous: string[]): string[] {
@@ -649,6 +689,50 @@ function reportOutcome(
649
689
  return 0
650
690
  }
651
691
 
692
+ async function runNextLabel(opts: NextLabelCommandOptions): Promise<number> {
693
+ const root = opts.root ?? (await mainWorktreeRoot())
694
+ const outcome = await nextLabel(root)
695
+
696
+ return reportNextLabel(outcome, opts.json ?? false, root)
697
+ }
698
+
699
+ function reportNextLabel(
700
+ outcome: LabelOutcome,
701
+ emitJson: boolean,
702
+ root: string,
703
+ ): number {
704
+ if (!outcome.ok) {
705
+ if (emitJson) {
706
+ process.stdout.write(
707
+ `${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
708
+ )
709
+ return 1
710
+ }
711
+
712
+ intro('canon tasks next-label')
713
+ logStep('Refused')
714
+ logError(outcome.message)
715
+ outro()
716
+ return 1
717
+ }
718
+
719
+ if (emitJson) {
720
+ process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
721
+ return 0
722
+ }
723
+
724
+ intro('canon tasks next-label')
725
+ logStep(outcome.label)
726
+ logInfo(
727
+ outcome.highest
728
+ ? `next after ${outcome.highest}.`
729
+ : 'the board and its archive hold no label yet.',
730
+ )
731
+ outro()
732
+
733
+ return 0
734
+ }
735
+
652
736
  async function runValidate(opts: ValidateCommandOptions): Promise<number> {
653
737
  const root = opts.root ?? (await mainWorktreeRoot())
654
738
  const outcome = await validateBoard(root)
@@ -0,0 +1,110 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { relative } from 'node:path'
3
+ import { archiveDir, listTaskStems, tasksDir } from '@/tasks/archive'
4
+
5
+ /** Every label in the corpus today stops here before rolling to the next major. */
6
+ const MINOR_ROLLOVER = 9
7
+
8
+ /** The label a board with no live or archived task yet allocates first. */
9
+ const FIRST_LABEL: Label = { major: 1, minor: 0 }
10
+
11
+ /**
12
+ * Matches a task filename stem's leading label. Anchored, so a sibling such as
13
+ * `TASK-ARCHIVE` fails it outright, and a reserved stem such as `index` or
14
+ * `priority` never reaches it at all, since `listTaskStems` already filters
15
+ * those out before this pattern sees a stem.
16
+ */
17
+ export const LABEL_PATTERN = /^v(\d+)\.(\d+)-/
18
+
19
+ interface Label {
20
+ readonly major: number
21
+ readonly minor: number
22
+ }
23
+
24
+ export interface NextLabel {
25
+ readonly ok: true
26
+ readonly label: string
27
+ /** The label this run was derived from, absent when neither folder holds one. */
28
+ readonly highest: string | undefined
29
+ }
30
+
31
+ export interface LabelRefused {
32
+ readonly ok: false
33
+ readonly reason: 'no-board'
34
+ readonly message: string
35
+ }
36
+
37
+ export type LabelOutcome = NextLabel | LabelRefused
38
+
39
+ function parseLabel(stem: string): Label | undefined {
40
+ const match = LABEL_PATTERN.exec(stem)
41
+ if (!match) return undefined
42
+
43
+ return { major: Number(match[1]), minor: Number(match[2]) }
44
+ }
45
+
46
+ function isHigher(candidate: Label, current: Label): boolean {
47
+ return candidate.major !== current.major
48
+ ? candidate.major > current.major
49
+ : candidate.minor > current.minor
50
+ }
51
+
52
+ function formatLabel(label: Label): string {
53
+ return `v${String(label.major).padStart(2, '0')}.${label.minor}`
54
+ }
55
+
56
+ /**
57
+ * The label after the one given, rolling a minor of 9 to the next major rather
58
+ * than continuing to a second minor digit. Every one of the 587 labels measured
59
+ * across the live board and its archive on 2026-09-06 stops at a single digit,
60
+ * so this is the rollover the whole corpus already follows rather than a rule
61
+ * this verb introduces.
62
+ */
63
+ function next(label: Label): Label {
64
+ return label.minor >= MINOR_ROLLOVER
65
+ ? { major: label.major + 1, minor: 0 }
66
+ : { major: label.major, minor: label.minor + 1 }
67
+ }
68
+
69
+ /**
70
+ * Reports the next unused phase label, read off the true maximum across
71
+ * `.canon/tasks/` and its `archive/` sibling together. A scan confined to the
72
+ * live board is blind to every label the archive already spent, which is what
73
+ * let two sessions hand out the same label within minutes of each other.
74
+ *
75
+ * It reports and never writes. Two sessions calling it in the same second can
76
+ * still take the same answer, since the board is gitignored files rather than
77
+ * a store with a lock, and `standards/versioning.md` permits free renumbering,
78
+ * so a collision costs a rename rather than anything worse. A duplicate label
79
+ * already sitting in the tree, and a gap left by a renumbering, both fold into
80
+ * the same max scan without needing a dedicated check.
81
+ */
82
+ export async function nextLabel(root: string): Promise<LabelOutcome> {
83
+ const dir = tasksDir(root)
84
+
85
+ if (!existsSync(dir)) {
86
+ return {
87
+ ok: false,
88
+ reason: 'no-board',
89
+ message: `No task board at ${relative(root, dir)}.`,
90
+ }
91
+ }
92
+
93
+ const archive = archiveDir(root)
94
+ const dirs = existsSync(archive) ? [dir, archive] : [dir]
95
+ const stems = (await Promise.all(dirs.map((d) => listTaskStems(d)))).flat()
96
+
97
+ const highest = stems
98
+ .map(parseLabel)
99
+ .filter((label): label is Label => label !== undefined)
100
+ .reduce<Label | undefined>(
101
+ (max, label) => (max === undefined || isHigher(label, max) ? label : max),
102
+ undefined,
103
+ )
104
+
105
+ return {
106
+ ok: true,
107
+ label: formatLabel(highest === undefined ? FIRST_LABEL : next(highest)),
108
+ highest: highest === undefined ? undefined : formatLabel(highest),
109
+ }
110
+ }
@@ -59,6 +59,12 @@ Append to the `## Scripts` table:
59
59
 
60
60
  In `.claude/context/ci.md`, the Typecheck row's assertion reads: `` `astro check` passes ``. The Build row's assertion reads: `` `astro build` succeeds ``.
61
61
 
62
+ ## Gitignore (extend)
63
+
64
+ `[gitignore]` groups this stack edits, restated here per the manifest-to-reference symmetry:
65
+
66
+ - `"# Astro" = [".astro/"]`
67
+
62
68
  ## Scenario switcher
63
69
 
64
70
  - `src/components/dev/scenarios.astro` ships as a golden config, always overwritten on sync, since it is toolkit-authored infrastructure rather than a file a project hand-edits.
@@ -9,11 +9,19 @@ interface CaptureCase {
9
9
  route: string
10
10
  width: number
11
11
  height: number
12
+ evidence?: boolean
12
13
  setup?: (page: Page) => Promise<void>
13
14
  }
14
15
 
15
16
  const CASES: CaptureCase[] = [
16
- { section: 'home', theme: 'default', route: '/', width: 1280, height: 800 },
17
+ {
18
+ section: 'home',
19
+ theme: 'default',
20
+ route: '/',
21
+ width: 1280,
22
+ height: 800,
23
+ evidence: true,
24
+ },
17
25
  {
18
26
  section: 'home',
19
27
  theme: 'dark',
@@ -47,8 +55,12 @@ const OUT_DIR = path.join('screenshots', hostname)
47
55
 
48
56
  const browser = await chromium.launch()
49
57
  const consoleErrors: string[] = []
58
+ let ranCases = 0
50
59
 
51
60
  for (const captureCase of CASES) {
61
+ if (captureCase.evidence && requireBaseUrl) continue
62
+
63
+ ranCases++
52
64
  const context = await browser.newContext({
53
65
  viewport: { width: captureCase.width, height: captureCase.height },
54
66
  })
@@ -76,11 +88,26 @@ for (const captureCase of CASES) {
76
88
  await page.screenshot({ path: file, fullPage: true })
77
89
  console.log(`captured ${file}`)
78
90
 
91
+ if (captureCase.evidence) {
92
+ const evidenceDir = path.join('evidence', captureCase.section)
93
+ await mkdir(evidenceDir, { recursive: true })
94
+ const evidenceFile = path.join(evidenceDir, `${captureCase.theme}.png`)
95
+ await page.screenshot({ path: evidenceFile, fullPage: true })
96
+ console.log(`captured ${evidenceFile}`)
97
+ }
98
+
79
99
  await context.close()
80
100
  }
81
101
 
82
102
  await browser.close()
83
103
 
104
+ if (requireBaseUrl && ranCases === 0) {
105
+ console.error(
106
+ 'every CASES entry is flagged evidence: true, so --require-base-url skipped all of them and checked nothing',
107
+ )
108
+ process.exit(1)
109
+ }
110
+
84
111
  if (checkConsoleClean && consoleErrors.length > 0) {
85
112
  console.error('console errors detected:')
86
113
  for (const error of consoleErrors) console.error(` ${error}`)
@@ -57,4 +57,5 @@ packages = [
57
57
  "# Build" = ["dist/"]
58
58
  "# Coverage" = ["coverage/"]
59
59
  "# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"]
60
+ "# Screenshots" = ["screenshots/"]
60
61
  "# VSCode" = [".vscode/*", "!.vscode/extensions.json", "!.vscode/settings.json"]
@@ -12,7 +12,7 @@ Golden config files live in `tooling/web/configs/` and are copied into the targe
12
12
 
13
13
  - `eslint.config.js`: flat config with `@eslint/js`, `typescript-eslint`, React hooks, import sort, check-file, vitest rules scoped to test files, `eslint-config-prettier` last.
14
14
  - `src/test/setup.ts`: `@testing-library/jest-dom` import, `cleanup` after each test.
15
- - `e2e/screenshot.ts`: capture template. A single `CASES` record at the top carries one entry per output file, each naming a section, a theme, a route, and its own viewport, and the loop below writes `screenshots/<hostname>/<section>/<theme>.png`, keyed on `SCREENSHOT_BASE_URL`'s hostname so a local and a deployed run land in different folders. Per-project cases extend the one record. A route's themes sit together under its section folder, so the filename carries the theme alone. `--check-console-clean` collects `console`-level error messages per case and exits 1 with the list if any fired, turning the capture into a smoke check. `--require-base-url` exits 1 before launching a browser when `SCREENSHOT_BASE_URL` is unset, guarding a script meant to run against a real deployment from silently capturing `localhost`.
15
+ - `e2e/screenshot.ts`: capture template. A single `CASES` record at the top carries one entry per output file, each naming a section, a theme, a route, and its own viewport, and the loop below writes `screenshots/<hostname>/<section>/<theme>.png`, keyed on `SCREENSHOT_BASE_URL`'s hostname so a local and a deployed run land in different folders. A case flagged `evidence: true` additionally writes `evidence/<section>/<theme>.png`, with no hostname segment. `--require-base-url` skips a flagged case entirely, so a production smoke run neither writes to the committed path nor counts an evidence route in its console-clean check, and exits 1 when that leaves zero cases run, so flagging every case cannot silence the production check without saying so. Per-project cases extend the one record. A route's themes sit together under its section folder, so the filename carries the theme alone. `--check-console-clean` collects `console`-level error messages per case and exits 1 with the list if any fired, turning the capture into a smoke check. `--require-base-url` exits 1 before launching a browser when `SCREENSHOT_BASE_URL` is unset, guarding a script meant to run against a real deployment from silently capturing `localhost`.
16
16
  - `.vscode/extensions.json` and `.vscode/settings.json`: editor wiring for ESLint, Tailwind, Playwright, Vitest.
17
17
  - `.github/workflows/verify.yml`: `static-checks`, `unit-tests`, `build-verify`, and `e2e-tests` jobs.
18
18
  - `scripts/verify.sh`: extends base verify with typecheck, lint, unit tests, and build in the full order.
@@ -112,13 +112,14 @@ Append rows:
112
112
 
113
113
  `governance/rules/ui/440-surface-capture.md` is what asks a session to run the capture after a route changes. It fires on route and page files rather than on every component, so a shared component changing every screen fires nothing and the operator runs the capture by hand.
114
114
 
115
- The screenshot output now tracks in git rather than getting discarded, so the first capture a scaffolded target runs after this change is the baseline it commits.
115
+ The sweep under `screenshots/` is ignored again, and only a flagged case's `evidence/` output tracks in git, so the first capture a scaffolded target runs after this change is the baseline it commits there.
116
116
 
117
117
  ## Gitignore (extend)
118
118
 
119
119
  `[gitignore]` groups this stack edits, restated here per the manifest-to-reference symmetry:
120
120
 
121
121
  - `"# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"]`
122
+ - `"# Screenshots" = ["screenshots/"]`
122
123
 
123
124
  ## Verify script
124
125