@erclx/canon 4.121.0 → 4.122.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/auto-ship/SKILL.md +1 -1
- package/claude/skills/backlog-triage/REQUIREMENT.md +47 -0
- package/claude/skills/backlog-triage/SKILL.md +139 -0
- package/claude/skills/canon-rollout/SKILL.md +6 -6
- package/claude/skills/context-fold/REQUIREMENT.md +75 -0
- package/claude/skills/context-fold/SKILL.md +162 -0
- package/claude/skills/{docs-fold → context-fold}/references/anchor-sweep.md +1 -1
- package/claude/skills/{docs-fold → context-fold}/references/classify.md +4 -2
- package/claude/skills/context-fold/references/context-refresh.md +48 -0
- package/claude/skills/context-fold/references/promoted-pages.md +29 -0
- package/claude/skills/context-fold/references/receipt-sweep.md +43 -0
- package/claude/skills/context-fold/references/unusable-baseline.md +25 -0
- package/claude/skills/{docs-fold → context-fold}/references/wireframe-sweep.md +1 -1
- package/claude/skills/docs-fold/REQUIREMENT.md +8 -58
- package/claude/skills/docs-fold/SKILL.md +3 -278
- package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
- package/claude/skills/docs-sync/SKILL.md +1 -1
- package/claude/skills/draft-context/REQUIREMENT.md +2 -2
- package/claude/skills/draft-context/SKILL.md +3 -3
- package/claude/skills/draft-docs/REQUIREMENT.md +1 -1
- package/claude/skills/draft-wireframes/REQUIREMENT.md +1 -1
- package/claude/skills/draft-wireframes/SKILL.md +2 -2
- package/claude/skills/git-pr/SKILL.md +8 -146
- package/claude/skills/git-pr/references/evidence.md +114 -0
- package/claude/skills/git-pr/references/labels.md +20 -0
- package/claude/skills/git-pr/references/task-number.md +26 -0
- package/claude/skills/git-ship/SKILL.md +1 -1
- package/claude/skills/memory-capture/REQUIREMENT.md +2 -2
- package/claude/skills/memory-capture/SKILL.md +5 -5
- package/claude/skills/memory-review/REQUIREMENT.md +1 -1
- package/claude/skills/memory-review/SKILL.md +5 -101
- package/claude/skills/memory-review/references/later-phases.md +108 -0
- package/claude/skills/plan-feature/REQUIREMENT.md +1 -1
- package/claude/skills/review-address/REQUIREMENT.md +1 -1
- package/claude/skills/review-address/SKILL.md +1 -1
- package/claude/skills/review-branch/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -0
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +2 -2
- package/claude/skills/session-resume/REQUIREMENT.md +1 -1
- package/claude/skills/session-worktree/SKILL.md +4 -10
- package/claude/skills/task-board/REQUIREMENT.md +1 -1
- package/claude/skills/task-board/SKILL.md +6 -80
- package/claude/skills/task-board/references/archive.md +54 -0
- package/claude/skills/task-board/references/decline.md +40 -0
- package/claude/skills/teach-workspace/SKILL.md +8 -105
- package/claude/skills/teach-workspace/references/lesson-craft.md +51 -1
- package/claude/skills/teach-workspace/references/promotion.md +41 -0
- package/docs/agents/architecture-record.md +26 -0
- package/docs/agents/commands.md +87 -154
- package/docs/agents/context-audit-checks.md +2 -20
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/context-classify.md +1 -1
- package/docs/agents/drift-surfaces.md +192 -0
- package/docs/agents/index.md +12 -4
- package/docs/agents/install-and-sync.md +2 -181
- package/docs/agents/markdown-audit-weight.md +73 -0
- package/docs/agents/markdown-audit.md +3 -90
- package/docs/agents/migrate.md +34 -0
- package/docs/agents/records-stale.md +62 -0
- package/docs/agents/records.md +5 -1
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/tasks-archive.md +81 -0
- package/docs/agents/tasks-plans.md +147 -0
- package/docs/agents/tasks-validate.md +131 -0
- package/docs/agents/tasks.md +6 -333
- package/docs/target-projects.md +3 -3
- package/docs/workflow/ai-workflow.md +32 -27
- package/governance/rules/core/045-memory.md +1 -1
- package/package.json +1 -1
- package/src/claude/cases/setup.ts +1 -1
- package/src/claude/cases/workflow.ts +22 -1
- package/src/commands/records.ts +146 -0
- package/src/commands/sandbox.ts +6 -4
- package/src/context/classify/extract.ts +1 -1
- package/src/context/classify/ollama.ts +1 -1
- package/src/context/classify/run.ts +1 -1
- package/src/gate/measures.ts +1 -1
- package/src/migrate/skill-names.ts +15 -2
- package/src/records/stale.ts +243 -0
- package/src/sandbox/expect.ts +1 -1
- package/src/tasks/archive.ts +28 -4
- package/src/tasks/record.ts +1 -1
- package/src/tasks/validate.ts +36 -3
- package/standards/memory.md +13 -1
- package/standards/tasks.md +1 -1
- package/tooling/astro/reference.md +1 -1
- package/tooling/base/configs/.husky/post-merge +1 -1
- package/tooling/claude/reference.md +1 -1
- package/tooling/go/reference.md +1 -1
- package/tooling/nextjs/reference.md +1 -1
- package/tooling/php/reference.md +1 -1
- package/tooling/python/reference.md +1 -1
- package/tooling/vite-react/reference.md +1 -1
- package/tooling/web/reference.md +1 -1
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
3
|
+
import { join, relative } from 'node:path'
|
|
4
|
+
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
5
|
+
import {
|
|
6
|
+
CREATION_ROOT,
|
|
7
|
+
RECORD_ROOTS,
|
|
8
|
+
recordDir,
|
|
9
|
+
SCRATCH,
|
|
10
|
+
spell,
|
|
11
|
+
} from '@/record-root'
|
|
12
|
+
|
|
13
|
+
/** The window after which a reviewed entry is due again, in days. */
|
|
14
|
+
export const DEFAULT_REVIEW_DAYS = 30
|
|
15
|
+
|
|
16
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
17
|
+
|
|
18
|
+
const MEMORY_INDEX = 'index.md'
|
|
19
|
+
|
|
20
|
+
export interface StaleEntry {
|
|
21
|
+
/** The filename stem, which is what a review names an entry by. */
|
|
22
|
+
readonly name: string
|
|
23
|
+
readonly category: string | null
|
|
24
|
+
/** `YYYY-MM-DD`, or null when the entry carries no readable review date. */
|
|
25
|
+
readonly reviewed: string | null
|
|
26
|
+
/** The raw value of a `reviewed` field that is not a date, present only then. */
|
|
27
|
+
readonly invalidReviewed?: string
|
|
28
|
+
readonly due: boolean
|
|
29
|
+
/** Backticked paths the entry cites that the project root does not hold. */
|
|
30
|
+
readonly unresolved: readonly string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StaleReport {
|
|
34
|
+
readonly ok: true
|
|
35
|
+
readonly root: string
|
|
36
|
+
/** The pen read, relative to the root. */
|
|
37
|
+
readonly folder: string
|
|
38
|
+
readonly days: number
|
|
39
|
+
readonly total: number
|
|
40
|
+
readonly due: number
|
|
41
|
+
readonly entries: readonly StaleEntry[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const STALE_REFUSALS = ['no-folder'] as const
|
|
45
|
+
|
|
46
|
+
export type StaleRefusal = (typeof STALE_REFUSALS)[number]
|
|
47
|
+
|
|
48
|
+
export interface StaleRefused {
|
|
49
|
+
readonly ok: false
|
|
50
|
+
readonly reason: StaleRefusal
|
|
51
|
+
readonly message: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type StaleOutcome = StaleReport | StaleRefused
|
|
55
|
+
|
|
56
|
+
const REVIEWED_FIELD = /^reviewed:[ \t]*(.*?)[ \t]*$/m
|
|
57
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
|
58
|
+
const RAW_FRONTMATTER = /^---\n([\s\S]*?)\n---/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Reads `reviewed` off the raw block rather than the parsed fields, for the
|
|
62
|
+
* reason `hasOpeningDate` in the validator gives: a YAML parser resolves a
|
|
63
|
+
* bare `YYYY-MM-DD` to a date on one schema and a string on another.
|
|
64
|
+
*
|
|
65
|
+
* A value that is not a calendar date reads as never reviewed and is carried
|
|
66
|
+
* back so the review can see it, rather than throwing over one hand edit.
|
|
67
|
+
*/
|
|
68
|
+
function readReviewed(
|
|
69
|
+
text: string,
|
|
70
|
+
): Pick<StaleEntry, 'reviewed' | 'invalidReviewed'> {
|
|
71
|
+
const block = RAW_FRONTMATTER.exec(text)?.[1]
|
|
72
|
+
const match = block ? REVIEWED_FIELD.exec(block) : null
|
|
73
|
+
if (!match) return { reviewed: null }
|
|
74
|
+
|
|
75
|
+
const value = match[1].replace(/^(['"])(.*)\1$/, '$2')
|
|
76
|
+
const parsed = Date.parse(`${value}T00:00:00Z`)
|
|
77
|
+
const isDate =
|
|
78
|
+
ISO_DATE.test(value) &&
|
|
79
|
+
Number.isFinite(parsed) &&
|
|
80
|
+
new Date(parsed).toISOString().slice(0, 10) === value
|
|
81
|
+
|
|
82
|
+
return isDate
|
|
83
|
+
? { reviewed: value }
|
|
84
|
+
: { reviewed: null, invalidReviewed: value }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Tokens that name a session record, scratch at either root, or a checkout,
|
|
89
|
+
* none of which a tree commits. The scratch spellings come from `spell` so the
|
|
90
|
+
* legacy root's variant is stated in one place.
|
|
91
|
+
*/
|
|
92
|
+
const UNTRACKED_PREFIXES = [
|
|
93
|
+
...RECORD_ROOTS.map((at) => `${at}/${spell(at, SCRATCH)}/`),
|
|
94
|
+
`${CREATION_ROOT}/`,
|
|
95
|
+
'.claude/worktrees/',
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
const INLINE_CODE = /`([^`\n]+)`/g
|
|
99
|
+
const LINE_ANCHOR = /:\d+(?:[-:]\d+)*$/
|
|
100
|
+
const FILE_EXTENSION = /\.[A-Za-z0-9]+$/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reduces one backticked token to the repository path it cites, or undefined
|
|
104
|
+
* when it cites none.
|
|
105
|
+
*
|
|
106
|
+
* A path is a token carrying a `/` and ending in a file extension once any
|
|
107
|
+
* line or heading anchor is stripped. A placeholder, an elided path, a glob, a
|
|
108
|
+
* variable, a URL, and a path outside the project all look like paths and
|
|
109
|
+
* resolve against nothing in the tree, so each is dropped rather than reported.
|
|
110
|
+
*/
|
|
111
|
+
function toCitedPath(token: string): string | undefined {
|
|
112
|
+
if (/\s/.test(token) || token.includes('://')) return undefined
|
|
113
|
+
if (/[<*{$]/.test(token) || token.includes('...')) return undefined
|
|
114
|
+
if (/^(?:\/|~|\.\.\/)/.test(token)) return undefined
|
|
115
|
+
|
|
116
|
+
const path = token
|
|
117
|
+
.replace(/#.*$/, '')
|
|
118
|
+
.replace(LINE_ANCHOR, '')
|
|
119
|
+
.replace(/^\.\//, '')
|
|
120
|
+
|
|
121
|
+
if (!path.includes('/') || !FILE_EXTENSION.test(path)) return undefined
|
|
122
|
+
if (UNTRACKED_PREFIXES.some((prefix) => path.startsWith(prefix)))
|
|
123
|
+
return undefined
|
|
124
|
+
|
|
125
|
+
return path
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Every distinct repository path a body cites in inline code, in order. */
|
|
129
|
+
export function citedPaths(body: string): string[] {
|
|
130
|
+
const paths = new Set<string>()
|
|
131
|
+
|
|
132
|
+
for (const match of body.matchAll(INLINE_CODE)) {
|
|
133
|
+
const path = toCitedPath(match[1])
|
|
134
|
+
if (path) paths.add(path)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return [...paths]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isDue(reviewed: string | null, days: number, now: number): boolean {
|
|
141
|
+
if (reviewed === null) return true
|
|
142
|
+
return now - Date.parse(`${reviewed}T00:00:00`) > days * DAY_MS
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function readEntry(
|
|
146
|
+
root: string,
|
|
147
|
+
dir: string,
|
|
148
|
+
file: string,
|
|
149
|
+
days: number,
|
|
150
|
+
now: number,
|
|
151
|
+
): Promise<StaleEntry> {
|
|
152
|
+
const text = (await readFile(join(dir, file), 'utf8')).replaceAll(
|
|
153
|
+
'\r\n',
|
|
154
|
+
'\n',
|
|
155
|
+
)
|
|
156
|
+
const frontmatter = parseFrontmatter(text)
|
|
157
|
+
const review = readReviewed(text)
|
|
158
|
+
const body = text.slice(RAW_FRONTMATTER.exec(text)?.[0].length ?? 0)
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
name: file.replace(/\.md$/, ''),
|
|
162
|
+
category: readField(frontmatter, 'category') ?? null,
|
|
163
|
+
...review,
|
|
164
|
+
due: isDue(review.reviewed, days, now),
|
|
165
|
+
unresolved: citedPaths(body).filter(
|
|
166
|
+
(path) => !existsSync(join(root, path)),
|
|
167
|
+
),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The order a review takes a batch in: due entries first, and among them the
|
|
173
|
+
* ones citing a tree that moved, then the longest unreviewed, then by name so
|
|
174
|
+
* two runs over one pen agree.
|
|
175
|
+
*/
|
|
176
|
+
function compareEntries(left: StaleEntry, right: StaleEntry): number {
|
|
177
|
+
if (left.due !== right.due) return left.due ? -1 : 1
|
|
178
|
+
|
|
179
|
+
const leftMoved = left.unresolved.length > 0
|
|
180
|
+
const rightMoved = right.unresolved.length > 0
|
|
181
|
+
if (leftMoved !== rightMoved) return leftMoved ? -1 : 1
|
|
182
|
+
|
|
183
|
+
if (left.reviewed !== right.reviewed) {
|
|
184
|
+
if (left.reviewed === null) return -1
|
|
185
|
+
if (right.reviewed === null) return 1
|
|
186
|
+
return left.reviewed < right.reviewed ? -1 : 1
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return left.name.localeCompare(right.name)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Reports each memory entry's review state and the paths it cites that the
|
|
194
|
+
* project no longer holds, ordered so a caller takes the first N as a batch.
|
|
195
|
+
*
|
|
196
|
+
* It reads the top level of the pen alone. `review/` holds receipts and
|
|
197
|
+
* `archive/` holds retirements, and neither is an entry a review would queue.
|
|
198
|
+
* Paths resolve against the project root rather than the entry's folder, since
|
|
199
|
+
* an entry cites the tree it was written about. It reports and never writes.
|
|
200
|
+
*
|
|
201
|
+
* `now` is a parameter so a test can pin the window against fixture dates.
|
|
202
|
+
*/
|
|
203
|
+
export async function staleMemory(
|
|
204
|
+
root: string,
|
|
205
|
+
days: number = DEFAULT_REVIEW_DAYS,
|
|
206
|
+
now: number = Date.now(),
|
|
207
|
+
): Promise<StaleOutcome> {
|
|
208
|
+
const dir = recordDir(root, 'memory')
|
|
209
|
+
|
|
210
|
+
if (!existsSync(dir)) {
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
reason: 'no-folder',
|
|
214
|
+
message: `No memory folder at ${dir}, so there are no entries to read.`,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const listed = await readdir(dir, { withFileTypes: true })
|
|
219
|
+
const files = listed
|
|
220
|
+
.filter(
|
|
221
|
+
(entry) =>
|
|
222
|
+
entry.isFile() &&
|
|
223
|
+
entry.name.endsWith('.md') &&
|
|
224
|
+
entry.name !== MEMORY_INDEX,
|
|
225
|
+
)
|
|
226
|
+
.map((entry) => entry.name)
|
|
227
|
+
|
|
228
|
+
const entries = (
|
|
229
|
+
await Promise.all(
|
|
230
|
+
files.map((file) => readEntry(root, dir, file, days, now)),
|
|
231
|
+
)
|
|
232
|
+
).toSorted(compareEntries)
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
root,
|
|
237
|
+
folder: relative(root, dir),
|
|
238
|
+
days,
|
|
239
|
+
total: entries.length,
|
|
240
|
+
due: entries.filter((entry) => entry.due).length,
|
|
241
|
+
entries,
|
|
242
|
+
}
|
|
243
|
+
}
|
package/src/sandbox/expect.ts
CHANGED
|
@@ -178,7 +178,7 @@ function contentArray(value: unknown): ContentAssertion[] {
|
|
|
178
178
|
// A bare key written below a `[[content]]` header belongs to that table in
|
|
179
179
|
// TOML, not to the document, so a declaration listing `manual` or
|
|
180
180
|
// `max_turns` after its content blocks parses clean and silently asserts
|
|
181
|
-
// neither. The arm then named `claude/ui-test
|
|
181
|
+
// neither. The arm then named `claude/ui-test` (canon-keep-retired), now
|
|
182
182
|
// `claude/ui-checklist`, shipped that way: a turn ceiling that
|
|
183
183
|
// never ran and five manual entries that never reached the unchecked count,
|
|
184
184
|
// while `canon sandbox coverage` counted the arm as armed. Nothing at the
|
package/src/tasks/archive.ts
CHANGED
|
@@ -269,6 +269,30 @@ export function readPlanTarget(text: string): string | undefined {
|
|
|
269
269
|
return match[1] ?? match[2]
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Reads every target the first `Plan:` line outside a fenced sample carries,
|
|
274
|
+
* falling back to the bare-path form. `readPlanTarget` answers only a line
|
|
275
|
+
* holding exactly one pointer, so this is how a caller tells a line linking
|
|
276
|
+
* several plans apart from no line at all.
|
|
277
|
+
*/
|
|
278
|
+
export function readPlanTargets(text: string): readonly string[] {
|
|
279
|
+
const lines = text.split('\n')
|
|
280
|
+
const fenced = fenceMask(lines)
|
|
281
|
+
const line = lines.find(
|
|
282
|
+
(candidate, index) => !fenced[index] && candidate.startsWith('Plan:'),
|
|
283
|
+
)
|
|
284
|
+
if (line === undefined) return []
|
|
285
|
+
|
|
286
|
+
const rest = line.slice('Plan:'.length)
|
|
287
|
+
const links = [...rest.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)].map(
|
|
288
|
+
(match) => match[1],
|
|
289
|
+
)
|
|
290
|
+
if (links.length > 0) return links
|
|
291
|
+
|
|
292
|
+
const bare = /^[ \t]*(\S+)/.exec(rest)
|
|
293
|
+
return bare ? [bare[1]] : []
|
|
294
|
+
}
|
|
295
|
+
|
|
272
296
|
/**
|
|
273
297
|
* Builds a `Plan:` line as a markdown link whose text and target stay in step,
|
|
274
298
|
* the label taken from the target's filename with its extension dropped.
|
|
@@ -431,7 +455,7 @@ function isBulletFor(line: string, target: string): boolean {
|
|
|
431
455
|
|
|
432
456
|
/**
|
|
433
457
|
* Resolves the `Plan:` target against the board and against the project root
|
|
434
|
-
* both, which is how `
|
|
458
|
+
* both, which is how `context-fold` reads the same line. It accepts `../plans/x.md`
|
|
435
459
|
* and `.canon/plans/x.md` as one file, so a gate reading only the first form
|
|
436
460
|
* would pass the second and strand the plan this exists to protect.
|
|
437
461
|
*
|
|
@@ -485,7 +509,7 @@ function atOneRoot(path: string, plans: string[], root: string): string {
|
|
|
485
509
|
|
|
486
510
|
/**
|
|
487
511
|
* Names the other live tasks whose `Plan:` line lands on the same file. This is
|
|
488
|
-
* the rule `
|
|
512
|
+
* the rule `context-fold` applies before it archives a plan, held here so one
|
|
489
513
|
* question has one implementation: a plan another live task still cites is a
|
|
490
514
|
* plan the sweep is correct to leave, and a guard that read the folder instead
|
|
491
515
|
* refused every task sharing one plan and deadlocked the board against the
|
|
@@ -542,7 +566,7 @@ export type CitationOutcome = PlanCitations | ArchiveRefused
|
|
|
542
566
|
|
|
543
567
|
/**
|
|
544
568
|
* Answers where one task's plan sits and who else holds it, which is the whole
|
|
545
|
-
* of the last-live-citation rule. `
|
|
569
|
+
* of the last-live-citation rule. `context-fold` reads this rather than scanning
|
|
546
570
|
* the board itself, so the sweep that moves a plan and the gate that refuses a
|
|
547
571
|
* task archive cannot drift into disagreeing about which plan is free.
|
|
548
572
|
*
|
|
@@ -758,7 +782,7 @@ export async function archiveTask(
|
|
|
758
782
|
const from = join(dir, `${stem}.md`)
|
|
759
783
|
const text = await readFile(from, 'utf8')
|
|
760
784
|
|
|
761
|
-
// `
|
|
785
|
+
// `context-fold` ticks outcomes at ship time, before the merge, so an earlier
|
|
762
786
|
// slice merging after the last slice shipped would find every box ticked
|
|
763
787
|
// while the last slice is still open. Only the last number closes the task.
|
|
764
788
|
const last = readPullRequest(text).at(-1)
|
package/src/tasks/record.ts
CHANGED
|
@@ -353,7 +353,7 @@ export async function recordPlan(
|
|
|
353
353
|
}
|
|
354
354
|
|
|
355
355
|
/**
|
|
356
|
-
* Marks the named outcomes `[x]` in place. `
|
|
356
|
+
* Marks the named outcomes `[x]` in place. `context-fold` runs this against a
|
|
357
357
|
* board it can read and cannot edit from a linked worktree, and the positions
|
|
358
358
|
* come from the read it already made.
|
|
359
359
|
*/
|
package/src/tasks/validate.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
isReservedStem,
|
|
8
8
|
readOutcomes,
|
|
9
9
|
readPlanTarget,
|
|
10
|
+
readPlanTargets,
|
|
10
11
|
readPullRequest,
|
|
11
12
|
resolveLivePlan,
|
|
12
13
|
tasksDir,
|
|
@@ -41,6 +42,7 @@ export const FINDING_KINDS = [
|
|
|
41
42
|
'plan-mismatched',
|
|
42
43
|
'plan-parked',
|
|
43
44
|
'plan-absent',
|
|
45
|
+
'plan-several',
|
|
44
46
|
'task-unresolved',
|
|
45
47
|
'row-duplicated',
|
|
46
48
|
'row-misshapen',
|
|
@@ -563,7 +565,7 @@ async function listTaskStems(dir: string): Promise<string[]> {
|
|
|
563
565
|
|
|
564
566
|
/**
|
|
565
567
|
* Resolves a pointer against the board and against the project root both, the
|
|
566
|
-
* way `
|
|
568
|
+
* way `context-fold` reads the same line. A fragment is dropped first, since an
|
|
567
569
|
* anchor is part of the link and never part of the path.
|
|
568
570
|
*/
|
|
569
571
|
function resolves(target: string, dir: string, root: string): boolean {
|
|
@@ -728,8 +730,12 @@ async function planDisagreement(
|
|
|
728
730
|
const file = join(dir, `${row.stem}.md`)
|
|
729
731
|
if (!existsSync(file)) return undefined
|
|
730
732
|
|
|
731
|
-
const
|
|
733
|
+
const text = await readFile(file, 'utf8')
|
|
734
|
+
const target = readPlanTarget(text)
|
|
732
735
|
if (!target) {
|
|
736
|
+
const several = severalPlans(text, 'Run now', subject)
|
|
737
|
+
if (several) return several
|
|
738
|
+
|
|
733
739
|
return {
|
|
734
740
|
kind: 'plan-uncited',
|
|
735
741
|
group: 'Run now',
|
|
@@ -787,7 +793,13 @@ async function groupClaimFinding(
|
|
|
787
793
|
const file = join(dir, `${row.stem}.md`)
|
|
788
794
|
if (!existsSync(file)) return undefined
|
|
789
795
|
|
|
790
|
-
const
|
|
796
|
+
const text = await readFile(file, 'utf8')
|
|
797
|
+
const target = readPlanTarget(text)
|
|
798
|
+
if (!target) {
|
|
799
|
+
const several = severalPlans(text, row.group, row.stem)
|
|
800
|
+
if (several) return several
|
|
801
|
+
}
|
|
802
|
+
|
|
791
803
|
const live = target ? resolveLivePlan(target, dir, root) : undefined
|
|
792
804
|
const hasLivePlan = live !== undefined && existsSync(live)
|
|
793
805
|
|
|
@@ -814,6 +826,27 @@ async function groupClaimFinding(
|
|
|
814
826
|
return undefined
|
|
815
827
|
}
|
|
816
828
|
|
|
829
|
+
/**
|
|
830
|
+
* Reports a `Plan:` line linking more than one plan. `readPlanTarget` reads
|
|
831
|
+
* such a line as naming nothing, so without this a caller would report the
|
|
832
|
+
* line as absent when it is there and breaks the one-plan rule instead.
|
|
833
|
+
*/
|
|
834
|
+
function severalPlans(
|
|
835
|
+
text: string,
|
|
836
|
+
group: Finding['group'],
|
|
837
|
+
subject: string,
|
|
838
|
+
): Finding | undefined {
|
|
839
|
+
const targets = readPlanTargets(text)
|
|
840
|
+
if (targets.length < 2) return undefined
|
|
841
|
+
|
|
842
|
+
return {
|
|
843
|
+
kind: 'plan-several',
|
|
844
|
+
group,
|
|
845
|
+
subject,
|
|
846
|
+
message: `cites ${targets.length} plans on its Plan: line (${targets.join(', ')}). One task names one plan, so split the task or drop the extra links.`,
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
817
850
|
/**
|
|
818
851
|
* Where a plan pointer lands, resolved against the board and against the
|
|
819
852
|
* project root the way the archive resolves the same line. Neither base
|
package/standards/memory.md
CHANGED
|
@@ -56,19 +56,21 @@ Write no entry for a fact another surface already owns. A fact about a domain be
|
|
|
56
56
|
|
|
57
57
|
## Frontmatter
|
|
58
58
|
|
|
59
|
-
Every entry carries
|
|
59
|
+
Every entry carries the three required fields, and a reviewed entry carries a fourth.
|
|
60
60
|
|
|
61
61
|
```yaml
|
|
62
62
|
---
|
|
63
63
|
title: A scope glob wide enough for a correct run can be wide enough for every wrong one
|
|
64
64
|
description: Omit a declaration key whose only passing value admits the whole tree
|
|
65
65
|
category: Project
|
|
66
|
+
reviewed: 2026-09-26
|
|
66
67
|
---
|
|
67
68
|
```
|
|
68
69
|
|
|
69
70
|
- `title` (required): the rule stated as a line a reader can act on, in sentence case.
|
|
70
71
|
- `description` (required): one line naming what the entry settles, so a reader scanning the catalog decides whether to open it.
|
|
71
72
|
- `category` (required): the type in sentence case, which is what groups the catalog.
|
|
73
|
+
- `reviewed` (optional): the `YYYY-MM-DD` date a review last read the entry and kept it. A review writes it and capture never does, so an entry without the field has never been reviewed. A rewrite of the entry carries the field through untouched, since dropping it sends a kept entry back to the front of the queue.
|
|
72
74
|
|
|
73
75
|
Never write the filename stem as the `title`. The stem is a slug, and an entry titled with it reads as an unwritten file in every catalog that renders the field.
|
|
74
76
|
|
|
@@ -103,6 +105,16 @@ Link a related entry as `[[name]]`, where `name` is the target's filename stem w
|
|
|
103
105
|
|
|
104
106
|
The catalog is generated from sibling frontmatter rather than authored. Never hand-edit it, since the next regeneration discards whatever was added by hand.
|
|
105
107
|
|
|
108
|
+
## Reading
|
|
109
|
+
|
|
110
|
+
Nothing loads the pen into every session, and that is the design. An entry reaches a reader through one of three paths:
|
|
111
|
+
|
|
112
|
+
- A review reads the pen in batches, taking entries in the order `canon records stale memory` reports: due entries first, those citing a path the tree no longer holds ahead of the rest, then the longest unreviewed.
|
|
113
|
+
- A resuming session reads the entries matching the task it picks up.
|
|
114
|
+
- Capture reads the catalog to find an existing entry on the same topic before writing a new one.
|
|
115
|
+
|
|
116
|
+
An entry none of the three reaches is one the pen holds for a review to promote or retire.
|
|
117
|
+
|
|
106
118
|
## Collecting a review receipt
|
|
107
119
|
|
|
108
120
|
A review of the pen writes a receipt recording what it proposed per entry and what was decided. The receipt is collected once every item on it has resolved, and it is held while any item is still pending, since a pending item is decision state the next round reads back.
|
package/standards/tasks.md
CHANGED
|
@@ -51,7 +51,7 @@ The handoff takes one file per session for the reason a task does. A single shar
|
|
|
51
51
|
|
|
52
52
|
The catalog is the one reader that filters nothing, so it carries a row per sibling alongside the tasks. That is what a folder catalog is for, and the handoffs are what make it worth stating: a board accumulates one row per session that ever wrote one, with nothing pruning them. Anything reading the catalog as the backlog therefore does its own filtering, and a reader that takes every row as a task reports the handoffs as queued work.
|
|
53
53
|
|
|
54
|
-
The `task-board` skill creates and archives task files, and the archive carries the task's plan with it. `
|
|
54
|
+
The `task-board` skill creates and archives task files, and the archive carries the task's plan with it. `context-fold` marks outcomes `[x]` in an existing file. Neither does the other's job.
|
|
55
55
|
|
|
56
56
|
## Ordering
|
|
57
57
|
|
|
@@ -58,7 +58,7 @@ Append to the `## Scripts` table:
|
|
|
58
58
|
|
|
59
59
|
## CI docs (extend)
|
|
60
60
|
|
|
61
|
-
In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `astro check` passes ``. The Build row's assertion reads: `` `astro build` succeeds ``.
|
|
61
|
+
In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `astro check` passes ``. The Build row's assertion reads: `` `astro build` succeeds ``. <!-- audit-ignore-citations: canon/context/ci.md -->
|
|
62
62
|
|
|
63
63
|
## Social card route
|
|
64
64
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# to hand a worker. This is the only event that lands after a merge.
|
|
8
8
|
#
|
|
9
9
|
# Candidates rather than closed tasks, because an all-`[x]` board entry does not
|
|
10
|
-
# mean the work merged. `
|
|
10
|
+
# mean the work merged. `context-fold` marks outcomes on the branch as step 1 of
|
|
11
11
|
# the ship chain, so a task can read closed while its pull request is still open
|
|
12
12
|
# or already abandoned. The board is gitignored and an archived task has nothing
|
|
13
13
|
# to recover it, so `task-board` confirms the work is on `main` before moving
|
|
@@ -25,7 +25,7 @@ canon/
|
|
|
25
25
|
.canon/
|
|
26
26
|
├── tasks/ ← seeded. One file per task plus a generated index.md, local scratch only. `archive/` holds the tasks that shipped.
|
|
27
27
|
├── diagrams/ ← seeded. Per-kind Mermaid views. `index.md` is the discovery anchor. `<kind>.md` files hold one diagram each, grouped by the `category` frontmatter field.
|
|
28
|
-
├── plans/ ← execution detail for multi-step tasks. `feature-*.md` entries swept by
|
|
28
|
+
├── plans/ ← execution detail for multi-step tasks. `feature-*.md` entries swept by context-fold into `archive/`.
|
|
29
29
|
├── review/ ← one subfolder per producer. `branch/` for review-branch, `feedback/` for canon feedback, `memory/` for memory-review with its own `archive/`, `design/` for canon design render.
|
|
30
30
|
├── tmp/ ← ephemeral scratch space, safe to delete without loss
|
|
31
31
|
└── memory/ ← session facts no context entry owns. `index.md` regenerated by a hook.
|
package/tooling/go/reference.md
CHANGED
|
@@ -57,4 +57,4 @@ Append to the `## Scripts` table:
|
|
|
57
57
|
|
|
58
58
|
## CI docs (extend)
|
|
59
59
|
|
|
60
|
-
In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `go vet ./...` passes ``. Add a Lint row whose assertion reads: `` `golangci-lint run` passes ``. Add a Tests row whose assertion reads: `` `go test -race ./...` exits 0 ``.
|
|
60
|
+
In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `go vet ./...` passes ``. Add a Lint row whose assertion reads: `` `golangci-lint run` passes ``. Add a Tests row whose assertion reads: `` `go test -race ./...` exits 0 ``. <!-- audit-ignore-citations: canon/context/ci.md -->
|
|
@@ -50,4 +50,4 @@ Append to the `## Scripts` table:
|
|
|
50
50
|
|
|
51
51
|
## CI docs (extend)
|
|
52
52
|
|
|
53
|
-
In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `next typegen && tsc --noEmit` passes ``.
|
|
53
|
+
In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `next typegen && tsc --noEmit` passes ``. <!-- audit-ignore-citations: canon/context/ci.md -->
|
package/tooling/php/reference.md
CHANGED
|
@@ -71,4 +71,4 @@ Append to the `## Scripts` table:
|
|
|
71
71
|
|
|
72
72
|
## CI docs (extend)
|
|
73
73
|
|
|
74
|
-
In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `phpstan analyse` passes ``. Add a Lint row whose assertion reads: `` `php-cs-fixer check` passes ``. Add a Tests row whose assertion reads: `` `phpunit` exits 0 ``.
|
|
74
|
+
In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `phpstan analyse` passes ``. Add a Lint row whose assertion reads: `` `php-cs-fixer check` passes ``. Add a Tests row whose assertion reads: `` `phpunit` exits 0 ``. <!-- audit-ignore-citations: canon/context/ci.md -->
|
|
@@ -62,4 +62,4 @@ Append to the `## Scripts` table:
|
|
|
62
62
|
|
|
63
63
|
## CI docs (extend)
|
|
64
64
|
|
|
65
|
-
In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `mypy .` passes ``. Add a Lint row whose assertion reads: `` `ruff check` and `ruff format --check` pass ``. Add a Tests row whose assertion reads: `` `pytest` exits 0 ``.
|
|
65
|
+
In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `mypy .` passes ``. Add a Lint row whose assertion reads: `` `ruff check` and `ruff format --check` pass ``. Add a Tests row whose assertion reads: `` `pytest` exits 0 ``. <!-- audit-ignore-citations: canon/context/ci.md -->
|
|
@@ -52,4 +52,4 @@ Append to the `## Scripts` table:
|
|
|
52
52
|
|
|
53
53
|
## CI docs (extend)
|
|
54
54
|
|
|
55
|
-
In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `tsc --noEmit` passes ``.
|
|
55
|
+
In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `tsc --noEmit` passes ``. <!-- audit-ignore-citations: canon/context/ci.md -->
|
package/tooling/web/reference.md
CHANGED
|
@@ -84,7 +84,7 @@ Sticky negative knowledge. Do not relearn.
|
|
|
84
84
|
|
|
85
85
|
## CI docs (extend)
|
|
86
86
|
|
|
87
|
-
Extend `canon/context/ci.md` so the `## Checks` table reflects the web jobs.
|
|
87
|
+
Extend `canon/context/ci.md` so the `## Checks` table reflects the web jobs. <!-- audit-ignore-citations: canon/context/ci.md -->
|
|
88
88
|
|
|
89
89
|
Append rows:
|
|
90
90
|
|