@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.
- package/README.md +1 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/bash-cli-script/REQUIREMENT.md +10 -9
- package/claude/skills/bash-cli-script/SKILL.md +10 -7
- package/claude/skills/bash-cli-script/references/template.md +4 -28
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/read-frames/REQUIREMENT.md +1 -0
- package/claude/skills/read-frames/SKILL.md +1 -0
- package/claude/skills/teach-workspace/SKILL.md +4 -4
- package/claude/skills/ux-measure/REQUIREMENT.md +2 -1
- package/claude/skills/ux-measure/SKILL.md +3 -1
- package/claude/skills/ux-walkthrough/REQUIREMENT.md +3 -0
- package/claude/skills/ux-walkthrough/SKILL.md +1 -0
- package/docs/agents/architecture-record.md +5 -3
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/context-audit.md +3 -3
- package/docs/agents/index.md +2 -2
- package/docs/agents/markdown-audit.md +1 -1
- package/docs/agents/pr-evidence.md +17 -0
- package/docs/agents/teach-authoring.md +3 -3
- package/docs/agents/teach.md +11 -5
- package/docs/workflow/skill-map.md +11 -10
- package/governance/rules/lang/120-bash.md +4 -5
- package/package.json +1 -1
- package/src/audits/catalog.ts +7 -1
- package/src/claude/cases/authoring.ts +0 -5
- package/src/commands/context.ts +25 -2
- package/src/commands/pr.ts +63 -41
- package/src/commands/serve.ts +2 -92
- package/src/commands/teach.ts +142 -17
- package/src/context/architecture.ts +82 -5
- package/src/context/gate.ts +10 -3
- package/src/gate/measures.ts +9 -4
- package/src/gate/stages.ts +1 -1
- package/src/serve/report.ts +110 -0
- package/src/teach/nav.ts +12 -1
- package/standards/architecture.md +14 -3
- package/standards/context.md +1 -1
- package/standards/decisions.md +6 -1
- package/standards/groundwork.md +1 -0
- package/standards/requirements.md +1 -1
- package/standards/rule.md +1 -0
- package/standards/skill.md +1 -1
- package/standards/standard.md +1 -0
- package/tooling/go/configs/scripts/verify.sh +47 -0
- package/tooling/go/reference.md +1 -1
- package/tooling/php/configs/scripts/verify.sh +47 -0
- package/tooling/php/reference.md +1 -1
- package/tooling/python/configs/scripts/verify.sh +47 -0
- package/tooling/python/reference.md +1 -1
- package/tooling/web/configs/scripts/verify.sh +47 -0
- package/tooling/web/reference.md +1 -1
- package/claude/skills/bash-script/REQUIREMENT.md +0 -36
- package/claude/skills/bash-script/SKILL.md +0 -100
- package/claude/skills/bash-script/references/patterns.md +0 -250
- 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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*
|
package/src/context/gate.ts
CHANGED
|
@@ -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
|
|
77
|
-
* each
|
|
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)
|
package/src/gate/measures.ts
CHANGED
|
@@ -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
|
|
243
|
-
* line ceiling its allowances derive and the
|
|
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
|
|
249
|
-
*
|
|
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(' ') }
|
package/src/gate/stages.ts
CHANGED
|
@@ -354,7 +354,7 @@ export const STAGES: readonly Stage[] = [
|
|
|
354
354
|
success: 'Context citations resolve',
|
|
355
355
|
},
|
|
356
356
|
{
|
|
357
|
-
// The record's
|
|
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
|
-
|
|
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
|
|
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
|
|
package/standards/context.md
CHANGED
|
@@ -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
|
package/standards/decisions.md
CHANGED
|
@@ -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>.
|
package/standards/groundwork.md
CHANGED
|
@@ -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.
|
package/standards/skill.md
CHANGED
|
@@ -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
|
|
package/standards/standard.md
CHANGED
|
@@ -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}"
|
package/tooling/go/reference.md
CHANGED
|
@@ -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}"
|
package/tooling/php/reference.md
CHANGED
|
@@ -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
|
|