@erclx/canon 5.3.0 → 5.5.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.
Files changed (56) hide show
  1. package/README.md +1 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/bash-cli-script/REQUIREMENT.md +10 -9
  4. package/claude/skills/bash-cli-script/SKILL.md +10 -7
  5. package/claude/skills/bash-cli-script/references/template.md +4 -28
  6. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  7. package/claude/skills/read-frames/REQUIREMENT.md +1 -0
  8. package/claude/skills/read-frames/SKILL.md +1 -0
  9. package/claude/skills/teach-workspace/SKILL.md +4 -4
  10. package/claude/skills/ux-measure/REQUIREMENT.md +2 -1
  11. package/claude/skills/ux-measure/SKILL.md +3 -1
  12. package/claude/skills/ux-walkthrough/REQUIREMENT.md +3 -0
  13. package/claude/skills/ux-walkthrough/SKILL.md +1 -0
  14. package/docs/agents/architecture-record.md +5 -3
  15. package/docs/agents/context-audit-checks.md +1 -1
  16. package/docs/agents/context-audit.md +3 -3
  17. package/docs/agents/index.md +2 -2
  18. package/docs/agents/markdown-audit.md +1 -1
  19. package/docs/agents/pr-evidence.md +17 -0
  20. package/docs/agents/teach-authoring.md +3 -3
  21. package/docs/agents/teach.md +11 -5
  22. package/docs/workflow/skill-map.md +11 -10
  23. package/governance/rules/lang/120-bash.md +4 -5
  24. package/package.json +1 -1
  25. package/src/audits/catalog.ts +7 -1
  26. package/src/claude/cases/authoring.ts +0 -5
  27. package/src/commands/context.ts +25 -2
  28. package/src/commands/pr.ts +63 -41
  29. package/src/commands/serve.ts +2 -92
  30. package/src/commands/teach.ts +142 -17
  31. package/src/context/architecture.ts +82 -5
  32. package/src/context/gate.ts +10 -3
  33. package/src/gate/measures.ts +9 -4
  34. package/src/gate/stages.ts +1 -1
  35. package/src/serve/report.ts +110 -0
  36. package/src/teach/nav.ts +12 -1
  37. package/standards/architecture.md +14 -3
  38. package/standards/context.md +1 -1
  39. package/standards/decisions.md +6 -1
  40. package/standards/groundwork.md +1 -0
  41. package/standards/requirements.md +1 -1
  42. package/standards/rule.md +1 -0
  43. package/standards/skill.md +1 -1
  44. package/standards/standard.md +1 -0
  45. package/tooling/go/configs/scripts/verify.sh +47 -0
  46. package/tooling/go/reference.md +1 -1
  47. package/tooling/php/configs/scripts/verify.sh +47 -0
  48. package/tooling/php/reference.md +1 -1
  49. package/tooling/python/configs/scripts/verify.sh +47 -0
  50. package/tooling/python/reference.md +1 -1
  51. package/tooling/web/configs/scripts/verify.sh +47 -0
  52. package/tooling/web/reference.md +1 -1
  53. package/claude/skills/bash-script/REQUIREMENT.md +0 -36
  54. package/claude/skills/bash-script/SKILL.md +0 -100
  55. package/claude/skills/bash-script/references/patterns.md +0 -250
  56. package/claude/skills/bash-script/references/prompts.md +0 -100
@@ -63,6 +63,12 @@ export interface DecisionReport {
63
63
  * it. Never gates, per `standards/architecture.md`'s `## Length` section.
64
64
  */
65
65
  readonly words: number
66
+ /**
67
+ * Whether a sentence outside code opens with `Revisit when`. Read on every
68
+ * record and required only by one stating the clause, so a project that
69
+ * never adopted it sees the reading without failing on it.
70
+ */
71
+ readonly revisit: boolean
66
72
  }
67
73
 
68
74
  export interface ArchitectureReport {
@@ -86,6 +92,12 @@ export interface ArchitectureReport {
86
92
  * project that never adopted a cap is measured and never gated.
87
93
  */
88
94
  readonly entryCap?: number
95
+ /**
96
+ * Whether the record states that every decision closes with a revisit
97
+ * sentence. Declared by the record for the reason the cap is, so a record
98
+ * that never wrote the clause is reported and never gated.
99
+ */
100
+ readonly revisitRequired: boolean
89
101
  readonly decisions: readonly DecisionReport[]
90
102
  }
91
103
 
@@ -214,6 +226,8 @@ interface RawDecision {
214
226
  readonly heading: string
215
227
  readonly line: number
216
228
  readonly body: string
229
+ /** The body with its fenced lines dropped, which is where prose is read. */
230
+ readonly prose: string
217
231
  }
218
232
 
219
233
  /**
@@ -230,7 +244,19 @@ interface RawDecision {
230
244
  export function splitDecisions(source: string): RawDecision[] {
231
245
  const lines = bodyLines(source)
232
246
  const decisions: RawDecision[] = []
233
- let open: { heading: string; line: number; body: string[] } | undefined
247
+ let open:
248
+ | { heading: string; line: number; body: string[]; prose: string[] }
249
+ | undefined
250
+
251
+ const close = (): void => {
252
+ if (!open) return
253
+ decisions.push({
254
+ heading: open.heading,
255
+ line: open.line,
256
+ body: open.body.join('\n'),
257
+ prose: open.prose.join('\n'),
258
+ })
259
+ }
234
260
 
235
261
  for (const line of lines) {
236
262
  if (line.fenced) {
@@ -240,23 +266,24 @@ export function splitDecisions(source: string): RawDecision[] {
240
266
 
241
267
  const heading = line.text.match(DECISION_HEADING)?.[1]
242
268
  if (heading !== undefined) {
243
- if (open) decisions.push({ ...open, body: open.body.join('\n') })
244
- open = { heading, line: line.number, body: [] }
269
+ close()
270
+ open = { heading, line: line.number, body: [], prose: [] }
245
271
  continue
246
272
  }
247
273
 
248
274
  // A decision runs to the next `###` or to the section that follows the
249
275
  // decision list, so the risks below never read as the last entry's body.
250
276
  if (SECTION_HEADING.test(line.text)) {
251
- if (open) decisions.push({ ...open, body: open.body.join('\n') })
277
+ close()
252
278
  open = undefined
253
279
  continue
254
280
  }
255
281
 
256
282
  open?.body.push(line.text)
283
+ open?.prose.push(line.text)
257
284
  }
258
285
 
259
- if (open) decisions.push({ ...open, body: open.body.join('\n') })
286
+ close()
260
287
  return decisions
261
288
  }
262
289
 
@@ -383,6 +410,41 @@ export function readEntryCap(source: string): number | undefined {
383
410
  return readCardinal(source.match(ENTRY_CAP_CLAUSE)?.[1])
384
411
  }
385
412
 
413
+ const REVISIT_CLAUSE =
414
+ /\bevery\s+decision\s+closes\s+with\s+a\s+revisit\s+sentence\b/i
415
+ /**
416
+ * `Revisit when` opening a sentence: at the start of the text, after a line
417
+ * break, or after a sentence's closing stop. Mid-sentence it is not the
418
+ * sentence the standard asks for.
419
+ */
420
+ const REVISIT_SENTENCE = /(?:^|\n|[.!?]\s+)\s*(?:[-*]\s+)?Revisit when\b/
421
+
422
+ /**
423
+ * Whether the record states the revisit clause for itself, read off its prose
424
+ * alone so a template quoting the clause inside a fence does not adopt it.
425
+ *
426
+ * The clause is the record's own, like the cap, so wording that drifts past it
427
+ * falls back to reporting rather than to a requirement held here.
428
+ */
429
+ export function readRevisitRule(source: string): boolean {
430
+ const prose = bodyLines(source)
431
+ .filter((line) => !line.fenced)
432
+ .map((line) => line.text)
433
+ .join('\n')
434
+ .replace(CODE_SPAN, ' ')
435
+ return REVISIT_CLAUSE.test(prose)
436
+ }
437
+
438
+ /**
439
+ * Whether a decision's prose carries a sentence opening `Revisit when`.
440
+ *
441
+ * Presence only, never content, so a vacuous sentence passes. A code span is
442
+ * dropped first, since a standard quoting the form is not stating one.
443
+ */
444
+ export function hasRevisitSentence(prose: string): boolean {
445
+ return REVISIT_SENTENCE.test(prose.replace(CODE_SPAN, ' '))
446
+ }
447
+
386
448
  /** Whether a read failed because nothing sits at the path. */
387
449
  function isMissing(error: unknown): boolean {
388
450
  const code = (error as { code?: unknown }).code
@@ -421,6 +483,7 @@ export async function measureArchitecture(
421
483
 
422
484
  const allowances = readAllowances(source)
423
485
  const entryCap = readEntryCap(source)
486
+ const revisitRequired = readRevisitRule(source)
424
487
  const raw = splitDecisions(source)
425
488
  const decisions = await Promise.all(
426
489
  raw.map(async (entry) => {
@@ -433,6 +496,7 @@ export async function measureArchitecture(
433
496
  ...(quantified !== undefined && { quantified }),
434
497
  checks: await namedChecks(root, entry.body),
435
498
  words: wordCount(entry.body),
499
+ revisit: hasRevisitSentence(entry.prose),
436
500
  }
437
501
  }),
438
502
  )
@@ -453,6 +517,7 @@ export async function measureArchitecture(
453
517
  ceiling: ceilingFor(allowances, raw.length),
454
518
  }),
455
519
  ...(entryCap !== undefined && { entryCap }),
520
+ revisitRequired,
456
521
  decisions,
457
522
  }
458
523
  }
@@ -470,6 +535,18 @@ export function isOverCount(report: ArchitectureReport): boolean {
470
535
  )
471
536
  }
472
537
 
538
+ /**
539
+ * The headings of decisions lacking a revisit sentence the record requires.
540
+ *
541
+ * Empty for a record stating no clause, for the reason `isOverCount` gives.
542
+ */
543
+ export function missingRevisit(report: ArchitectureReport): string[] {
544
+ if (!report.revisitRequired) return []
545
+ return report.decisions
546
+ .filter((entry) => !entry.revisit)
547
+ .map((entry) => entry.heading)
548
+ }
549
+
473
550
  /**
474
551
  * Whether the record is longer than the ceiling it derives for itself.
475
552
  *
@@ -19,6 +19,12 @@ export interface GateInput {
19
19
  * gates under the same two modes and is false in the same two cases.
20
20
  */
21
21
  readonly recordOverCount: boolean
22
+ /**
23
+ * Whether a decision lacks the revisit sentence the record requires of
24
+ * every decision, which gates under the same two modes and is false in the
25
+ * same two cases, plus a record stating no such clause.
26
+ */
27
+ readonly recordMissingRevisit: boolean
22
28
  readonly sections: readonly SectionFinding[]
23
29
  readonly drift: readonly FolderDrift[]
24
30
  /**
@@ -73,8 +79,8 @@ export function hasSketchWithEvidence(
73
79
  * Whether the audit found something that should fail the caller.
74
80
  *
75
81
  * An unresolved citation is a broken pointer and gates unconditionally, and so
76
- * does a record past its own ceiling or its own entry cap: the record states
77
- * each limit for itself, which makes those the measures here that are facts
82
+ * does a record past its own ceiling or its own entry cap, or missing a
83
+ * revisit sentence it requires: the record states each rule for itself, which makes those the measures here that are facts
78
84
  * rather than thresholds a reader weighs. The findings `--gate` adds are
79
85
  * the ones answerable from the file itself: a required section it does not
80
86
  * declare, an index disagreeing with its folder, and a wireframe's States
@@ -88,13 +94,14 @@ export function isGating({
88
94
  unresolvedCitations,
89
95
  recordOverLength,
90
96
  recordOverCount,
97
+ recordMissingRevisit,
91
98
  sections,
92
99
  drift,
93
100
  wireframes,
94
101
  widened,
95
102
  }: GateInput): boolean {
96
103
  if (unresolvedCitations > 0) return true
97
- if (recordOverLength || recordOverCount) return true
104
+ if (recordOverLength || recordOverCount || recordMissingRevisit) return true
98
105
  if (!widened) return false
99
106
 
100
107
  return sections.length > 0 || hasDrift(drift) || hasStatesMismatch(wireframes)
@@ -11,6 +11,7 @@ import {
11
11
  isOverCount,
12
12
  isOverLength,
13
13
  measureArchitecture,
14
+ missingRevisit,
14
15
  } from '@/context/architecture'
15
16
  import { auditSkills } from '@/claude/skills-audit'
16
17
  import { listRepositoryFiles } from '@/git-files'
@@ -239,14 +240,15 @@ function citedPaths(record: { paths?: unknown } | undefined): string[] {
239
240
  }
240
241
 
241
242
  /**
242
- * The architecture record against the two limits it states for itself: the
243
- * line ceiling its allowances derive and the entry cap.
243
+ * The architecture record against the three rules it states for itself: the
244
+ * line ceiling its allowances derive, the entry cap, and the revisit sentence
245
+ * closing each decision.
244
246
  *
245
247
  * Read in-process rather than through `context audit`, whose one gating stage
246
248
  * here runs `--citations-only` and never opens the record, which is why the
247
249
  * line ceiling went unenforced by `bun run check` until this stage. A project
248
- * carrying no record, or a record stating neither limit, passes, since both
249
- * limits belong to the record rather than to the toolkit.
250
+ * carrying no record, or a record stating none of the three, passes, since
251
+ * each rule belongs to the record rather than to the toolkit.
250
252
  */
251
253
  export const architectureRecord: Measure = async (ctx) => {
252
254
  const report = await measureArchitecture(ctx.root)
@@ -255,11 +257,14 @@ export const architectureRecord: Measure = async (ctx) => {
255
257
  }
256
258
 
257
259
  const decisions = report.decisions.length
260
+ const lackingRevisit = missingRevisit(report)
258
261
  const failures = [
259
262
  isOverCount(report) &&
260
263
  `${decisions} decisions against a cap of ${report.entryCap}. Merge two or retire one in ${report.rel}, never compress.`,
261
264
  isOverLength(report) &&
262
265
  `${report.lines} lines against a ceiling of ${report.ceiling} in ${report.rel}.`,
266
+ lackingRevisit.length > 0 &&
267
+ `No revisit sentence in ${lackingRevisit.map((heading) => `"${heading}"`).join(', ')}. Close each with one opening "Revisit when" in ${report.rel}.`,
263
268
  ].filter((failure): failure is string => typeof failure === 'string')
264
269
 
265
270
  if (failures.length > 0) return { emissions: [], failure: failures.join(' ') }
@@ -354,7 +354,7 @@ export const STAGES: readonly Stage[] = [
354
354
  success: 'Context citations resolve',
355
355
  },
356
356
  {
357
- // The record's two limits are facts it states about itself, unlike the
357
+ // The record's three rules are facts it states about itself, unlike the
358
358
  // judgment thresholds the stage above leaves out, so they gate here.
359
359
  id: 'architecture-record',
360
360
  label: 'Architecture record',
@@ -0,0 +1,110 @@
1
+ import { relative } from 'node:path'
2
+ import {
3
+ DEFAULT_PORT,
4
+ type ServeOutcome,
5
+ type ServeStarted,
6
+ } from '@/serve/static'
7
+ import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
8
+
9
+ /**
10
+ * The relative path where it stays inside the working directory, and the
11
+ * absolute one where it climbs out. A run from a linked worktree serving the
12
+ * main root reports `../../teach`, which names the directory without giving a
13
+ * reader anything they can open.
14
+ */
15
+ export function displayPath(root: string): string {
16
+ const near = relative(process.cwd(), root)
17
+ if (near === '') return '.'
18
+ return near.startsWith('..') ? root : near
19
+ }
20
+
21
+ /**
22
+ * Undefined for a value that is not a port, distinct from an absent flag,
23
+ * which takes the default. A `Number` of a typo is `NaN`, and passing that on
24
+ * asks the runtime to bind a port nobody named.
25
+ */
26
+ export function parsePort(raw: string | undefined): number | undefined {
27
+ if (raw === undefined) return DEFAULT_PORT
28
+ const value = Number(raw)
29
+ if (!Number.isInteger(value) || value < 0 || value > 65535) return undefined
30
+ return value
31
+ }
32
+
33
+ /** The fields a started server reports, shared by every command that serves. */
34
+ export function serveFields(outcome: ServeStarted): {
35
+ readonly host: string
36
+ readonly port: number
37
+ readonly entry: string
38
+ readonly url: string
39
+ readonly entryExists: boolean
40
+ } {
41
+ return {
42
+ host: outcome.host,
43
+ port: outcome.port,
44
+ entry: outcome.entry,
45
+ url: outcome.url,
46
+ entryExists: outcome.entryExists,
47
+ }
48
+ }
49
+
50
+ /** The steps a framed report prints for a running server, inside its frame. */
51
+ export function logServing(outcome: ServeStarted): void {
52
+ logStep('Serving')
53
+ logInfo(displayPath(outcome.root))
54
+ logStep('Open')
55
+ logInfo(outcome.url)
56
+
57
+ if (!outcome.entryExists) {
58
+ logStep('No entry page')
59
+ logWarn(
60
+ `${outcome.entry} is not in that directory, so the link opens a 404`,
61
+ )
62
+ }
63
+
64
+ logStep('Stop')
65
+ logInfo('Ctrl-C')
66
+ }
67
+
68
+ export function report(outcome: ServeOutcome, emitJson: boolean): number {
69
+ if (!outcome.ok) {
70
+ if (emitJson) {
71
+ process.stdout.write(
72
+ `${JSON.stringify({ ok: false, reason: outcome.reason, detail: outcome.detail })}\n`,
73
+ )
74
+ return 1
75
+ }
76
+ intro('canon serve')
77
+ logError(outcome.detail)
78
+ outro()
79
+ return 1
80
+ }
81
+
82
+ if (emitJson) {
83
+ process.stdout.write(
84
+ `${JSON.stringify({ ok: true, root: outcome.root, ...serveFields(outcome) })}\n`,
85
+ )
86
+ return 0
87
+ }
88
+
89
+ intro('canon serve')
90
+ logServing(outcome)
91
+ return 0
92
+ }
93
+
94
+ /**
95
+ * Resolves when the process is asked to stop. The frame is closed here rather
96
+ * than in the reporter, because the run is the serving rather than the start,
97
+ * and closing at start would print the frame's end while the server ran on.
98
+ */
99
+ export function waitForInterrupt(stop: () => Promise<void>): Promise<void> {
100
+ return new Promise((settle) => {
101
+ const finish = () => {
102
+ void stop().then(() => {
103
+ outro()
104
+ settle()
105
+ })
106
+ }
107
+ process.once('SIGINT', finish)
108
+ process.once('SIGTERM', finish)
109
+ })
110
+ }
package/src/teach/nav.ts CHANGED
@@ -1613,8 +1613,19 @@ export async function generateNav(
1613
1613
  const detail = found.workspace
1614
1614
  const metas = await readLessonMetas(root, detail)
1615
1615
 
1616
+ // The generated base sheet is rewritten on every run so a CLI upgrade
1617
+ // reaches it, while a legacy workspace holding only a hand-authored
1618
+ // course.css is never handed a base sheet its lessons do not link.
1616
1619
  const cssPath = join(root, detail.path, TEACH_ASSETS, TEACH_STYLESHEET)
1617
- if (!existsSync(cssPath)) await writeStylesheet(root, detail.slug)
1620
+ const basePath = join(
1621
+ root,
1622
+ detail.path,
1623
+ TEACH_ASSETS,
1624
+ TEACH_STYLESHEET_BASE,
1625
+ )
1626
+ if (existsSync(basePath) || !existsSync(cssPath)) {
1627
+ await writeStylesheet(root, detail.slug)
1628
+ }
1618
1629
 
1619
1630
  const contentsPath = join(root, detail.path, 'index.html')
1620
1631
  await writeFile(
@@ -65,6 +65,17 @@ A decision's reasoning stays correct while the numbers it cites move. The anchor
65
65
  - Do not edit a claim in the pass that first anchors it. The anchor states what the claim was measured against, so changing both at once leaves nothing to check the anchor against.
66
66
  - Refresh the anchor whenever the number is re-read, whether or not it moved. A confirmed number and an unread one are the same text without the date.
67
67
 
68
+ ## Revisit condition
69
+
70
+ A decision says why it won and rarely says when that reason stops holding, so an entry can go stale while every word in it stays true. The revisit sentence names the finding that would show the harm the decision prevents has gone or moved.
71
+
72
+ - Close each decision with one sentence of the form `Revisit when <finding>.` Name something a reader could notice, such as a platform release, a measured count crossing a line, or a dependency dropping a behavior.
73
+ - Draft it from the harm the decision prevents, never from the mechanism it uses. A mechanism can keep working long after the reason for choosing it is gone, so re-checking it confirms the wrong fact.
74
+ - Place the sentence after the reasoning and before any verification anchor, so the anchor stays the entry's last sentence.
75
+ - Give a heading holding several principles one sentence, for the premise they share, rather than one per principle.
76
+ - Adopt the requirement by stating the clause `Every decision closes with a revisit sentence.` in the record itself. The clause is the record's own, like the cap, so a checker reads it from the file and gates only a record that states it. A record stating none is reported and never gated.
77
+ - A checker reads presence alone. A vacuous sentence passes it, so the sentence is only as useful as the finding it names.
78
+
68
79
  ## Entry cap
69
80
 
70
81
  Every session pays for this file before any work starts, so a heavy read is a real cost. A file that reads heavy is carrying too many decisions, not decisions written too long, so the bound is a count of decisions rather than of words.
@@ -78,20 +89,20 @@ Every session pays for this file before any work starts, so a heavy read is a re
78
89
 
79
90
  ## Template
80
91
 
81
- The anchor sentence closes a decision whose reasoning cites a measured number and is absent from one that cites none. Each heading below names a slot, so a record starts with one decision per slot and renames each heading to the choice it records.
92
+ The revisit sentence closes the reasoning of every decision. The anchor sentence follows it on a decision whose reasoning cites a measured number and is absent from one that cites none. Each heading below names a slot, so a record starts with one decision per slot and renames each heading to the choice it records.
82
93
 
83
94
  ```markdown
84
95
  # Architecture
85
96
 
86
97
  ## Overview
87
98
 
88
- This record holds at most 12 decisions.
99
+ This record holds at most 12 decisions. Every decision closes with a revisit sentence.
89
100
 
90
101
  ## Key technical decisions
91
102
 
92
103
  ### Stack and runtime
93
104
 
94
- What was chosen, the alternative that lost, and why. Measured at <short-sha> on <YYYY-MM-DD>.
105
+ What was chosen, the alternative that lost, and why. Revisit when <finding>. Measured at <short-sha> on <YYYY-MM-DD>.
95
106
 
96
107
  ### Delivery
97
108
 
@@ -56,7 +56,7 @@ Both fields feed `canon/context/index.md` when regenerated.
56
56
 
57
57
  Include whenever the domain has content for them. Never pad an entry with an empty heading.
58
58
 
59
- - `## Decisions`: non-obvious choices and the reasoning behind them. The highest-value section in the folder and the one most often missing.
59
+ - `## Decisions`: non-obvious choices and the reasoning behind them. The highest-value section in the folder and the one most often missing. A decision resting on how an outside tool, an installer, or the model behaves may close with one `Revisit when <finding>.` sentence naming the harm the decision prevents, and a decision resting on the project's own code takes none.
60
60
  - `## Gotchas`: workarounds, things tried and rejected, scar tissue worth preserving.
61
61
 
62
62
  ## Optional sections
@@ -45,11 +45,12 @@ A record failing these is non-conforming even when it satisfies every shape rule
45
45
 
46
46
  ## Sections
47
47
 
48
- Use `## Context`, `## Decision`, `## Alternatives`, and `## Measurements`.
48
+ Use `## Context`, `## Decision`, `## Alternatives`, `## Revisit when`, and `## Measurements`.
49
49
 
50
50
  - `## Context`: the problem as it stood, stated so a reader needs nothing else open. Restate a fact rather than pointing at where it was found.
51
51
  - `## Decision`: what was chosen, and why, in enough detail that a reader can tell it apart from an alternative that sounds similar.
52
52
  - `## Alternatives`: each one considered and dropped, with the reason it lost. An alternative with no stated reason reads as a claim nobody checked.
53
+ - `## Revisit when`: one bullet per finding that would reopen the decision. Required when the record came from a groundwork track whose deciding leans carried "Overturned by" lines, which carry over as written. It is optional otherwise, for a decision resting on how an outside tool, an installer, or the model behaves. Skip it for a decision resting on the project's own code.
53
54
  - `## Measurements`: skip when the decision cites no number. When it does, state the number and close with the commit it was read against, per Verification anchors below.
54
55
 
55
56
  ## Verification anchors
@@ -94,6 +95,10 @@ description: <one line naming what was decided>
94
95
  - **<Alternative>.** <Why it lost.>
95
96
  - **<Alternative>.** <Why it lost.>
96
97
 
98
+ ## Revisit when
99
+
100
+ - <The finding that would reopen the decision.>
101
+
97
102
  ## Measurements
98
103
 
99
104
  <The number the decision rests on.> Measured at <short-sha> on <YYYY-MM-DD>.
@@ -102,6 +102,7 @@ Closes the folder. Everything above it is input.
102
102
  - The goal
103
103
  - The items to do
104
104
  - What was considered and dropped
105
+ - `## Revisit when`: the "Overturned by" line of each lean that became the decision, carried over as written. A lean the decision did not take carries nothing, and a track whose deciding leans carried no overturn line omits the section.
105
106
 
106
107
  The dropped list is what stops a future session re-proposing something already rejected.
107
108
 
@@ -21,7 +21,7 @@ Does not govern:
21
21
 
22
22
  - The problem being solved and for whom
23
23
  - User-facing goals stated as outcomes, not implementation
24
- - Explicit non-goals that prevent feature creep. Mark deferred items "(deferred)" so they read as paused, not excluded.
24
+ - Explicit non-goals that prevent feature creep. Mark deferred items "(deferred)" so they read as paused, not excluded. A non-goal resting on how an outside tool, an installer, or the model behaves may close with one `Revisit when <finding>.` sentence, and a non-goal resting on scope alone takes none.
25
25
  - MVP features as a numbered list: feature name and one-line description
26
26
  - Tech stack as a plain list of tools
27
27
  - Hard constraints that shape every decision
package/standards/rule.md CHANGED
@@ -79,6 +79,7 @@ paths:
79
79
  - Use imperative voice for every rule (`Prefix booleans with is`, not `Booleans should be prefixed`)
80
80
  - State one rule per bullet as a single directive line
81
81
  - State what to do and what not to do. Do not explain the reasoning behind a rule.
82
+ - End the directive line of a ban asserting how the model or a tool behaves with one `Revisit when <finding>.` sentence, which states a condition rather than the reasoning. A style, safety, or consistency ban takes none.
82
83
  - Phrase a rule as a ban on the forbidden shape when it could otherwise enumerate allowed options, so it stays stable as categories grow
83
84
  - Cut any rule that resists crisp one-line phrasing. Vague guidance is worse than none.
84
85
  - Keep the file to one topic. A second topic is a second rule file.
@@ -105,7 +105,7 @@ allowed-tools: <tools required>
105
105
  - Group bullets under H2 headings by domain concern. Keep dos and don'ts together under the topic they belong to rather than splitting them into flat rules and constraints sections.
106
106
  - One actionable constraint per bullet. Prefer the `X over Y` form for preferences.
107
107
  - Do not include code examples unless a one to three line inline snippet captures a pattern the model cannot infer.
108
- - Do not duplicate general knowledge the model already has. Focus on project-specific conventions and preferences.
108
+ - Do not duplicate general knowledge the model already has. Focus on project-specific conventions and preferences. Revisit when removing a line of general knowledge from a skill is shown to change what the agent does.
109
109
 
110
110
  ### Progressive disclosure
111
111
 
@@ -79,6 +79,7 @@ A standard failing these questions is non-conforming even when it satisfies ever
79
79
  - State the forbidden shape rather than enumerating allowed options, so a rule survives new categories
80
80
  - Cut any rule that resists a crisp one-line phrasing
81
81
  - Do not pad with filler prose. Every line earns its place as a usable reference entry.
82
+ - Close a ban asserting how the model or a tool behaves with one `Revisit when <finding>.` sentence, since that claim can stop holding while the ban reads the same. A style, safety, or consistency ban takes none, and filling one with a condition is padding.
82
83
 
83
84
  ## Success criterion
84
85
 
@@ -52,6 +52,50 @@ run_base_phase() {
52
52
  log_info "$pass_msg"
53
53
  }
54
54
 
55
+ check_markdown_bans() {
56
+ if ! command -v canon >/dev/null 2>&1; then
57
+ log_info "Skipped: no canon binary on PATH. Install with \`bun install --global @erclx/canon\`."
58
+ return 0
59
+ fi
60
+
61
+ local files=()
62
+ while IFS= read -r file; do
63
+ files+=("$file")
64
+ done < <(git ls-files --cached --others --exclude-standard -- '*.md' ':(exclude)CHANGELOG.md' ':(exclude)**/CHANGELOG.md')
65
+
66
+ if [ ${#files[@]} -eq 0 ]; then
67
+ log_info "No markdown file to check outside the exclusion set."
68
+ return 0
69
+ fi
70
+
71
+ local output code
72
+ if output=$(canon markdown audit "${files[@]}" 2>&1); then
73
+ code=0
74
+ else
75
+ code=$?
76
+ fi
77
+
78
+ case "$code" in
79
+ 0)
80
+ log_info "No banned character"
81
+ ;;
82
+ 1)
83
+ log_info "Skipped: canon markdown audit refused and measured nothing."
84
+ ;;
85
+ 2)
86
+ echo "$output" | pipe_output
87
+ log_error "Markdown prose carries a banned character, or a relative link resolves to nothing on disk"
88
+ ;;
89
+ 3)
90
+ echo "$output" | pipe_output
91
+ log_error "The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for"
92
+ ;;
93
+ *)
94
+ log_error "canon markdown audit exited $code, which is neither a pass nor a finding"
95
+ ;;
96
+ esac
97
+ }
98
+
55
99
  run_check() {
56
100
  local cmd=$1
57
101
  local err_msg=$2
@@ -86,6 +130,9 @@ main() {
86
130
  run_check "bun run test:run" "Unit tests failed"
87
131
  log_info "Unit tests passed"
88
132
 
133
+ log_step "Markdown bans"
134
+ check_markdown_bans
135
+
89
136
  if [ "$NESTED" = false ]; then
90
137
  echo -e "${GREY}└${NC}\n"
91
138
  echo -e "${GREEN}✓ Verification passed${NC}"
@@ -28,7 +28,7 @@ The go stack also ships `smoke_test.go` at the module root as a copy-once seed.
28
28
 
29
29
  ## verify.sh
30
30
 
31
- - Run Typecheck (`go vet`), Lint (`golangci-lint run`), and Unit tests (`go test -v`), plus the four base phases wherever the folder declares their scripts.
31
+ - Run Typecheck (`go vet`), Lint (`golangci-lint run`), and Unit tests (`go test -v`), plus the four base phases wherever the folder declares their scripts, then the markdown bans stage base ships.
32
32
  - Keep `build` out of `verify.sh`. `go build ./...` exits 1 with `no packages to build` on a module holding only a test file, and a seeded `main.go` would assume every module is a binary rather than a library.
33
33
  - Keep `-race` out of `test:run`. The race detector needs cgo and a C toolchain, so it belongs in CI rather than in a local `bun run check`.
34
34
 
@@ -56,6 +56,50 @@ run_base_phase() {
56
56
  log_info "$pass_msg"
57
57
  }
58
58
 
59
+ check_markdown_bans() {
60
+ if ! command -v canon >/dev/null 2>&1; then
61
+ log_info "Skipped: no canon binary on PATH. Install with \`bun install --global @erclx/canon\`."
62
+ return 0
63
+ fi
64
+
65
+ local files=()
66
+ while IFS= read -r file; do
67
+ files+=("$file")
68
+ done < <(git ls-files --cached --others --exclude-standard -- '*.md' ':(exclude)CHANGELOG.md' ':(exclude)**/CHANGELOG.md')
69
+
70
+ if [ ${#files[@]} -eq 0 ]; then
71
+ log_info "No markdown file to check outside the exclusion set."
72
+ return 0
73
+ fi
74
+
75
+ local output code
76
+ if output=$(canon markdown audit "${files[@]}" 2>&1); then
77
+ code=0
78
+ else
79
+ code=$?
80
+ fi
81
+
82
+ case "$code" in
83
+ 0)
84
+ log_info "No banned character"
85
+ ;;
86
+ 1)
87
+ log_info "Skipped: canon markdown audit refused and measured nothing."
88
+ ;;
89
+ 2)
90
+ echo "$output" | pipe_output
91
+ log_error "Markdown prose carries a banned character, or a relative link resolves to nothing on disk"
92
+ ;;
93
+ 3)
94
+ echo "$output" | pipe_output
95
+ log_error "The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for"
96
+ ;;
97
+ *)
98
+ log_error "canon markdown audit exited $code, which is neither a pass nor a finding"
99
+ ;;
100
+ esac
101
+ }
102
+
59
103
  run_check() {
60
104
  local cmd=$1
61
105
  local err_msg=$2
@@ -91,6 +135,9 @@ main() {
91
135
  run_check "bun run test:run" "Unit tests failed"
92
136
  log_info "Unit tests passed"
93
137
 
138
+ log_step "Markdown bans"
139
+ check_markdown_bans
140
+
94
141
  if [ "$NESTED" = false ]; then
95
142
  echo -e "${GREY}└${NC}\n"
96
143
  echo -e "${GREEN}✓ Verification passed${NC}"
@@ -42,7 +42,7 @@ The php stack also ships `tests/SmokeTest.php` as a copy-once seed, so `bun run
42
42
  ## verify.sh
43
43
 
44
44
  - Stop and name `composer install` when `vendor/bin/phpunit` is missing.
45
- - Run Typecheck (`phpstan analyse`), Lint (`php-cs-fixer check`), and Unit tests (`phpunit --testdox`), plus the four base phases wherever the folder declares their scripts.
45
+ - Run Typecheck (`phpstan analyse`), Lint (`php-cs-fixer check`), and Unit tests (`phpunit --testdox`), plus the four base phases wherever the folder declares their scripts, then the markdown bans stage base ships.
46
46
 
47
47
  ## Gitignore
48
48