@erclx/canon 5.17.0 → 5.18.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
- package/claude/skills/deploy-app/SKILL.md +45 -0
- package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
- package/claude/skills/deploy-app/references/vercel.md +72 -0
- package/docs/agents/architecture-record.md +12 -4
- package/docs/agents/canvas.md +13 -1
- package/docs/agents/commands.md +2 -2
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/context-audit.md +4 -4
- package/docs/agents/index.md +2 -2
- package/docs/workflow/skill-map.md +9 -9
- package/package.json +1 -1
- package/src/audits/catalog.ts +46 -1
- package/src/canvas/address.ts +73 -39
- package/src/canvas/client/inspector.tsx +241 -33
- package/src/canvas/client/pages-panel.tsx +51 -0
- package/src/canvas/client/shell.css +126 -1
- package/src/canvas/client/state.ts +120 -4
- package/src/canvas/client/theme-panel.tsx +55 -0
- package/src/canvas/content.ts +14 -3
- package/src/canvas/edit.ts +360 -0
- package/src/canvas/server.ts +50 -5
- package/src/canvas/tokens.ts +82 -0
- package/src/claude/cases/misc.ts +5 -1
- package/src/commands/canvas.ts +95 -1
- package/src/commands/context.ts +85 -5
- package/src/context/architecture.ts +98 -18
- package/src/context/gate.ts +30 -3
- package/src/context/requirements.ts +77 -0
- package/src/gate/measures.ts +69 -28
- package/src/gate/stages.ts +5 -5
- package/standards/architecture.md +13 -7
- package/standards/requirements.md +4 -0
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
- package/tooling/cloudflare/reference.md +1 -1
- package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { access, readFile } from 'node:fs/promises'
|
|
2
2
|
import { join, relative } from 'node:path'
|
|
3
3
|
import { AUDITS } from '@/audits/catalog'
|
|
4
|
-
import { bodyLines } from '@/markdown/scan'
|
|
4
|
+
import { type BodyLine, bodyLines } from '@/markdown/scan'
|
|
5
5
|
import { surfaceDir } from '@/surface-root'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -58,9 +58,9 @@ export interface DecisionReport {
|
|
|
58
58
|
*/
|
|
59
59
|
readonly checks: readonly string[]
|
|
60
60
|
/**
|
|
61
|
-
* The entry's
|
|
62
|
-
*
|
|
63
|
-
*
|
|
61
|
+
* The entry's prose word count, its heading and fenced lines left out. Gates
|
|
62
|
+
* only against a per-decision cap the record states, so the report and the
|
|
63
|
+
* stage read one number.
|
|
64
64
|
*/
|
|
65
65
|
readonly words: number
|
|
66
66
|
/**
|
|
@@ -82,6 +82,12 @@ export interface ArchitectureReport {
|
|
|
82
82
|
* only what is still open, so its weight is read the same way the file's is.
|
|
83
83
|
*/
|
|
84
84
|
readonly risksWords?: number
|
|
85
|
+
/**
|
|
86
|
+
* List items in the Risks section, nested ones included, since a nested
|
|
87
|
+
* bullet is weight the reader pays for. Absent alongside `risksWords`, so a
|
|
88
|
+
* record with no section never reads as one holding zero bullets.
|
|
89
|
+
*/
|
|
90
|
+
readonly risksBullets?: number
|
|
85
91
|
/** What the record declared, absent when it states no length rule. */
|
|
86
92
|
readonly allowances?: Allowances
|
|
87
93
|
/** The frame plus the per-decision allowance, absent alongside it. */
|
|
@@ -92,6 +98,10 @@ export interface ArchitectureReport {
|
|
|
92
98
|
* project that never adopted a cap is measured and never gated.
|
|
93
99
|
*/
|
|
94
100
|
readonly entryCap?: number
|
|
101
|
+
/** The most words a decision may hold, absent when the record states none. */
|
|
102
|
+
readonly wordCap?: number
|
|
103
|
+
/** The most Risks bullets the record allows, absent when it states none. */
|
|
104
|
+
readonly riskCap?: number
|
|
95
105
|
/**
|
|
96
106
|
* Whether the record states that every decision closes with a revisit
|
|
97
107
|
* sentence. Declared by the record for the reason the cap is, so a record
|
|
@@ -103,7 +113,9 @@ export interface ArchitectureReport {
|
|
|
103
113
|
|
|
104
114
|
const DECISION_HEADING = /^###\s+(.+?)\s*$/
|
|
105
115
|
const SECTION_HEADING = /^##\s+\S/
|
|
106
|
-
/**
|
|
116
|
+
/** A markdown list item at any depth, which is what a risk bullet is. */
|
|
117
|
+
const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+\S/
|
|
118
|
+
/** The H2 whose bullets the standard's `## Caps` section counts. */
|
|
107
119
|
const RISKS_HEADING = /^##\s+Risks\s*\/\s*open questions\s*$/i
|
|
108
120
|
const CODE_SPAN = /`[^`]*`/g
|
|
109
121
|
/** Dropped ahead of the figure scan, since an anchor date is not a claim. */
|
|
@@ -291,13 +303,12 @@ export function splitDecisions(source: string): RawDecision[] {
|
|
|
291
303
|
}
|
|
292
304
|
|
|
293
305
|
/**
|
|
294
|
-
* Counts whitespace-delimited tokens,
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
* for by reading, the same trade the line count above it already takes.
|
|
306
|
+
* Counts whitespace-delimited tokens, the unit a record's word caps are stated
|
|
307
|
+
* in. A code span counts as the tokens it holds, so a decision citing a long
|
|
308
|
+
* path pays for it the way a reader does. A caller gating on the count hands
|
|
309
|
+
* in prose with fenced lines already dropped.
|
|
299
310
|
*/
|
|
300
|
-
function wordCount(text: string): number {
|
|
311
|
+
export function wordCount(text: string): number {
|
|
301
312
|
return text.match(/\S+/g)?.length ?? 0
|
|
302
313
|
}
|
|
303
314
|
|
|
@@ -310,14 +321,30 @@ function wordCount(text: string): number {
|
|
|
310
321
|
* when the section is last, which is where the standard's template puts it.
|
|
311
322
|
*/
|
|
312
323
|
export function risksSection(source: string): string | undefined {
|
|
313
|
-
|
|
314
|
-
|
|
324
|
+
return risksLines(source)
|
|
325
|
+
?.map((line) => line.text)
|
|
326
|
+
.join('\n')
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Counts the list items in the Risks section, nested ones included, or
|
|
331
|
+
* nothing when the record carries no such heading. A fenced line is skipped,
|
|
332
|
+
* since a template bullet is not a risk.
|
|
333
|
+
*/
|
|
334
|
+
export function riskBullets(source: string): number | undefined {
|
|
335
|
+
return risksLines(source)?.filter(
|
|
336
|
+
(line) => !line.fenced && LIST_ITEM.test(line.text),
|
|
337
|
+
).length
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function risksLines(source: string): BodyLine[] | undefined {
|
|
341
|
+
const body: BodyLine[] = []
|
|
315
342
|
let capturing = false
|
|
316
343
|
let found = false
|
|
317
344
|
|
|
318
|
-
for (const line of
|
|
345
|
+
for (const line of bodyLines(source)) {
|
|
319
346
|
if (line.fenced) {
|
|
320
|
-
if (capturing) body.push(line
|
|
347
|
+
if (capturing) body.push(line)
|
|
321
348
|
continue
|
|
322
349
|
}
|
|
323
350
|
|
|
@@ -332,10 +359,10 @@ export function risksSection(source: string): string | undefined {
|
|
|
332
359
|
continue
|
|
333
360
|
}
|
|
334
361
|
|
|
335
|
-
if (capturing) body.push(line
|
|
362
|
+
if (capturing) body.push(line)
|
|
336
363
|
}
|
|
337
364
|
|
|
338
|
-
return found ? body
|
|
365
|
+
return found ? body : undefined
|
|
339
366
|
}
|
|
340
367
|
|
|
341
368
|
/** Cardinals a record spells rather than writes, which the corpus does for both. */
|
|
@@ -376,6 +403,14 @@ const ENTRY_CAP_CLAUSE = new RegExp(
|
|
|
376
403
|
String.raw`\bat\s+most\s+${CARDINAL}\s+decisions?\b`,
|
|
377
404
|
'i',
|
|
378
405
|
)
|
|
406
|
+
const WORD_CAP_CLAUSE = new RegExp(
|
|
407
|
+
String.raw`\bat\s+most\s+${CARDINAL}\s+words\s+a\s+decision\b`,
|
|
408
|
+
'i',
|
|
409
|
+
)
|
|
410
|
+
const RISK_CAP_CLAUSE = new RegExp(
|
|
411
|
+
String.raw`\bat\s+most\s+${CARDINAL}\s+risk\s+bullets?\b`,
|
|
412
|
+
'i',
|
|
413
|
+
)
|
|
379
414
|
|
|
380
415
|
function readCardinal(token: string | undefined): number | undefined {
|
|
381
416
|
if (token === undefined) return undefined
|
|
@@ -413,6 +448,20 @@ export function readEntryCap(source: string): number | undefined {
|
|
|
413
448
|
return readCardinal(source.match(ENTRY_CAP_CLAUSE)?.[1])
|
|
414
449
|
}
|
|
415
450
|
|
|
451
|
+
/**
|
|
452
|
+
* Reads the per-decision word cap a record declares, spelled `at most <n>
|
|
453
|
+
* words a decision`, or nothing. One spelling, the way the line allowance
|
|
454
|
+
* reads `<n> lines a decision`, so a drift falls back to reporting.
|
|
455
|
+
*/
|
|
456
|
+
export function readWordCap(source: string): number | undefined {
|
|
457
|
+
return readCardinal(source.match(WORD_CAP_CLAUSE)?.[1])
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Reads the Risks cap a record declares, spelled `at most <n> risk bullets`. */
|
|
461
|
+
export function readRiskCap(source: string): number | undefined {
|
|
462
|
+
return readCardinal(source.match(RISK_CAP_CLAUSE)?.[1])
|
|
463
|
+
}
|
|
464
|
+
|
|
416
465
|
const REVISIT_CLAUSE =
|
|
417
466
|
/\bevery\s+decision\s+closes\s+with\s+a\s+revisit\s+sentence\b/i
|
|
418
467
|
/**
|
|
@@ -486,6 +535,8 @@ export async function measureArchitecture(
|
|
|
486
535
|
|
|
487
536
|
const allowances = readAllowances(source)
|
|
488
537
|
const entryCap = readEntryCap(source)
|
|
538
|
+
const wordCap = readWordCap(source)
|
|
539
|
+
const riskCap = readRiskCap(source)
|
|
489
540
|
const revisitRequired = readRevisitRule(source)
|
|
490
541
|
const raw = splitDecisions(source)
|
|
491
542
|
const decisions = await Promise.all(
|
|
@@ -498,13 +549,14 @@ export async function measureArchitecture(
|
|
|
498
549
|
figures,
|
|
499
550
|
...(quantified !== undefined && { quantified }),
|
|
500
551
|
checks: await namedChecks(root, entry.body),
|
|
501
|
-
words: wordCount(entry.
|
|
552
|
+
words: wordCount(entry.prose),
|
|
502
553
|
revisit: hasRevisitSentence(entry.prose),
|
|
503
554
|
}
|
|
504
555
|
}),
|
|
505
556
|
)
|
|
506
557
|
|
|
507
558
|
const risks = risksSection(source)
|
|
559
|
+
const bullets = riskBullets(source)
|
|
508
560
|
|
|
509
561
|
return {
|
|
510
562
|
rel,
|
|
@@ -515,11 +567,14 @@ export async function measureArchitecture(
|
|
|
515
567
|
.join('\n'),
|
|
516
568
|
),
|
|
517
569
|
...(risks !== undefined && { risksWords: wordCount(risks) }),
|
|
570
|
+
...(bullets !== undefined && { risksBullets: bullets }),
|
|
518
571
|
...(allowances !== undefined && {
|
|
519
572
|
allowances,
|
|
520
573
|
ceiling: ceilingFor(allowances, raw.length),
|
|
521
574
|
}),
|
|
522
575
|
...(entryCap !== undefined && { entryCap }),
|
|
576
|
+
...(wordCap !== undefined && { wordCap }),
|
|
577
|
+
...(riskCap !== undefined && { riskCap }),
|
|
523
578
|
revisitRequired,
|
|
524
579
|
decisions,
|
|
525
580
|
}
|
|
@@ -538,6 +593,31 @@ export function isOverCount(report: ArchitectureReport): boolean {
|
|
|
538
593
|
)
|
|
539
594
|
}
|
|
540
595
|
|
|
596
|
+
/**
|
|
597
|
+
* The decisions holding more prose words than the cap the record states.
|
|
598
|
+
*
|
|
599
|
+
* Empty for a record stating no word cap, for the reason `isOverCount` gives.
|
|
600
|
+
*/
|
|
601
|
+
export function overWords(report: ArchitectureReport): DecisionReport[] {
|
|
602
|
+
const cap = report.wordCap
|
|
603
|
+
if (cap === undefined) return []
|
|
604
|
+
return report.decisions.filter((entry) => entry.words > cap)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Whether the Risks section holds more bullets than the cap the record states.
|
|
609
|
+
*
|
|
610
|
+
* False for a record stating no cap, and false for one carrying no Risks
|
|
611
|
+
* section, which the stage reports as absent rather than as zero bullets.
|
|
612
|
+
*/
|
|
613
|
+
export function isOverRisks(report: ArchitectureReport): boolean {
|
|
614
|
+
return (
|
|
615
|
+
report.riskCap !== undefined &&
|
|
616
|
+
report.risksBullets !== undefined &&
|
|
617
|
+
report.risksBullets > report.riskCap
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
|
|
541
621
|
/**
|
|
542
622
|
* The headings of decisions lacking a revisit sentence the record requires.
|
|
543
623
|
*
|
package/src/context/gate.ts
CHANGED
|
@@ -25,6 +25,19 @@ export interface GateInput {
|
|
|
25
25
|
* same two cases, plus a record stating no such clause.
|
|
26
26
|
*/
|
|
27
27
|
readonly recordMissingRevisit: boolean
|
|
28
|
+
/**
|
|
29
|
+
* Whether a decision holds more words than the cap the record states, which
|
|
30
|
+
* gates under the same two modes and is false in the same two cases, plus a
|
|
31
|
+
* record stating no word cap.
|
|
32
|
+
*/
|
|
33
|
+
readonly recordOverWords: boolean
|
|
34
|
+
/** The same reading for the Risks section's bullet cap. */
|
|
35
|
+
readonly recordOverRisks: boolean
|
|
36
|
+
/**
|
|
37
|
+
* Whether the requirements record holds more words than the cap it states,
|
|
38
|
+
* which gates for the reason the architecture record's caps do.
|
|
39
|
+
*/
|
|
40
|
+
readonly requirementsOverWords: boolean
|
|
28
41
|
readonly sections: readonly SectionFinding[]
|
|
29
42
|
readonly drift: readonly FolderDrift[]
|
|
30
43
|
/**
|
|
@@ -79,8 +92,10 @@ export function hasSketchWithEvidence(
|
|
|
79
92
|
* Whether the audit found something that should fail the caller.
|
|
80
93
|
*
|
|
81
94
|
* An unresolved citation is a broken pointer and gates unconditionally, and so
|
|
82
|
-
* does a record past its own ceiling
|
|
83
|
-
* revisit sentence it requires
|
|
95
|
+
* does a record past its own ceiling, entry cap, word cap, or bullet cap, or
|
|
96
|
+
* missing a revisit sentence it requires, and a requirements record past its
|
|
97
|
+
* own word cap: each record states its rules for itself, which makes those the
|
|
98
|
+
* measures here that are facts
|
|
84
99
|
* rather than thresholds a reader weighs. The findings `--gate` adds are
|
|
85
100
|
* the ones answerable from the file itself: a required section it does not
|
|
86
101
|
* declare, an index disagreeing with its folder, and a wireframe's States
|
|
@@ -95,13 +110,25 @@ export function isGating({
|
|
|
95
110
|
recordOverLength,
|
|
96
111
|
recordOverCount,
|
|
97
112
|
recordMissingRevisit,
|
|
113
|
+
recordOverWords,
|
|
114
|
+
recordOverRisks,
|
|
115
|
+
requirementsOverWords,
|
|
98
116
|
sections,
|
|
99
117
|
drift,
|
|
100
118
|
wireframes,
|
|
101
119
|
widened,
|
|
102
120
|
}: GateInput): boolean {
|
|
103
121
|
if (unresolvedCitations > 0) return true
|
|
104
|
-
if (
|
|
122
|
+
if (
|
|
123
|
+
recordOverLength ||
|
|
124
|
+
recordOverCount ||
|
|
125
|
+
recordMissingRevisit ||
|
|
126
|
+
recordOverWords ||
|
|
127
|
+
recordOverRisks ||
|
|
128
|
+
requirementsOverWords
|
|
129
|
+
) {
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
105
132
|
if (!widened) return false
|
|
106
133
|
|
|
107
134
|
return sections.length > 0 || hasDrift(drift) || hasStatesMismatch(wireframes)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
|
+
import { wordCount } from '@/context/architecture'
|
|
4
|
+
import { bodyLines } from '@/markdown/scan'
|
|
5
|
+
import { surfaceDir } from '@/surface-root'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The record this measures, relative to `root`, resolved at either surface
|
|
9
|
+
* root for the reason `architectureRel` gives.
|
|
10
|
+
*/
|
|
11
|
+
export function requirementsRel(root: string): string {
|
|
12
|
+
return relative(root, surfaceDir(root, 'REQUIREMENTS.md'))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RequirementsReport {
|
|
16
|
+
readonly rel: string
|
|
17
|
+
/** The whole record's word count, frontmatter left out. */
|
|
18
|
+
readonly words: number
|
|
19
|
+
/**
|
|
20
|
+
* The most words the record says it holds, absent when it states no cap.
|
|
21
|
+
* Declared by the record rather than held here, so a project that never
|
|
22
|
+
* adopted a cap is measured and never gated.
|
|
23
|
+
*/
|
|
24
|
+
readonly wordCap?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const WORD_CAP_CLAUSE = /\bat\s+most\s+(\d+)\s+words\b/i
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reads the word cap a record declares, spelled `at most <n> words`, or
|
|
31
|
+
* nothing. Digits only, since a cap in the hundreds is never spelled out.
|
|
32
|
+
*/
|
|
33
|
+
export function readRequirementsWordCap(source: string): number | undefined {
|
|
34
|
+
const digits = source.match(WORD_CAP_CLAUSE)?.[1]
|
|
35
|
+
return digits === undefined ? undefined : Number.parseInt(digits, 10)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isMissing(error: unknown): boolean {
|
|
39
|
+
const code = (error as { code?: unknown }).code
|
|
40
|
+
return code === 'ENOENT' || code === 'ENOTDIR'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Measures the record, or reports nothing when the project carries none.
|
|
45
|
+
*
|
|
46
|
+
* Absent rather than zeroed, for the reason `measureArchitecture` gives, and a
|
|
47
|
+
* record present and unreadable propagates the same way.
|
|
48
|
+
*/
|
|
49
|
+
export async function measureRequirements(
|
|
50
|
+
root: string,
|
|
51
|
+
): Promise<RequirementsReport | undefined> {
|
|
52
|
+
const rel = requirementsRel(root)
|
|
53
|
+
|
|
54
|
+
let source: string
|
|
55
|
+
try {
|
|
56
|
+
source = await readFile(join(root, rel), 'utf8')
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (!isMissing(error)) throw error
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const wordCap = readRequirementsWordCap(source)
|
|
63
|
+
return {
|
|
64
|
+
rel,
|
|
65
|
+
words: wordCount(
|
|
66
|
+
bodyLines(source)
|
|
67
|
+
.map((line) => line.text)
|
|
68
|
+
.join('\n'),
|
|
69
|
+
),
|
|
70
|
+
...(wordCap !== undefined && { wordCap }),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether the record holds more words than the cap it states. */
|
|
75
|
+
export function isOverWordCap(report: RequirementsReport): boolean {
|
|
76
|
+
return report.wordCap !== undefined && report.words > report.wordCap
|
|
77
|
+
}
|
package/src/gate/measures.ts
CHANGED
|
@@ -10,9 +10,12 @@ import {
|
|
|
10
10
|
import {
|
|
11
11
|
isOverCount,
|
|
12
12
|
isOverLength,
|
|
13
|
+
isOverRisks,
|
|
13
14
|
measureArchitecture,
|
|
14
15
|
missingRevisit,
|
|
16
|
+
overWords,
|
|
15
17
|
} from '@/context/architecture'
|
|
18
|
+
import { isOverWordCap, measureRequirements } from '@/context/requirements'
|
|
16
19
|
import {
|
|
17
20
|
auditSkills,
|
|
18
21
|
PRACTICE_LEDGER,
|
|
@@ -246,42 +249,80 @@ function citedPaths(record: { paths?: unknown } | undefined): string[] {
|
|
|
246
249
|
}
|
|
247
250
|
|
|
248
251
|
/**
|
|
249
|
-
* The
|
|
250
|
-
* line ceiling its allowances derive,
|
|
251
|
-
* closing each decision
|
|
252
|
+
* The canonical records against the caps each states for itself. The
|
|
253
|
+
* architecture record carries five: the line ceiling its allowances derive,
|
|
254
|
+
* the entry cap, the revisit sentence closing each decision, the words a
|
|
255
|
+
* decision may hold, and the bullets its Risks section may hold. The
|
|
256
|
+
* requirements record carries one, its own word count.
|
|
252
257
|
*
|
|
253
258
|
* Read in-process rather than through `context audit`, whose one gating stage
|
|
254
|
-
* here runs `--citations-only` and never opens
|
|
255
|
-
* line ceiling went unenforced by `bun run check` until this stage. A
|
|
256
|
-
* carrying
|
|
257
|
-
* each rule belongs to the record rather than to the toolkit.
|
|
259
|
+
* here runs `--citations-only` and never opens either record, which is why
|
|
260
|
+
* the line ceiling went unenforced by `bun run check` until this stage. A
|
|
261
|
+
* project carrying neither record, or records stating none of the caps,
|
|
262
|
+
* passes, since each rule belongs to the record rather than to the toolkit.
|
|
258
263
|
*/
|
|
259
|
-
export const
|
|
260
|
-
const report = await
|
|
264
|
+
export const canonicalRecords: Measure = async (ctx) => {
|
|
265
|
+
const [report, requirements] = await Promise.all([
|
|
266
|
+
measureArchitecture(ctx.root),
|
|
267
|
+
measureRequirements(ctx.root),
|
|
268
|
+
])
|
|
269
|
+
|
|
270
|
+
const failures: string[] = []
|
|
271
|
+
const emissions: Emission[] = []
|
|
272
|
+
|
|
261
273
|
if (report === undefined) {
|
|
262
|
-
|
|
274
|
+
emissions.push(info('No architecture record to measure'))
|
|
275
|
+
} else {
|
|
276
|
+
const decisions = report.decisions.length
|
|
277
|
+
const lackingRevisit = missingRevisit(report)
|
|
278
|
+
const longDecisions = overWords(report)
|
|
279
|
+
failures.push(
|
|
280
|
+
...[
|
|
281
|
+
isOverCount(report) &&
|
|
282
|
+
`${decisions} decisions against a cap of ${report.entryCap}. Merge two or retire one in ${report.rel}, never compress.`,
|
|
283
|
+
isOverLength(report) &&
|
|
284
|
+
`${report.lines} lines against a ceiling of ${report.ceiling} in ${report.rel}.`,
|
|
285
|
+
lackingRevisit.length > 0 &&
|
|
286
|
+
`No revisit sentence in ${lackingRevisit.map((heading) => `"${heading}"`).join(', ')}. Close each with one opening "Revisit when" in ${report.rel}.`,
|
|
287
|
+
longDecisions.length > 0 &&
|
|
288
|
+
`${longDecisions.map((entry) => `"${entry.heading}" (${entry.words})`).join(', ')} past ${report.wordCap} words a decision in ${report.rel}. Cut the reasoning to the trade, never split one decision across two headings.`,
|
|
289
|
+
isOverRisks(report) &&
|
|
290
|
+
`${report.risksBullets} risk bullets against a cap of ${report.riskCap} in ${report.rel}. Drop what is no longer open.`,
|
|
291
|
+
].filter((failure): failure is string => typeof failure === 'string'),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
const cap =
|
|
295
|
+
report.entryCap === undefined
|
|
296
|
+
? 'no entry cap stated'
|
|
297
|
+
: `a cap of ${report.entryCap}`
|
|
298
|
+
emissions.push(info(`${decisions} decisions against ${cap}`))
|
|
299
|
+
if (report.riskCap !== undefined && report.risksBullets === undefined) {
|
|
300
|
+
emissions.push(
|
|
301
|
+
info(
|
|
302
|
+
`No Risks section to count against a cap of ${report.riskCap} in ${report.rel}`,
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
}
|
|
263
306
|
}
|
|
264
307
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
`${
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
308
|
+
if (requirements === undefined) {
|
|
309
|
+
emissions.push(info('No requirements record to measure'))
|
|
310
|
+
} else if (isOverWordCap(requirements)) {
|
|
311
|
+
failures.push(
|
|
312
|
+
`${requirements.words} words against a cap of ${requirements.wordCap} in ${requirements.rel}.`,
|
|
313
|
+
)
|
|
314
|
+
} else {
|
|
315
|
+
const cap =
|
|
316
|
+
requirements.wordCap === undefined
|
|
317
|
+
? 'no word cap stated'
|
|
318
|
+
: `a cap of ${requirements.wordCap}`
|
|
319
|
+
emissions.push(
|
|
320
|
+
info(`${requirements.words} requirements words against ${cap}`),
|
|
321
|
+
)
|
|
322
|
+
}
|
|
275
323
|
|
|
276
324
|
if (failures.length > 0) return { emissions: [], failure: failures.join(' ') }
|
|
277
|
-
|
|
278
|
-
const cap =
|
|
279
|
-
report.entryCap === undefined
|
|
280
|
-
? 'no entry cap stated'
|
|
281
|
-
: `a cap of ${report.entryCap}`
|
|
282
|
-
return {
|
|
283
|
-
emissions: [info(`${decisions} decisions against ${cap}`)],
|
|
284
|
-
}
|
|
325
|
+
return { emissions }
|
|
285
326
|
}
|
|
286
327
|
|
|
287
328
|
/**
|
package/src/gate/stages.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
skillPaths,
|
|
7
7
|
} from '@/gate/boundaries'
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
canonicalRecords,
|
|
10
10
|
auditSet,
|
|
11
11
|
captureStamps,
|
|
12
12
|
clientCommandCitations,
|
|
@@ -346,11 +346,11 @@ export const STAGES: readonly Stage[] = [
|
|
|
346
346
|
success: 'Context citations resolve',
|
|
347
347
|
},
|
|
348
348
|
{
|
|
349
|
-
//
|
|
349
|
+
// Each record's caps are facts it states about itself, unlike the
|
|
350
350
|
// judgment thresholds the stage above leaves out, so they gate here.
|
|
351
|
-
id: '
|
|
352
|
-
label: '
|
|
353
|
-
checks: [{ kind: 'measure', measure:
|
|
351
|
+
id: 'canonical-records',
|
|
352
|
+
label: 'Canonical records',
|
|
353
|
+
checks: [{ kind: 'measure', measure: canonicalRecords }],
|
|
354
354
|
},
|
|
355
355
|
{
|
|
356
356
|
// Unscoped, since any branch can grow any document. A document past the
|
|
@@ -73,18 +73,24 @@ A decision says why it won and rarely says when that reason stops holding, so an
|
|
|
73
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
74
|
- Place the sentence after the reasoning and before any verification anchor, so the anchor stays the entry's last sentence.
|
|
75
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
|
|
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 caps, 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
77
|
- A checker reads presence alone. A vacuous sentence passes it, so the sentence is only as useful as the finding it names.
|
|
78
78
|
|
|
79
|
-
##
|
|
79
|
+
## Caps
|
|
80
80
|
|
|
81
|
-
Every session pays for this file before any work starts, so a heavy read is a real cost.
|
|
81
|
+
Every session pays for this file before any work starts, so a heavy read is a real cost. Weight comes from two places, too many decisions and decisions written too long, so the record caps both, and caps the open risks beside them.
|
|
82
82
|
|
|
83
|
-
- State
|
|
84
|
-
-
|
|
85
|
-
-
|
|
83
|
+
- State each cap in the record itself. A checker reads only these spellings, so a clause worded another way is reported and never gated:
|
|
84
|
+
- `This record holds at most <n> decisions.`
|
|
85
|
+
- `at most <n> words a decision`, counting a decision's prose below its heading, fenced lines left out
|
|
86
|
+
- `at most <n> risk bullets`, counting every list item under `## Risks / open questions`, nested ones included
|
|
87
|
+
- The three can share one sentence, such as `This record holds at most 12 decisions, at most 150 words a decision, and at most 6 risk bullets.` Each cap is the record's own, so a record stating none is measured and never gated, and the template states the entry cap alone so the other two are adopted on purpose.
|
|
88
|
+
- Set the decision cap near one decision per slot plus a small margin, the word cap near what one trade takes to state, about 150, and the risk cap at what a reader holds in mind at once, about 6.
|
|
89
|
+
- At the decision cap, merge two decisions or retire one before adding another, and name which in the change that does it. Never pack two decisions under one heading, which the count cannot see.
|
|
90
|
+
- At the word cap, cut a decision to the choice, the alternative that lost, the reason, and the revisit sentence. What it sheds is a mechanism or a measurement, which goes to the domain context entry it constrains, never under a second heading.
|
|
91
|
+
- At the risk cap, drop what is no longer open before adding a risk.
|
|
86
92
|
- Retire a decision by moving it to the domain context entry it constrains, not by deleting its reasoning.
|
|
87
|
-
-
|
|
93
|
+
- The whole file's word count stays a report, read alongside the judgment, and never gates.
|
|
88
94
|
- Yield to the paragraph weight checkpoint in `markdown.md`. A paragraph past the checkpoint is a defect no length guideline licenses.
|
|
89
95
|
|
|
90
96
|
## Template
|
|
@@ -36,6 +36,10 @@ Does not govern:
|
|
|
36
36
|
|
|
37
37
|
Use `## Problem`, `## Goals`, `## Non-goals`, `## MVP features`, and `## Constraints`. Add `## Distribution` when the rule below applies. Add `## Worldview` when the project holds beliefs that shape every decision, and keep a belief there only when it changes a decision, since a padded worldview turns into a second goals list. Add `## Premise` when the project exists to answer a question, stated as the question and what would count as an answer, never as the answer measured so far. Drop a section rather than pad it with filler.
|
|
38
38
|
|
|
39
|
+
## Length
|
|
40
|
+
|
|
41
|
+
This file loads into every session, so its weight is paid before any work starts. Cap it by stating the clause `This record holds at most <n> words.` in the record itself, where a checker reads it and counts every word below the frontmatter. The cap is the record's own, so a record stating none is measured and never gated. Set it near 600, and at the cap cut a goal or a constraint that no longer changes a decision before adding one.
|
|
42
|
+
|
|
39
43
|
## Lifecycle
|
|
40
44
|
|
|
41
45
|
Once every entry in the MVP list, or in a later scope section, has shipped, delete the section. Git keeps the old text, and the document states the project as it stands. Do not annotate an entry as shipped and leave it in place. While any entry is unshipped, do not renumber the list or append to it.
|
|
@@ -75,7 +75,7 @@ jobs:
|
|
|
75
75
|
|
|
76
76
|
- name: Skip notice
|
|
77
77
|
if: steps.secrets-check.outputs.skip == 'true'
|
|
78
|
-
run: echo "::notice::CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is not set, skipping deploy. Run the deploy-
|
|
78
|
+
run: echo "::notice::CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is not set, skipping deploy. Run the deploy-app skill to finish setup."
|
|
79
79
|
|
|
80
80
|
# This job builds nothing and still needs a toolchain. The checkout
|
|
81
81
|
# below brings bun.lock, wrangler-action reads it, picks bun on the
|
|
@@ -110,7 +110,7 @@ jobs:
|
|
|
110
110
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
111
111
|
wranglerVersion: '3.90.0'
|
|
112
112
|
# Fill in this project's Pages project name, set when the
|
|
113
|
-
# deploy-
|
|
113
|
+
# deploy-app skill runs wrangler pages project create.
|
|
114
114
|
command: pages deploy dist --project-name=REPLACE_WITH_PROJECT_NAME --branch=${{ github.ref_name }}
|
|
115
115
|
|
|
116
116
|
# `canon pr preview` reads this line out of the run log, so the marker is
|
|
@@ -20,7 +20,7 @@ Three values in the seeded file have no generic default and are set once, right
|
|
|
20
20
|
|
|
21
21
|
- The `paths:` filter under the `push` trigger. The shipped default is a single `src/**` glob. Widen it to cover every path the build reads when the build draws on catalogs or generated content living outside `src/`.
|
|
22
22
|
- The `concurrency.group` name. The shipped default is `deploy-${{ github.ref_name }}`, which is enough for a single-deploy project. Give the `deploy` prefix a project-specific name only if the project runs more than one deploy workflow, and keep the ref suffix.
|
|
23
|
-
- The Pages project name, written twice as `REPLACE_WITH_PROJECT_NAME`: once in the `--project-name` flag on the `wrangler pages deploy` command and once as `PROJECT_NAME` in the cleanup job. Set both to the name the `deploy-
|
|
23
|
+
- The Pages project name, written twice as `REPLACE_WITH_PROJECT_NAME`: once in the `--project-name` flag on the `wrangler pages deploy` command and once as `PROJECT_NAME` in the cleanup job. Set both to the name the `deploy-app` skill creates the Pages project under.
|
|
24
24
|
|
|
25
25
|
## Re-sync
|
|
26
26
|
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: deploy-cloudflare
|
|
3
|
-
description: What the Cloudflare Pages setup gap is, which of the four manual steps this skill closes, and why the credential and the custom domain stay the operator's
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Deploy Cloudflare requirement
|
|
7
|
-
|
|
8
|
-
## Gap
|
|
9
|
-
|
|
10
|
-
Without this skill, a Cloudflare Pages setup is a sequence performed from memory. Three of its four repeated acts touch a service rather than the tree, so no workflow file can absorb them, and one of the acts has already cost a broken deploy: the account ID displayed in the Cloudflare dashboard is truncated, and a value copied from there is wrong in a way that only shows up at deploy time.
|
|
11
|
-
|
|
12
|
-
## Must
|
|
13
|
-
|
|
14
|
-
- Create the Pages project through `wrangler pages project create`
|
|
15
|
-
- Fetch the account ID from `wrangler`'s own authenticated session rather than have it typed or pasted
|
|
16
|
-
- Stop for the operator to issue the API token and run `gh secret set CLOUDFLARE_API_TOKEN` themselves
|
|
17
|
-
- Verify both secrets are present with `gh secret list` before continuing
|
|
18
|
-
- Stop for the operator to attach the custom domain in the dashboard
|
|
19
|
-
- Call `wrangler` and `gh` directly for every step those tools already cover
|
|
20
|
-
|
|
21
|
-
## Must not
|
|
22
|
-
|
|
23
|
-
- Accept a Cloudflare API token as input in any form. The toolkit verifies a secret exists, never what it contains.
|
|
24
|
-
- Reimplement `wrangler pages project create` or `gh secret set` as a hand-built API call
|
|
25
|
-
- Open a pull request or merge
|
|
26
|
-
|
|
27
|
-
## Guards
|
|
28
|
-
|
|
29
|
-
- `wrangler` not authenticated: stop and name the login command
|
|
30
|
-
- `gh` not authenticated: stop and name the login command
|
|
31
|
-
- A secret missing after the token stop: stop and name which one
|
|
32
|
-
|
|
33
|
-
## Out of scope
|
|
34
|
-
|
|
35
|
-
- Attaching a custom domain through the Cloudflare REST API instead of the dashboard. Measured absent from `wrangler pages` at plan time and the REST API was not read, so the dashboard stop stays for this pass.
|
|
36
|
-
- Running the deploy itself once secrets and the domain are set. That is the seeded `tooling/cloudflare/configs/.github/workflows/deploy.yml` workflow, triggered by a push to main.
|
|
37
|
-
- Reconfiguring an existing Pages project's build settings.
|