@erclx/canon 4.59.0 → 4.60.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.59.0",
4
+ "version": "4.60.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -22,6 +22,7 @@ A track that closes with several separable findings writes only one task, and th
22
22
  - Carry a lean and the finding that would overturn it on every open question, or admit that a measurement is missing
23
23
  - Confine writes to the track folder, with the close-time task file, the experiment fixture, and the intake routing below as the only exceptions
24
24
  - Route a closing-track finding the required task does not cover through `claude-intake`, rather than leaving it to be asked about. The route runs in the same session, so it is a write outside the folder rather than a handoff to a later one.
25
+ - Place the closing task's row through `claude-tasks` Step 4 rather than writing `priority.md` or `backlog.md` directly
25
26
  - Link every claim about a source outside the project, and list an unread source as a lead rather than citing it
26
27
  - Put a fixture a headless run is pointed at outside the repository
27
28
  - Write the next-session file self-contained, since the folder is unbacked and dies with the machine
@@ -92,7 +92,7 @@ The standard sets the open question format and requires it inside a topic file a
92
92
  1. Write `06-decision.md`. It states the problem once, names the goal, lists what to do, and lists what was considered and dropped.
93
93
  2. Write `07-next-session.md` self-contained, so it survives a compaction that loses the conversation.
94
94
  3. Update the file map in `README.md`.
95
- 4. Write one task file in `.canon/tasks/` recording what the track concluded, even when the conclusion is to do nothing. Follow `${CLAUDE_SKILL_DIR}/../../standards/tasks.md` for the filename and frontmatter. Aside from an experiment fixture, this and the routing in Step 5 are the only ways close mode reaches outside the folder.
95
+ 4. Write one task file in `.canon/tasks/` recording what the track concluded, even when the conclusion is to do nothing. Follow `${CLAUDE_SKILL_DIR}/../../standards/tasks.md` for the filename and frontmatter. Place the row through `claude-tasks` Step 4, which checks the roster for a live orchestrator before writing `priority.md` or `backlog.md` directly. Aside from an experiment fixture, this and the routing in Step 5 are the only ways close mode reaches outside the folder.
96
96
  5. When the task written in Step 4 does not cover every finding the track surfaced, route what it leaves out through `claude-intake`. Skip this step when it does.
97
97
  6. Report uncited external claims. Closing already reads every file in the folder, so list any statement about a source outside the project that carries neither a link nor a lead entry. Report and do not block, because judging whether a sentence makes an external claim is the call a checker gets wrong.
98
98
 
@@ -65,7 +65,7 @@ For each in-scope entry (see Scope), pick one action:
65
65
  - **Promote to `CLAUDE.md`**: the rule is cross-domain behavior or a design principle applied across the whole project.
66
66
  - **Promote to a skill body**: the rule fires only when editing a specific path-scoped domain. Name the target skill.
67
67
  - **Promote to a standards file**: the rule is an authoring reference that belongs in the project's own standards folder as `<domain>.md`.
68
- - **Promote to a context entry**: the entry states a fact about a domain carrying an entry in `.claude/context/index.md`. Append it to `.canon/tmp/memory-routing/<slug>.md` in the format `claude-memory-capture` writes, and tell the user to run `/claude-docs` from a branch. Do not edit the context entry here.
68
+ - **Promote to a context entry**: the entry states a fact about a domain carrying an entry in `.claude/context/index.md`. Append it to `.canon/tmp/memory-routing/<slug>.md` at the main worktree root, in the format `claude-memory-capture` writes, and tell the user to run `/claude-docs` from a branch. Do not edit the context entry here.
69
69
  - **Hand off to governance**: the rule is coding-standards class (typescript, testing, naming, error-handling, performance, logging, concurrency, planning). Do not author the rule file inline. Never edit the synced `.claude/rules/` copies of toolkit rules, because `canon gov sync` overwrites them. Stop at handoff.
70
70
  - In the toolkit repo, point the user at `internal-governance` and `${CLAUDE_SKILL_DIR}/../../standards/rule.md`, which own the source-of-truth rules under `governance/rules/`.
71
71
  - In a target project, point the user at the `create-rule` skill, which scaffolds a project-local rule under `.claude/rules/`.
@@ -150,7 +150,7 @@ Free-form text after the verb is a reason. Capture it in the receipt but do not
150
150
  Action by action type:
151
151
 
152
152
  - **Promote**: use `Edit` to insert the rewritten rule into the target surface, then archive the memory file.
153
- - **Promote to a context entry**: append the fact to `.canon/tmp/memory-routing/<slug>.md`, then archive the memory file. `claude-docs` folds it in on its next run from a branch, which is what keeps one skill writing context entries.
153
+ - **Promote to a context entry**: append the fact to `.canon/tmp/memory-routing/<slug>.md` at the main worktree root, then archive the memory file. `claude-docs` folds it in on its next run from a branch, which is what keeps one skill writing context entries.
154
154
  - **Hand off**: do not edit governance. Archive the memory file only if the user confirmed the handoff explicitly. Otherwise leave it in place.
155
155
  - **Retire**: archive the memory file.
156
156
 
@@ -11,12 +11,15 @@ Without this skill, a task file gets a filename and frontmatter invented on the
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
 
14
+ Placing a row without checking for another writer collides the same way. Two sessions filing work at once can claim one phase label twice or land two rows beside each other unread, since neither reads the board before writing it.
15
+
14
16
  ## Must
15
17
 
16
18
  - Resolve the board at the main worktree root, since a linked worktree writing to `pwd` creates a second board nothing reads
17
19
  - Read the tasks standard before writing, rather than working the filename and frontmatter from memory
18
20
  - Require an origin at creation, because that is the only moment the invariant is enforceable
19
21
  - Propose the phase label from what is already on the board and pad it to two digits
22
+ - Check the roster for a live orchestrator before writing a row, and hand off rather than write when one is found
20
23
  - Confirm the work reached the default branch before archiving
21
24
  - Run the archive command and route on the reason it refuses, since each reason has one resolution
22
25
  - Report an origin that carries no task, as a list rather than a prompt
@@ -28,6 +31,7 @@ Archiving fails in two ways that both lose work. Moving the file, editing the or
28
31
  - Write a pull request line at creation, when any number is a guess at someone else's work
29
32
  - Archive around a refusal
30
33
  - Split the legacy single-file archive into per-task files nobody wrote
34
+ - Fall through to a direct write when no orchestrator is found and this session's own name starts with `worker-` or `planner-`, since those role bodies ban the write with no exception
31
35
 
32
36
  ## Guards
33
37
 
@@ -47,9 +47,15 @@ Write it immediately. Claude Code's tool permission dialog is the confirmation g
47
47
 
48
48
  A task file with no row is a dropped task, so name the surface it lands on in the same pass that creates it. A task that would plausibly be planned within the next few waves takes a row under `## Needs a plan` in `.canon/tasks/priority.md`, positioned by where it sits against the rows already there, with the reason for that position in its `Waiting on` cell. Anything else takes a line in `.canon/tasks/backlog.md`, which is unordered and where in the file it goes means nothing.
49
49
 
50
- The test and both file shapes are in `${CLAUDE_SKILL_DIR}/../../standards/tasks.md`. From a linked worktree the file-editing tools refuse the main root, so a row lands through the same `Bash` route the file itself took.
50
+ The test and both file shapes are in `${CLAUDE_SKILL_DIR}/../../standards/tasks.md`.
51
51
 
52
- Say which surface it went to and why in the report. The call is a judgment restated on every sweep rather than a property of the task, and a placement with no stated reason is one the next sweep re-derives from nothing.
52
+ Check the roster for a live orchestrator before writing either file. Read `canon sessions list --self --json` for this session's own `sessionId` and `name`, then read `canon sessions list --json` and match a row whose `repository` matches this session's own, whose `sessionId` differs from it, and whose `name` starts with `orchestrator-`. That is the convention every hand-launched controller on this machine currently uses, and it excludes every `worker-` and `planner-` session cleanly. Treat a refusal from either call the same as a roster read that failed.
53
+
54
+ - **Found.** Do not write `priority.md` or `backlog.md`. Report the surface the row would take and the reason worked out above, then hand off by messaging that session with the same information so it places the row itself rather than two sessions writing the board at once.
55
+ - **Not found, and this session's own name (already read above) starts with `worker-` or `planner-`.** Do not write either file. Those two role bodies ban a board write with no exception, and an orchestrator absent from the roster is not consent to break it. Report the row and the surface it would take, so the operator or a later orchestrator places it.
56
+ - **Not found otherwise, or the roster read fails.** Write the row directly, as today. A failed read is indistinguishable from a solo project holding no orchestrator, and stopping would strand the row on the one path this section otherwise keeps unconditional. From a linked worktree the file-editing tools refuse the main root, so the row lands through the same `Bash` route the file itself took.
57
+
58
+ Say which branch fired and why in the report. The call is a judgment restated on every sweep rather than a property of the task, and a placement with no stated reason is one the next sweep re-derives from nothing.
53
59
 
54
60
  ### Step 5: report unlinked origins
55
61
 
@@ -214,7 +214,7 @@ Seven checks run. Plan and Collisions reach one half each of the `## Run now` te
214
214
  | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
215
215
  | Shape | A row whose cell count disagrees with its table's header, or one stranded behind a table a blank line already closed |
216
216
  | Plan | A `## Run now` row whose Plan column carries no link, resolves to no file, or disagrees with the task's own line |
217
- | Mapping | A row or backlog line naming no task file, and a task file neither surface names |
217
+ | Mapping | A row or backlog line naming no task file |
218
218
  | Grouping | A task carrying a row in more than one readiness group, or on both surfaces |
219
219
  | Ordering | A `## Needs a plan` row whose stated position disagrees with where it actually sits, or which states no position at all |
220
220
  | Collisions | Two `## Run now` rows whose Touches columns name a path in common |
@@ -224,9 +224,9 @@ Shape runs before any other check reads a row, since a row failing it carries no
224
224
 
225
225
  The Plan check reads the row and the task file both, because the two are written by different hands and only the task's own `Plan:` line reaches the archive. A row carrying a plan whose task states none is `plan-uncited`, and a pair naming two different plans is `plan-mismatched`. Both sides resolve against the board and against the project root before they compare, so a row writing `../plans/x.md` and a task writing `.canon/plans/x.md` name one file rather than two.
226
226
 
227
- Mapping spans two surfaces, because a task sits on `priority.md` when it would plausibly be planned soon and on `backlog.md` otherwise. A task file either surface names is accounted for, a file neither names is `row-missing`, and a file both name is `row-duplicated` for the reason a task in two groups is: it claims two things about itself and only one can hold. One check across both is what lets a task move between them without the move reading as a dropped file.
227
+ Mapping spans two surfaces, because a task sits on `priority.md` when it would plausibly be planned soon and on `backlog.md` otherwise. A row or a backlog line naming no task file is `task-unresolved`, and a file both surfaces name is `row-duplicated` for the reason a task in two groups is: it claims two things about itself and only one can hold. A task file neither surface names is `unplaced` rather than a finding, since that is the normal state between a session filing it and a live orchestrator placing it on the board. One check across both is what lets a task move between them without the move reading as a dropped file.
228
228
 
229
- A backlog line is a bullet carrying a link to a sibling task, since the backlog is a flat unordered list rather than a table. A bullet holding prose is skipped rather than reported, which keeps the file's own intro out of the findings, and the task that bullet meant to name is still reported as reaching neither surface. A project carrying no `backlog.md` reads as an empty backlog rather than a refusal, which leaves the one-to-one mapping this check ran before the second surface existed.
229
+ A backlog line is a bullet carrying a link to a sibling task, since the backlog is a flat unordered list rather than a table. A bullet holding prose is skipped rather than reported, which keeps the file's own intro out of the findings, and the task that bullet meant to name still lands in the `unplaced` array below rather than the findings. A project carrying no `backlog.md` reads as an empty backlog rather than a refusal, which leaves the one-to-one mapping this check ran before the second surface existed.
230
230
 
231
231
  Ordering reads a `## Needs a plan` row's `Waiting on` cell for the position it claims, and reports two failures off one walk. A row stating an ordinal is checked against where it actually sits, which is `row-misordered`. A row claiming no position in either form it may take is `row-unranked`, since its cell argues the task matters and ranks it against nothing, which leaves the order recording when each row was filed.
232
232
 
@@ -282,6 +282,21 @@ That claim collides with every row a later session writes under the folder, and
282
282
 
283
283
  The scan reaches `## Run now` and stops, where the collision check stops. A cell in another group describes work nobody has planned, written as a sentence and rewritten once a plan exists, so a claim read off one reports on prose rather than on a file set. A parked folder claim surfaces when its row is promoted, which is when the cell becomes something a dispatcher can act on.
284
284
 
285
+ A task file neither surface names lands in a fourth array, on the same reasoning:
286
+
287
+ ```json
288
+ {
289
+ "unplaced": [
290
+ {
291
+ "subject": "v50.6-a-standard-no-skill-reads", // canon-allow-reference: shows the subject field's real vXX.Y-slug shape, not a citation of a real task
292
+ "message": "is a task file with no row on the board and no line on the backlog."
293
+ }
294
+ ]
295
+ }
296
+ ```
297
+
298
+ Under the roster-checked hand-off `claude-tasks` and `claude-groundwork` state, filing a task and placing its row are two acts a different session each may take, so a task caught between the two is ordinary rather than a finding and this array moves no exit code. It still reports, since it is the only local detector for a row a hand-edit dropped, a handoff message that never arrived, or an orchestrator that ended before placing it.
299
+
285
300
  Exit codes: `0` every check passed, `1` refused, `2` at least one finding. The `reason` field carries which gate refused: `no-board`, `no-ordering`, or `no-groups`. A board grouping under headings of its own trips `no-groups` rather than being read against columns it never declared.
286
301
 
287
302
  Columns are read from each table's own header rather than by position, so a project whose board differs from this one is reported for what it lacks. The `index`, `priority`, and `backlog` siblings are skipped, along with every pre-compaction handoff, which takes one file per session under a `session-` prefix. None of them is a task, and a handoff counted as one would be reported as a task carrying no row on every session that wrote one.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.59.0",
4
+ "version": "4.60.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -155,9 +155,9 @@ export function register(program: Command): void {
155
155
  "It also grades the title alone against standards/pr.md's ## Title",
156
156
  'section: the `<type>(<scope>): <subject>` structure, lowercase casing',
157
157
  'for the type, the scope, and the first subject word, and a 72-',
158
- 'character length cap. A review carries no title of its own, so this',
159
- 'check is skipped there rather than graded against the forced empty',
160
- 'string.',
158
+ 'character length cap. A scan given no title, whether from a review',
159
+ 'or from a bare --body or --body-file invocation, skips this check',
160
+ 'rather than grading an empty string.',
161
161
  '',
162
162
  'Exit codes:',
163
163
  ' 0 none of the five found',
@@ -287,12 +287,10 @@ async function runScan(opts: ScanOptions): Promise<number> {
287
287
 
288
288
  const result = scanPhaseLabels(resolved)
289
289
  const spelling = await scanTitleSpelling(resolved.title, process.cwd())
290
- // A review carries no title of its own, so `resolved.title` is forced
291
- // empty and grading it would fail as `structure` for the wrong reason.
290
+ // A scan given no title, whatever produced that absence, has nothing to
291
+ // grade and would otherwise fail as `structure` for the wrong reason.
292
292
  const titleFormat =
293
- resolved.source === 'pull-request'
294
- ? checkTitleFormat(resolved.title)
295
- : undefined
293
+ resolved.title !== '' ? checkTitleFormat(resolved.title) : undefined
296
294
 
297
295
  logStep(resolved.source === 'review' ? 'Review comment' : 'Pull request')
298
296
  logInfo(
@@ -373,7 +371,7 @@ async function runScan(opts: ScanOptions): Promise<number> {
373
371
  : 'Title format issue found',
374
372
  )
375
373
  if (titleFormat === undefined) {
376
- logInfo('a review comment carries no title, so there is no format to grade')
374
+ logInfo('the scan carries no title, so there is no format to grade')
377
375
  } else if (titleFormat.conforms) {
378
376
  logInfo(
379
377
  'the title matches <type>(<scope>): <subject> and its casing and length rules',
@@ -22,6 +22,7 @@ import {
22
22
  import {
23
23
  type Finding,
24
24
  type FolderClaim,
25
+ type Unplaced,
25
26
  type Untested,
26
27
  type ValidateOutcome,
27
28
  validateBoard,
@@ -887,6 +888,17 @@ function reportValidation(
887
888
  } else {
888
889
  for (const claim of outcome.claims) logWarn(describeClaim(claim))
889
890
  }
891
+
892
+ // Unplaced is the normal state between a session filing a task and an
893
+ // orchestrator placing its row, so it moves no exit code. It still reports,
894
+ // since it is the only local detector for a row a hand-edit dropped or a
895
+ // handoff that never arrived.
896
+ logStep('Unplaced')
897
+ if (outcome.unplaced.length === 0) {
898
+ logInfo('every task file sits on the board or the backlog')
899
+ } else {
900
+ for (const task of outcome.unplaced) logWarn(describeUnplaced(task))
901
+ }
890
902
  outro()
891
903
  }
892
904
 
@@ -901,6 +913,7 @@ function reportValidation(
901
913
  findings: outcome.findings,
902
914
  untested: outcome.untested,
903
915
  claims: outcome.claims,
916
+ unplaced: outcome.unplaced,
904
917
  })}\n`,
905
918
  )
906
919
  }
@@ -921,6 +934,10 @@ function describeClaim(claim: FolderClaim): string {
921
934
  return `${claim.group}: ${claim.subject} ${claim.message}`
922
935
  }
923
936
 
937
+ function describeUnplaced(task: Unplaced): string {
938
+ return `${task.subject} ${task.message}`
939
+ }
940
+
924
941
  async function runArchive(
925
942
  task: string | undefined,
926
943
  opts: ArchiveCommandOptions,
@@ -38,7 +38,6 @@ export const FINDING_KINDS = [
38
38
  'plan-uncited',
39
39
  'plan-mismatched',
40
40
  'task-unresolved',
41
- 'row-missing',
42
41
  'row-duplicated',
43
42
  'row-misshapen',
44
43
  'row-untabled',
@@ -83,6 +82,18 @@ export interface FolderClaim {
83
82
  readonly message: string
84
83
  }
85
84
 
85
+ /**
86
+ * A task file neither surface names. That is the normal state between a
87
+ * session filing it and a live orchestrator placing it, so it reports beside
88
+ * the findings and moves no exit code. It is still the only local detector for
89
+ * a row lost to a hand-edit, a handoff message that never arrived, or an
90
+ * orchestrator that ended before placing it.
91
+ */
92
+ export interface Unplaced {
93
+ readonly subject: string
94
+ readonly message: string
95
+ }
96
+
86
97
  /**
87
98
  * A backlog line, which carries a pointer and nothing else. The backlog is
88
99
  * explicitly unordered, so a line has no position to read and no columns to
@@ -116,6 +127,7 @@ export interface ValidateReport {
116
127
  readonly findings: readonly Finding[]
117
128
  readonly untested: readonly Untested[]
118
129
  readonly claims: readonly FolderClaim[]
130
+ readonly unplaced: readonly Unplaced[]
119
131
  }
120
132
 
121
133
  export interface ValidateRefused {
@@ -556,15 +568,15 @@ function resolves(target: string, dir: string, root: string): boolean {
556
568
  }
557
569
 
558
570
  /**
559
- * Accounts every task file against both surfaces the board spans. A task sits
560
- * on `priority.md` when it would plausibly be planned soon and on `backlog.md`
561
- * otherwise, so a file reached by neither is the dropped one this reports and a
562
- * file reached by both claims two contradictory things about itself.
571
+ * Reports a task cited from two places on the board, which claims two
572
+ * contradictory things about itself: a task belongs to exactly one group,
573
+ * board or backlog. A task file neither surface names is no longer reported
574
+ * here, since that is the normal state between a session filing it and a live
575
+ * orchestrator placing it.
563
576
  */
564
577
  function checkMapping(
565
578
  rows: readonly BoardRow[],
566
579
  backlog: readonly BacklogRow[],
567
- stems: readonly string[],
568
580
  dir: string,
569
581
  ): Finding[] {
570
582
  const findings: Finding[] = []
@@ -638,18 +650,6 @@ function checkMapping(
638
650
  }
639
651
  }
640
652
 
641
- for (const stem of stems) {
642
- if (!seen.has(stem) && !listed.has(stem)) {
643
- findings.push({
644
- kind: 'row-missing',
645
- group: undefined,
646
- subject: stem,
647
- message:
648
- 'is a task file with no row on the board and no line on the backlog.',
649
- })
650
- }
651
- }
652
-
653
653
  return findings
654
654
  }
655
655
 
@@ -878,6 +878,32 @@ function checkFolderClaims(
878
878
  return claims
879
879
  }
880
880
 
881
+ /**
882
+ * Reports a task file neither the board nor the backlog names. Under the
883
+ * roster-checked hand-off, filing a task and placing its row are two acts a
884
+ * different session each may perform, so this state is ordinary rather than
885
+ * an error, and it moves no exit code. It still surfaces here rather than
886
+ * nowhere, since it is the only local detector for a row a hand-edit dropped,
887
+ * a handoff message that never arrived, or an orchestrator that ended before
888
+ * placing it.
889
+ */
890
+ function checkUnplaced(
891
+ rows: readonly BoardRow[],
892
+ backlog: readonly BacklogRow[],
893
+ stems: readonly string[],
894
+ ): Unplaced[] {
895
+ const seen = new Set(rows.flatMap((row) => (row.stem ? [row.stem] : [])))
896
+ const listed = new Set(backlog.flatMap((row) => (row.stem ? [row.stem] : [])))
897
+
898
+ return stems
899
+ .filter((stem) => !seen.has(stem) && !listed.has(stem))
900
+ .map((stem) => ({
901
+ subject: stem,
902
+ message:
903
+ 'is a task file with no row on the board and no line on the backlog.',
904
+ }))
905
+ }
906
+
881
907
  /**
882
908
  * Reads the task a blocker cell cites. A task pointer is a bare sibling
883
909
  * filename, the way every `Task` column spells one, so a target carrying a
@@ -1136,7 +1162,7 @@ export async function validateBoard(
1136
1162
 
1137
1163
  const findings = [
1138
1164
  ...shapeFindings,
1139
- ...checkMapping(rows, backlog, stems, dir),
1165
+ ...checkMapping(rows, backlog, dir),
1140
1166
  ...checkPlans(rows, dir, root),
1141
1167
  ...(await checkPlanAgreement(rows, dir, root)),
1142
1168
  ...checkCollisions(rows),
@@ -1152,5 +1178,6 @@ export async function validateBoard(
1152
1178
  findings,
1153
1179
  untested: parked.untested,
1154
1180
  claims: checkFolderClaims(rows, root),
1181
+ unplaced: checkUnplaced(rows, backlog, stems),
1155
1182
  }
1156
1183
  }
@@ -17,3 +17,6 @@ packages = [
17
17
  [scripts.override]
18
18
  "dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && next dev --port $((3000 + WORKTREE_PORT_OFFSET))"
19
19
  "screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 3000) && export PREVIEW_PORT && bash scripts/screenshot.sh"
20
+
21
+ [gitignore]
22
+ "# Next" = [".next/"]
@@ -33,6 +33,12 @@ Next has no config-file port hook, unlike `astro.config.mjs`'s `server.port` or
33
33
 
34
34
  `create-next-app`'s own default `tsconfig.json` needs no changes beyond project-specific path aliases, so a golden copy here would ship nothing the scaffold does not already write.
35
35
 
36
+ ## Gitignore (extend)
37
+
38
+ `[gitignore]` groups this stack edits, restated here per the manifest-to-reference symmetry:
39
+
40
+ - `"# Next" = [".next/"]`
41
+
36
42
  ## Development docs (extend)
37
43
 
38
44
  Append to the `## Scripts` table: