@erclx/canon 5.30.0 → 5.32.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 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/references/adopted.md +1 -1
- package/claude/skills/auto-ship/REQUIREMENT.md +4 -16
- package/claude/skills/auto-ship/SKILL.md +13 -58
- package/claude/skills/auto-ship/references/draft-mark.md +2 -2
- package/claude/skills/auto-ship/references/failure-recovery.md +1 -5
- package/claude/skills/auto-ship/references/tier-failures.md +1 -1
- package/claude/skills/auto-ship/references/ui-checklist.md +2 -2
- package/claude/skills/auto-ship/references/verb-absent.md +3 -27
- package/claude/skills/backlog-triage/SKILL.md +1 -1
- package/claude/skills/build-in-slices/REQUIREMENT.md +1 -1
- package/claude/skills/build-in-slices/references/adopted.md +1 -1
- package/claude/skills/canon-cli/REQUIREMENT.md +4 -6
- package/claude/skills/canon-cli/SKILL.md +2 -2
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
- package/claude/skills/canon-feedback/SKILL.md +10 -13
- package/claude/skills/canon-feedback-triage/REQUIREMENT.md +13 -14
- package/claude/skills/canon-feedback-triage/SKILL.md +50 -29
- package/claude/skills/canvas/REQUIREMENT.md +3 -4
- package/claude/skills/canvas/SKILL.md +5 -5
- package/{standards → claude/skills/canvas/references}/canvas.md +12 -1
- package/claude/skills/ci-workflow/REQUIREMENT.md +0 -1
- package/claude/skills/context-fold/REQUIREMENT.md +8 -20
- package/claude/skills/context-fold/SKILL.md +21 -45
- package/claude/skills/context-fold/references/classify.md +7 -8
- package/claude/skills/context-fold/references/completion-report.md +2 -6
- package/claude/skills/context-fold/references/context-refresh.md +5 -15
- package/claude/skills/context-fold/references/promoted-pages.md +3 -3
- package/claude/skills/context-fold/references/receipt-sweep.md +6 -17
- package/claude/skills/context-fold/references/unusable-baseline.md +4 -6
- package/claude/skills/create-rule/REQUIREMENT.md +0 -1
- package/claude/skills/create-skill/REQUIREMENT.md +6 -4
- package/claude/skills/create-skill/SKILL.md +9 -8
- package/{standards → claude/skills/create-skill/references}/skill-paths.md +1 -0
- package/claude/skills/decision-escalate/REQUIREMENT.md +1 -1
- package/claude/skills/decision-escalate/SKILL.md +1 -1
- package/claude/skills/design-taste/REQUIREMENT.md +0 -2
- package/claude/skills/design-taste/SKILL.md +2 -4
- package/claude/skills/design-taste/references/vocabulary.md +1 -1
- package/claude/skills/docs-sync/REQUIREMENT.md +3 -3
- package/claude/skills/docs-sync/SKILL.md +1 -1
- package/claude/skills/document-health/REQUIREMENT.md +2 -2
- package/claude/skills/document-health/SKILL.md +4 -5
- package/claude/skills/document-health/references/axes.md +4 -6
- package/claude/skills/draft-and-pick/REQUIREMENT.md +16 -14
- package/claude/skills/draft-and-pick/SKILL.md +40 -32
- package/claude/skills/draft-and-pick/references/live-arms.md +9 -9
- package/claude/skills/draft-doc/REQUIREMENT.md +5 -5
- package/claude/skills/draft-doc/SKILL.md +10 -11
- package/claude/skills/draft-doc/references/requirements.md +61 -0
- package/claude/skills/draft-doc/references/wiki-kind.md +53 -0
- package/claude/skills/draft-doc/references/wiki.md +50 -30
- package/claude/skills/draft-figure/REQUIREMENT.md +2 -2
- package/claude/skills/draft-figure/SKILL.md +5 -5
- package/claude/skills/draft-figure/references/architecture-set.md +4 -4
- package/claude/skills/draft-figure/references/hand-drawn.md +1 -1
- package/claude/skills/draft-figure/references/mermaid-path.md +55 -0
- package/claude/skills/draft-figure/references/mermaid.md +43 -34
- package/claude/skills/draft-identity/REQUIREMENT.md +1 -1
- package/claude/skills/draft-identity/SKILL.md +9 -7
- package/claude/skills/draft-ready/REQUIREMENT.md +1 -1
- package/claude/skills/draft-ready/SKILL.md +6 -6
- package/claude/skills/draft-ready/references/assembly.md +1 -1
- package/{standards → claude/skills/draft-ready/references}/ready.md +13 -5
- package/claude/skills/git-issue/SKILL.md +1 -1
- package/claude/skills/git-issue/references/issue.md +3 -3
- package/claude/skills/git-pr/SKILL.md +1 -1
- package/claude/skills/git-ship/REQUIREMENT.md +0 -2
- package/claude/skills/git-ship/SKILL.md +16 -24
- package/claude/skills/git-stage/SKILL.md +1 -1
- package/claude/skills/markdown-craft/REQUIREMENT.md +23 -0
- package/claude/skills/markdown-craft/SKILL.md +17 -0
- package/{standards → claude/skills/markdown-craft/references}/markdown.md +14 -7
- package/claude/skills/plan-feature/REQUIREMENT.md +2 -1
- package/claude/skills/plan-feature/SKILL.md +14 -8
- package/{standards → claude/skills/plan-feature/references}/plan.md +33 -21
- package/claude/skills/plan-groundwork/SKILL.md +5 -6
- package/{standards → claude/skills/plan-groundwork/references}/groundwork.md +17 -4
- package/claude/skills/plan-intake/SKILL.md +2 -3
- package/{standards → claude/skills/plan-intake/references}/intake.md +17 -2
- package/claude/skills/plan-intake-answer/REQUIREMENT.md +1 -1
- package/claude/skills/plan-intake-answer/SKILL.md +1 -1
- package/claude/skills/review-address/SKILL.md +2 -2
- package/claude/skills/review-address/references/post-review-findings.md +1 -1
- package/claude/skills/review-craft/REQUIREMENT.md +3 -3
- package/claude/skills/review-craft/SKILL.md +2 -2
- package/claude/skills/review-craft/references/ui.md +4 -4
- package/claude/skills/review-pr/REQUIREMENT.md +1 -1
- package/claude/skills/review-pr/SKILL.md +5 -7
- package/claude/skills/review-pr/references/post.md +2 -2
- package/claude/skills/review-ui/REQUIREMENT.md +0 -1
- package/claude/skills/review-ui/SKILL.md +1 -1
- package/claude/skills/review-ui/references/post.md +1 -1
- package/claude/skills/role-orchestrator/REQUIREMENT.md +0 -3
- package/claude/skills/role-orchestrator/SKILL.md +8 -8
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +4 -3
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +7 -10
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +0 -3
- package/claude/skills/role-orchestrator/references/orchestrator-resume.md +1 -1
- package/claude/skills/role-planner/SKILL.md +2 -2
- package/claude/skills/role-reviewer/SKILL.md +1 -1
- package/claude/skills/role-worker/SKILL.md +2 -2
- package/claude/skills/session-compact/REQUIREMENT.md +1 -4
- package/claude/skills/session-compact/SKILL.md +3 -13
- package/claude/skills/session-compact/references/handoff-note.md +2 -2
- package/claude/skills/session-map/REQUIREMENT.md +1 -9
- package/claude/skills/session-map/SKILL.md +8 -16
- package/{standards → claude/skills/session-map/references}/session.md +17 -11
- package/claude/skills/session-relay/REQUIREMENT.md +1 -1
- package/claude/skills/session-resume/REQUIREMENT.md +4 -6
- package/claude/skills/session-resume/SKILL.md +3 -9
- package/claude/skills/session-worktree/SKILL.md +1 -1
- package/claude/skills/session-worktree/references/main-root-writes.md +2 -2
- package/claude/skills/systematic-debugging/REQUIREMENT.md +0 -1
- package/claude/skills/target-check/REQUIREMENT.md +3 -3
- package/claude/skills/target-check/SKILL.md +1 -1
- package/claude/skills/target-check/references/domains.md +1 -1
- package/claude/skills/target-setup/REQUIREMENT.md +4 -4
- package/claude/skills/target-setup/SKILL.md +3 -3
- package/claude/skills/target-setup/references/detect.md +1 -1
- package/claude/skills/target-setup/references/indexes.md +1 -1
- package/claude/skills/task-board/SKILL.md +4 -4
- package/claude/skills/task-board/references/archive.md +1 -1
- package/{standards → claude/skills/task-board/references}/board.md +9 -0
- package/claude/skills/task-board/references/decline.md +1 -1
- package/{standards → claude/skills/task-board/references}/tasks.md +13 -8
- package/claude/skills/teach-workspace/SKILL.md +1 -1
- package/claude/skills/teach-workspace/references/revisit.md +1 -1
- package/{standards → claude/skills/teach-workspace/references}/teach.md +17 -5
- package/claude/skills/test-first/REQUIREMENT.md +1 -1
- package/claude/skills/ux-audit/REQUIREMENT.md +2 -2
- package/claude/skills/ux-audit/SKILL.md +2 -3
- package/claude/skills/write-human/REQUIREMENT.md +0 -1
- package/claude/skills/write-human/SKILL.md +2 -2
- package/docs/agents/audits.md +1 -1
- package/docs/agents/canvas.md +1 -1
- package/docs/agents/commands.md +6 -10
- package/docs/agents/context-audit-checks.md +10 -24
- package/docs/agents/context-audit.md +7 -15
- package/docs/agents/context-classify.md +5 -5
- package/docs/agents/counts.md +5 -5
- package/docs/agents/drift-surfaces.md +2 -2
- package/docs/agents/index.md +3 -4
- package/docs/agents/install-and-sync.md +6 -6
- package/docs/agents/intake.md +1 -1
- package/docs/agents/markdown-audit.md +1 -1
- package/docs/agents/records-stale.md +5 -5
- package/docs/agents/records-validate.md +1 -1
- package/docs/agents/records.md +2 -2
- package/docs/agents/restated.md +1 -1
- package/docs/agents/routing.md +1 -1
- package/docs/agents/rule-citations.md +1 -1
- package/docs/agents/skills-audit.md +2 -2
- package/docs/agents/state-scoped-risk.md +2 -2
- package/docs/agents/superseded.md +2 -2
- package/docs/agents/tasks-plans.md +2 -2
- package/docs/agents/tasks-validate.md +1 -1
- package/docs/agents/tasks.md +1 -1
- package/docs/agents/teach.md +1 -1
- package/docs/agents/test-order.md +1 -1
- package/docs/agents/upstream.md +94 -0
- package/docs/target/migrations.md +24 -0
- package/docs/target/projects.md +5 -7
- package/docs/target/scenarios.md +2 -2
- package/docs/target/sync.md +3 -3
- package/docs/workflow/ai-workflow.md +7 -38
- package/docs/workflow/index.md +1 -1
- package/docs/workflow/operating-model.md +8 -8
- package/docs/workflow/parallel-features.md +7 -7
- package/docs/workflow/skill-map-on-request.md +13 -16
- package/docs/workflow/skill-map.md +20 -32
- package/docs/workflow/visual-design-workflow.md +11 -14
- package/governance/renames.toml +0 -23
- package/governance/rules/canon/605-worktrees.md +1 -1
- package/governance/rules/{standards → canon}/plan.md +5 -5
- package/governance/rules/canon/tasks.md +18 -0
- package/governance/rules/writing/markdown.md +23 -0
- package/governance/stacks/base.toml +1 -1
- package/package.json +1 -1
- package/scripts/core/install-check.sh +2 -2
- package/scripts/core/regen-web-previews.ts +1 -1
- package/src/audits/catalog.ts +5 -97
- package/src/claude/cases/authoring.ts +5 -18
- package/src/claude/cases/misc.ts +0 -17
- package/src/claude/cases/setup.ts +3 -20
- package/src/claude/cases/workflow.ts +0 -33
- package/src/claude/seeds.ts +1 -7
- package/src/claude/skills-audit.ts +1 -1
- package/src/claude/skills-reach.ts +1 -1
- package/src/cli/help.ts +3 -4
- package/src/cli.ts +2 -4
- package/src/commands/claude.ts +9 -15
- package/src/commands/context.ts +18 -280
- package/src/commands/feedback.ts +22 -91
- package/src/commands/gov.ts +2 -2
- package/src/commands/records.ts +24 -6
- package/src/commands/sync.ts +5 -3
- package/src/commands/upstream.ts +430 -0
- package/src/context/audit.ts +6 -15
- package/src/context/classify/extract.ts +6 -13
- package/src/context/classify/patterns.ts +2 -41
- package/src/context/classify/prompts.ts +2 -6
- package/src/context/classify/run.ts +3 -3
- package/src/context/folders.ts +7 -12
- package/src/context/gate.ts +11 -91
- package/src/context/requirements.ts +13 -4
- package/src/counts/scan.ts +3 -3
- package/src/design/components.ts +2 -2
- package/src/feedback/body.ts +0 -12
- package/src/gate/measures.ts +6 -57
- package/src/gate/stages.ts +0 -1
- package/src/gov/citations.ts +9 -3
- package/src/gov/restated.ts +3 -3
- package/src/markdown/structure.ts +3 -3
- package/src/migrate/record-layout.ts +3 -3
- package/src/records/backup.ts +1 -0
- package/src/records/ordinal.ts +1 -1
- package/src/records/prune.ts +2 -1
- package/src/records/stale-canonical.ts +2 -2
- package/src/records/validate.ts +55 -0
- package/src/roots/record.ts +2 -1
- package/src/sandbox/scenario.ts +115 -11
- package/src/shipped/references.ts +3 -3
- package/src/standards/read.ts +52 -1
- package/src/sync/seeds-report.ts +1 -1
- package/src/tasks/answers.ts +14 -1
- package/src/tasks/reach.ts +2 -2
- package/src/tasks/stack.ts +176 -0
- package/src/tasks/validate.ts +1 -1
- package/src/teach/workspace.ts +1 -1
- package/src/upstream/catalog.ts +112 -0
- package/src/upstream/cursor.ts +120 -0
- package/src/upstream/digest.ts +75 -0
- package/src/upstream/due.ts +128 -0
- package/src/upstream/releases.ts +170 -0
- package/standards/context.md +3 -5
- package/standards/design.md +2 -3
- package/standards/index.md +0 -23
- package/standards/publish.md +1 -1
- package/standards/rule.md +1 -1
- package/standards/skill.md +1 -1
- package/tooling/base/reference.md +1 -1
- package/tooling/claude/reference.md +6 -20
- package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +1 -1
- package/tooling/claude/seeds/.claude/hooks/records-index.sh +8 -20
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +3 -3
- package/tooling/claude/seeds/CLAUDE.md +1 -3
- package/tooling/claude/seeds/canon/REQUIREMENTS.md +2 -9
- package/tooling/web/configs/e2e/screenshot.ts +0 -1
- package/claude/skills/auto-ship/references/review-findings.md +0 -20
- package/claude/skills/bash-cli-script/REQUIREMENT.md +0 -43
- package/claude/skills/bash-cli-script/SKILL.md +0 -53
- package/claude/skills/bash-cli-script/references/template.md +0 -19
- package/claude/skills/canon-operator/REQUIREMENT.md +0 -62
- package/claude/skills/canon-operator/SKILL.md +0 -113
- package/claude/skills/canon-rollout/REQUIREMENT.md +0 -59
- package/claude/skills/canon-rollout/SKILL.md +0 -140
- package/claude/skills/canon-rollout/references/failure-recovery.md +0 -20
- package/claude/skills/context-fold/references/anchor-sweep.md +0 -58
- package/claude/skills/context-fold/references/wireframe-sweep.md +0 -45
- package/claude/skills/create-standard/REQUIREMENT.md +0 -33
- package/claude/skills/create-standard/SKILL.md +0 -31
- package/claude/skills/design-extract/REQUIREMENT.md +0 -42
- package/claude/skills/design-extract/SKILL.md +0 -108
- package/claude/skills/draft-and-pick/references/frame.html +0 -118
- package/claude/skills/draft-doc/references/wireframes.md +0 -48
- package/claude/skills/index-lookup/REQUIREMENT.md +0 -35
- package/claude/skills/index-lookup/SKILL.md +0 -44
- package/claude/skills/markdown-propose/REQUIREMENT.md +0 -47
- package/claude/skills/markdown-propose/SKILL.md +0 -122
- package/claude/skills/markdown-propose/references/format.md +0 -118
- package/claude/skills/memory-capture/REQUIREMENT.md +0 -50
- package/claude/skills/memory-capture/SKILL.md +0 -117
- package/claude/skills/memory-review/REQUIREMENT.md +0 -52
- package/claude/skills/memory-review/SKILL.md +0 -129
- package/claude/skills/memory-review/references/later-phases.md +0 -118
- package/claude/skills/memory-review/references/receipt-format.md +0 -64
- package/claude/skills/project-commands/REQUIREMENT.md +0 -42
- package/claude/skills/project-commands/SKILL.md +0 -87
- package/claude/skills/repo-metadata/REQUIREMENT.md +0 -37
- package/claude/skills/repo-metadata/SKILL.md +0 -54
- package/claude/skills/restate-plainly/REQUIREMENT.md +0 -41
- package/claude/skills/restate-plainly/SKILL.md +0 -41
- package/claude/skills/review-branch/REQUIREMENT.md +0 -40
- package/claude/skills/review-branch/SKILL.md +0 -137
- package/claude/skills/seed-sync/REQUIREMENT.md +0 -45
- package/claude/skills/seed-sync/SKILL.md +0 -158
- package/claude/skills/sketch-design/REQUIREMENT.md +0 -38
- package/claude/skills/sketch-design/SKILL.md +0 -94
- package/claude/skills/ux-walkthrough/REQUIREMENT.md +0 -56
- package/claude/skills/ux-walkthrough/SKILL.md +0 -53
- package/claude/skills/ux-walkthrough/references/builds.md +0 -15
- package/claude/skills/ux-walkthrough/references/candidate-pages.md +0 -11
- package/claude/skills/ux-walkthrough/references/measuring.md +0 -22
- package/claude/skills/ux-walkthrough/references/record.md +0 -36
- package/claude/skills/ux-walkthrough/references/relay.md +0 -25
- package/docs/agents/architecture-record.md +0 -36
- package/docs/agents/review-classification.md +0 -76
- package/governance/rules/canon/601-indexes.md +0 -9
- package/governance/rules/canon/603-memory.md +0 -11
- package/governance/rules/claude/565-behavior.md +0 -27
- package/governance/rules/claude/567-planning.md +0 -16
- package/governance/rules/claude/575-hooks.md +0 -16
- package/governance/rules/claude/576-settings.md +0 -13
- package/governance/rules/claude/592-claude-md.md +0 -19
- package/governance/rules/lang/120-bash.md +0 -18
- package/governance/rules/standards/architecture.md +0 -13
- package/governance/rules/standards/groundwork.md +0 -13
- package/governance/rules/standards/intake.md +0 -12
- package/governance/rules/standards/markdown.md +0 -16
- package/governance/rules/standards/memory.md +0 -12
- package/governance/rules/standards/ready.md +0 -13
- package/governance/rules/standards/requirements.md +0 -12
- package/governance/rules/standards/session.md +0 -13
- package/governance/rules/standards/tasks.md +0 -15
- package/governance/rules/standards/teach.md +0 -14
- package/governance/rules/standards/wiki.md +0 -13
- package/governance/rules/standards/wireframes.md +0 -13
- package/governance/rules/writing/800-prose.md +0 -15
- package/src/autoship/classify.ts +0 -75
- package/src/autoship/paths.ts +0 -50
- package/src/commands/autoship.ts +0 -129
- package/src/commands/repo.ts +0 -393
- package/src/context/architecture.ts +0 -654
- package/src/context/wireframe-states.ts +0 -238
- package/src/repo/metadata.ts +0 -206
- package/standards/architecture.md +0 -127
- package/standards/memory.md +0 -158
- package/standards/mermaid.md +0 -64
- package/standards/requirements.md +0 -79
- package/standards/wiki.md +0 -77
- package/standards/wireframes.md +0 -143
- package/tooling/claude/seeds/.claude/memory/index.md +0 -8
- package/tooling/claude/seeds/canon/ARCHITECTURE.md +0 -23
- package/tooling/claude/seeds/canon/wireframes/index.md +0 -8
- /package/{standards → claude/skills/create-skill/references}/skill-practice.md +0 -0
- /package/{standards → claude/skills/create-skill/references}/skill-requirement.md +0 -0
- /package/{standards → claude/skills/plan-feature/references}/plan-lifecycle.md +0 -0
- /package/{standards → claude/skills/plan-groundwork/references}/groundwork-spikes.md +0 -0
- /package/{standards → claude/skills/task-board/references}/tasks-decline.md +0 -0
- /package/{standards → claude/skills/teach-workspace/references}/glossary.md +0 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
3
|
+
import { basename, join } from 'node:path'
|
|
4
|
+
import { recordDir } from '@/roots/record'
|
|
5
|
+
import type { Finding } from '@/records/validate'
|
|
6
|
+
import { splitPlanSections } from '@/records/validate'
|
|
7
|
+
import { readDeclarations } from '@/tasks/reach'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The fold thresholds, calibrated over 1,043 live and archived plans in which
|
|
11
|
+
* 87 stack pairs name a parent. At these values 15 report: the inspector,
|
|
12
|
+
* canvas, slides, and evidence chains, plus one sandbox contract split that
|
|
13
|
+
* carries a `Judged apart from` line. The ceiling drops one pair of 24 files.
|
|
14
|
+
* The per-PR cost was never measured as a number, so these are calibrated
|
|
15
|
+
* against the corpus and not derived.
|
|
16
|
+
*/
|
|
17
|
+
export const MIN_SHARED = 3
|
|
18
|
+
export const MIN_OVERLAP = 0.6
|
|
19
|
+
export const MAX_UNION = 20
|
|
20
|
+
|
|
21
|
+
const STACK_PARENT =
|
|
22
|
+
/(?:stacks? on|builds? on|branch(?:es)? from)\s+(?:(?:the|batch|plan|slice)\s+)*`(feature-[a-z0-9-]+)`/gi
|
|
23
|
+
|
|
24
|
+
const JUDGED_APART = /^judged apart from\s+`(feature-[a-z0-9-]+)`:\s*\S/i
|
|
25
|
+
|
|
26
|
+
const BULLET = /^[-*]\s+/
|
|
27
|
+
|
|
28
|
+
export interface StackPlan {
|
|
29
|
+
readonly stem: string
|
|
30
|
+
readonly text: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StackPair {
|
|
34
|
+
readonly child: string
|
|
35
|
+
readonly parent: string
|
|
36
|
+
readonly shared: number
|
|
37
|
+
readonly union: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One string per `**Constraints:**` bullet, with a wrapped continuation line
|
|
42
|
+
* joined to the bullet it belongs to.
|
|
43
|
+
*/
|
|
44
|
+
function constraintBullets(text: string): string[] {
|
|
45
|
+
const bullets: string[] = []
|
|
46
|
+
|
|
47
|
+
for (const line of splitPlanSections(text).get('Constraints') ?? []) {
|
|
48
|
+
const trimmed = line.trim()
|
|
49
|
+
if (trimmed.length === 0) continue
|
|
50
|
+
|
|
51
|
+
if (BULLET.test(trimmed)) bullets.push(trimmed.replace(BULLET, ''))
|
|
52
|
+
else if (bullets.length > 0) bullets[bullets.length - 1] += ` ${trimmed}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return bullets
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The plan stems a plan says it stacks on, in the order written.
|
|
60
|
+
*
|
|
61
|
+
* Only the slug standing directly after the phrase counts, with at most a
|
|
62
|
+
* filler word between. A line like "`feature-x` stacks on this slice, not on
|
|
63
|
+
* `feature-y`" names a parent for `feature-y`'s plan and none for this one, and
|
|
64
|
+
* reading every slug on a stack-worded line would hand it the wrong parent.
|
|
65
|
+
*/
|
|
66
|
+
export function readStackParents(text: string): string[] {
|
|
67
|
+
const parents: string[] = []
|
|
68
|
+
|
|
69
|
+
for (const bullet of constraintBullets(text)) {
|
|
70
|
+
for (const match of bullet.matchAll(STACK_PARENT)) {
|
|
71
|
+
const parent = match[1]
|
|
72
|
+
if (parent !== undefined) parents.push(parent.toLowerCase())
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [...new Set(parents)]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isJudgedApart(text: string, parent: string): boolean {
|
|
80
|
+
return constraintBullets(text).some(
|
|
81
|
+
(bullet) => JUDGED_APART.exec(bullet)?.[1]?.toLowerCase() === parent,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether two declarations name the same file or one folder holding the other. */
|
|
86
|
+
function overlaps(a: string, b: string): boolean {
|
|
87
|
+
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function countShared(from: readonly string[], to: readonly string[]): number {
|
|
91
|
+
return from.filter((entry) => to.some((other) => overlaps(entry, other)))
|
|
92
|
+
.length
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Every live plan that stacks on a live sibling it mostly shares files with and
|
|
97
|
+
* has not written down why the two are judged apart.
|
|
98
|
+
*
|
|
99
|
+
* A parent absent from `plans` is never reported, which is how an archived
|
|
100
|
+
* parent stays clear: the caller hands over the live folder only, so a merged
|
|
101
|
+
* parent cannot be folded into. When two plans each name the other, the pair is
|
|
102
|
+
* reported once, on the child sorting second.
|
|
103
|
+
*
|
|
104
|
+
* A folder declaration counts as sharing the files under it, taking the larger
|
|
105
|
+
* of the two directions and capping at the smaller set, so a folder and the
|
|
106
|
+
* files beneath it are not undercounted as distinct strings.
|
|
107
|
+
*/
|
|
108
|
+
export function stackPairs(plans: readonly StackPlan[]): StackPair[] {
|
|
109
|
+
const byStem = new Map(plans.map((plan) => [plan.stem, plan]))
|
|
110
|
+
const pairs: StackPair[] = []
|
|
111
|
+
|
|
112
|
+
for (const child of plans) {
|
|
113
|
+
for (const parentStem of readStackParents(child.text)) {
|
|
114
|
+
const parent = byStem.get(parentStem)
|
|
115
|
+
if (parent === undefined || parent.stem === child.stem) continue
|
|
116
|
+
|
|
117
|
+
const mutual = readStackParents(parent.text).includes(child.stem)
|
|
118
|
+
if (mutual && child.stem < parent.stem) continue
|
|
119
|
+
|
|
120
|
+
if (isJudgedApart(child.text, parentStem)) continue
|
|
121
|
+
|
|
122
|
+
const own = readDeclarations(child.text)
|
|
123
|
+
const theirs = readDeclarations(parent.text)
|
|
124
|
+
const smaller = Math.min(own.length, theirs.length)
|
|
125
|
+
if (smaller === 0) continue
|
|
126
|
+
|
|
127
|
+
const shared = Math.min(
|
|
128
|
+
smaller,
|
|
129
|
+
Math.max(countShared(own, theirs), countShared(theirs, own)),
|
|
130
|
+
)
|
|
131
|
+
const union = new Set([...own, ...theirs]).size
|
|
132
|
+
|
|
133
|
+
if (
|
|
134
|
+
shared >= MIN_SHARED &&
|
|
135
|
+
shared / smaller >= MIN_OVERLAP &&
|
|
136
|
+
union <= MAX_UNION
|
|
137
|
+
) {
|
|
138
|
+
pairs.push({ child: child.stem, parent: parentStem, shared, union })
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return pairs
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The `Judged apart from` remedy, worded once for the finding and the gate. */
|
|
147
|
+
export const JUDGED_APART_REMEDY =
|
|
148
|
+
'Fold the batch into its parent as contiguous commits on one branch, or write `Judged apart from `feature-<parent>`: <reason>` in its constraints.'
|
|
149
|
+
|
|
150
|
+
export function stackFindings(plans: readonly StackPlan[]): Finding[] {
|
|
151
|
+
return stackPairs(plans).map(({ child, parent, shared, union }) => ({
|
|
152
|
+
kind: 'stack-foldable',
|
|
153
|
+
record: `${child}.md`,
|
|
154
|
+
subject: parent,
|
|
155
|
+
message: `stacks on \`${parent}\` and shares ${shared} declared files with it, ${union} between the two. ${JUDGED_APART_REMEDY}`,
|
|
156
|
+
}))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The live plans folder as stem and text pairs. The archive is a folder below
|
|
161
|
+
* this one and is never walked, so a merged parent is absent and stays clear.
|
|
162
|
+
*/
|
|
163
|
+
export async function readLivePlans(root: string): Promise<StackPlan[]> {
|
|
164
|
+
const dir = recordDir(root, 'plans')
|
|
165
|
+
if (!existsSync(dir)) return []
|
|
166
|
+
|
|
167
|
+
const names = (await readdir(dir)).filter((name) => name.endsWith('.md'))
|
|
168
|
+
names.sort()
|
|
169
|
+
|
|
170
|
+
return Promise.all(
|
|
171
|
+
names.map(async (name) => ({
|
|
172
|
+
stem: basename(name, '.md'),
|
|
173
|
+
text: await readFile(join(dir, name), 'utf8'),
|
|
174
|
+
})),
|
|
175
|
+
)
|
|
176
|
+
}
|
package/src/tasks/validate.ts
CHANGED
|
@@ -18,7 +18,7 @@ const ORDERING_FILE = 'priority.md'
|
|
|
18
18
|
const BACKLOG_FILE = 'backlog.md'
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* The readiness headings `
|
|
21
|
+
* The readiness headings `claude/skills/task-board/references/board.md` fixes. The names are the
|
|
22
22
|
* contract rather than a suggestion, so a board grouping under names of its own
|
|
23
23
|
* reads as carrying no group at all and the run refuses instead of reporting a
|
|
24
24
|
* clean board it never parsed.
|
package/src/teach/workspace.ts
CHANGED
|
@@ -30,7 +30,7 @@ export interface TeachRefused {
|
|
|
30
30
|
readonly detail: readonly string[]
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/** The files and folders `
|
|
33
|
+
/** The files and folders `claude/skills/teach-workspace/references/teach.md` fixes for a workspace. */
|
|
34
34
|
export const TEACH_MISSION = 'MISSION.md'
|
|
35
35
|
export const TEACH_RESOURCES = 'RESOURCES.md'
|
|
36
36
|
export const TEACH_GLOSSARY = 'GLOSSARY.md'
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { COMMAND_GROUPS } from '@/cli/help'
|
|
4
|
+
import { listSkillsAt } from '@/claude/skills-list'
|
|
5
|
+
import { STAGES } from '@/gate/stages'
|
|
6
|
+
|
|
7
|
+
const MAX_TEXT = 140
|
|
8
|
+
const PLUGIN_SKILLS = 'claude/skills'
|
|
9
|
+
const INTERNAL_SKILLS = '.claude/skills'
|
|
10
|
+
|
|
11
|
+
export interface CatalogEntry {
|
|
12
|
+
readonly name: string
|
|
13
|
+
readonly text: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Catalog {
|
|
17
|
+
readonly skills: CatalogEntry[]
|
|
18
|
+
readonly internalSkills: CatalogEntry[]
|
|
19
|
+
readonly verbs: CatalogEntry[]
|
|
20
|
+
readonly hooks: CatalogEntry[]
|
|
21
|
+
readonly gate: CatalogEntry[]
|
|
22
|
+
/** Rows that exist but could not be described, so none drops out silently. */
|
|
23
|
+
readonly gaps: string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const firstSentence = (text: string): string =>
|
|
27
|
+
text.split(/\.\s/)[0]?.replace(/\.$/, '').slice(0, MAX_TEXT) ?? ''
|
|
28
|
+
|
|
29
|
+
function skillsAt(
|
|
30
|
+
root: string,
|
|
31
|
+
corpus: string,
|
|
32
|
+
gaps: string[],
|
|
33
|
+
): CatalogEntry[] {
|
|
34
|
+
const dir = join(root, corpus)
|
|
35
|
+
if (!existsSync(dir)) return []
|
|
36
|
+
|
|
37
|
+
const listed = listSkillsAt(dir)
|
|
38
|
+
const named = new Set(listed.map((skill) => skill.name))
|
|
39
|
+
const folders = readdirSync(dir, { withFileTypes: true })
|
|
40
|
+
.filter((entry) => entry.isDirectory())
|
|
41
|
+
.map((entry) => entry.name)
|
|
42
|
+
|
|
43
|
+
for (const folder of folders) {
|
|
44
|
+
if (!named.has(folder)) gaps.push(`${corpus}/${folder}: no SKILL.md`)
|
|
45
|
+
}
|
|
46
|
+
for (const skill of listed) {
|
|
47
|
+
if (!skill.description) gaps.push(`${corpus}/${skill.name}: no description`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return listed.map((skill) => ({
|
|
51
|
+
name: skill.name,
|
|
52
|
+
text: firstSentence(skill.description),
|
|
53
|
+
}))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hooksAt(root: string): CatalogEntry[] {
|
|
57
|
+
const dir = join(root, '.claude', 'hooks')
|
|
58
|
+
if (!existsSync(dir)) return []
|
|
59
|
+
|
|
60
|
+
return readdirSync(dir)
|
|
61
|
+
.filter((file) => file.endsWith('.sh'))
|
|
62
|
+
.sort()
|
|
63
|
+
.map((file) => {
|
|
64
|
+
const comment = readFileSync(join(dir, file), 'utf8')
|
|
65
|
+
.split('\n')
|
|
66
|
+
.find((line) => /^#\s*\S/.test(line) && !line.startsWith('#!'))
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
name: file,
|
|
70
|
+
text: (comment ?? '').replace(/^#\s*/, '').slice(0, MAX_TEXT),
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildCatalog(root: string): Catalog {
|
|
76
|
+
const gaps: string[] = []
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
skills: skillsAt(root, PLUGIN_SKILLS, gaps),
|
|
80
|
+
internalSkills: skillsAt(root, INTERNAL_SKILLS, gaps),
|
|
81
|
+
verbs: COMMAND_GROUPS.flatMap((group) => group.rows).map(
|
|
82
|
+
([signature, description]) => ({
|
|
83
|
+
name: `canon ${signature}`,
|
|
84
|
+
text: description,
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
hooks: hooksAt(root),
|
|
88
|
+
gate: STAGES.map((stage) => ({ name: stage.id, text: stage.label })),
|
|
89
|
+
gaps,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const section = (title: string, entries: readonly CatalogEntry[]): string[] => [
|
|
94
|
+
`### ${title}`,
|
|
95
|
+
...entries.map((entry) =>
|
|
96
|
+
entry.text ? `- ${entry.name}: ${entry.text}` : `- ${entry.name}`,
|
|
97
|
+
),
|
|
98
|
+
'',
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
export function renderCatalog(catalog: Catalog): string {
|
|
102
|
+
return [
|
|
103
|
+
...section('Plugin skills (claude/skills/)', catalog.skills),
|
|
104
|
+
...section('Internal skills (.claude/skills/)', catalog.internalSkills),
|
|
105
|
+
...section('CLI verbs', catalog.verbs),
|
|
106
|
+
...section('Hooks (.claude/hooks/)', catalog.hooks),
|
|
107
|
+
...section('Gate stages (canon gate run)', catalog.gate),
|
|
108
|
+
...(catalog.gaps.length > 0
|
|
109
|
+
? ['### Gaps', ...catalog.gaps.map((gap) => `- ${gap}`), '']
|
|
110
|
+
: []),
|
|
111
|
+
].join('\n')
|
|
112
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from 'node:fs'
|
|
8
|
+
import { dirname } from 'node:path'
|
|
9
|
+
import { recordDir } from '@/roots/record'
|
|
10
|
+
|
|
11
|
+
const FOLDER = 'upstream'
|
|
12
|
+
const VERSION = /^\d+(\.\d+)*$/
|
|
13
|
+
|
|
14
|
+
export interface Cursor {
|
|
15
|
+
readonly version: string
|
|
16
|
+
readonly date: string
|
|
17
|
+
readonly intake: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AdvanceInput {
|
|
21
|
+
readonly version: string
|
|
22
|
+
readonly intake: string
|
|
23
|
+
readonly today: string
|
|
24
|
+
/** The current `llms.txt`, or null to keep the copy already stored. */
|
|
25
|
+
readonly llms: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AdvanceOutcome =
|
|
29
|
+
| { readonly kind: 'advanced' }
|
|
30
|
+
| {
|
|
31
|
+
readonly kind: 'refused'
|
|
32
|
+
readonly reason: 'older-than-cursor' | 'invalid-version'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface LlmsDifference {
|
|
36
|
+
readonly added: string[]
|
|
37
|
+
readonly removed: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cursorPath = (root: string): string =>
|
|
41
|
+
recordDir(root, FOLDER, 'cursor.json')
|
|
42
|
+
|
|
43
|
+
const llmsPath = (root: string): string => recordDir(root, FOLDER, 'llms.txt')
|
|
44
|
+
|
|
45
|
+
export function readCursor(root: string): Cursor | null {
|
|
46
|
+
const path = cursorPath(root)
|
|
47
|
+
if (!existsSync(path)) return null
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(readFileSync(path, 'utf8')) as Cursor
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readLastLlms(root: string): string | null {
|
|
57
|
+
const path = llmsPath(root)
|
|
58
|
+
|
|
59
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function compareVersions(a: string, b: string): number {
|
|
63
|
+
const left = a.split('.').map(Number)
|
|
64
|
+
const right = b.split('.').map(Number)
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
67
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0)
|
|
68
|
+
if (diff !== 0) return diff
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// `fetch` reads this file, so a write that half-lands would leave a reader on a
|
|
75
|
+
// truncated cursor. A rename within one folder is atomic.
|
|
76
|
+
export async function writeAtomic(path: string, body: string): Promise<void> {
|
|
77
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
78
|
+
const temp = `${path}.${process.pid}.tmp`
|
|
79
|
+
writeFileSync(temp, body)
|
|
80
|
+
renameSync(temp, path)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function advanceCursor(
|
|
84
|
+
root: string,
|
|
85
|
+
input: AdvanceInput,
|
|
86
|
+
): Promise<AdvanceOutcome> {
|
|
87
|
+
if (!VERSION.test(input.version)) {
|
|
88
|
+
return { kind: 'refused', reason: 'invalid-version' }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const stored = readCursor(root)
|
|
92
|
+
if (stored && compareVersions(input.version, stored.version) < 0) {
|
|
93
|
+
return { kind: 'refused', reason: 'older-than-cursor' }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (input.llms !== null) await writeAtomic(llmsPath(root), input.llms)
|
|
97
|
+
const next: Cursor = {
|
|
98
|
+
version: input.version,
|
|
99
|
+
date: input.today,
|
|
100
|
+
intake: input.intake,
|
|
101
|
+
}
|
|
102
|
+
await writeAtomic(cursorPath(root), `${JSON.stringify(next, null, 2)}\n`)
|
|
103
|
+
|
|
104
|
+
return { kind: 'advanced' }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const lines = (text: string): string[] => text.split('\n').filter(Boolean)
|
|
108
|
+
|
|
109
|
+
export function llmsDifference(
|
|
110
|
+
last: string | null,
|
|
111
|
+
current: string,
|
|
112
|
+
): LlmsDifference {
|
|
113
|
+
const before = new Set(lines(last ?? ''))
|
|
114
|
+
const after = new Set(lines(current))
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
added: [...after].filter((line) => !before.has(line)),
|
|
118
|
+
removed: [...before].filter((line) => !after.has(line)),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
extractIdentifiers,
|
|
3
|
+
keepLines,
|
|
4
|
+
versionOf,
|
|
5
|
+
walkReleases,
|
|
6
|
+
type FetchFailure,
|
|
7
|
+
type PageFetcher,
|
|
8
|
+
type ReleaseLine,
|
|
9
|
+
} from '@/upstream/releases'
|
|
10
|
+
|
|
11
|
+
const FILE_HINTS = 3
|
|
12
|
+
|
|
13
|
+
export interface NamedIdentifier {
|
|
14
|
+
readonly name: string
|
|
15
|
+
readonly files: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DigestLine extends ReleaseLine {
|
|
19
|
+
readonly identifiers: NamedIdentifier[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type Digest =
|
|
23
|
+
| {
|
|
24
|
+
readonly kind: 'digest'
|
|
25
|
+
readonly from: string
|
|
26
|
+
readonly to: string
|
|
27
|
+
readonly releases: number
|
|
28
|
+
readonly lines: DigestLine[]
|
|
29
|
+
}
|
|
30
|
+
| { readonly kind: 'cursor-missing'; readonly cursor: string }
|
|
31
|
+
| {
|
|
32
|
+
readonly kind: 'failed'
|
|
33
|
+
readonly reason: FetchFailure
|
|
34
|
+
readonly message: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Files naming an identifier, supplied so a test needs no repository. */
|
|
38
|
+
export type Grep = (identifier: string) => Promise<string[]>
|
|
39
|
+
|
|
40
|
+
export async function buildDigest(
|
|
41
|
+
cursor: string,
|
|
42
|
+
fetchPage: PageFetcher,
|
|
43
|
+
grep: Grep,
|
|
44
|
+
): Promise<Digest> {
|
|
45
|
+
const walk = await walkReleases(cursor, fetchPage)
|
|
46
|
+
if (walk.kind !== 'reached') return walk
|
|
47
|
+
|
|
48
|
+
const oldestFirst = [...walk.releases].reverse()
|
|
49
|
+
const lines = await Promise.all(
|
|
50
|
+
oldestFirst.flatMap(keepLines).map(async (line) => ({
|
|
51
|
+
...line,
|
|
52
|
+
identifiers: await hintsFor(line.text, grep),
|
|
53
|
+
})),
|
|
54
|
+
)
|
|
55
|
+
const newest = walk.releases[0]
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
kind: 'digest',
|
|
59
|
+
from: cursor,
|
|
60
|
+
to: newest ? versionOf(newest.tag_name) : cursor,
|
|
61
|
+
releases: walk.releases.length,
|
|
62
|
+
lines,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function hintsFor(text: string, grep: Grep): Promise<NamedIdentifier[]> {
|
|
67
|
+
const found = await Promise.all(
|
|
68
|
+
extractIdentifiers(text).map(async (name) => ({
|
|
69
|
+
name,
|
|
70
|
+
files: (await grep(name)).slice(0, FILE_HINTS),
|
|
71
|
+
})),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return found.filter((entry) => entry.files.length > 0)
|
|
75
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { recordDir, SCRATCH } from '@/roots/record'
|
|
3
|
+
import { compareVersions, writeAtomic, type Cursor } from '@/upstream/cursor'
|
|
4
|
+
|
|
5
|
+
const DAY_MS = 86_400_000
|
|
6
|
+
const DUE_AFTER_DAYS = 7
|
|
7
|
+
|
|
8
|
+
// Ported from the groundwork's pre-filter script. It names canon's own surfaces,
|
|
9
|
+
// so an Added line matching it is one a digest would act on.
|
|
10
|
+
const VOCABULARY =
|
|
11
|
+
/plugin|skill|hook|SendMessage|ListAgents|agent view|agents view|worktree|subagent|background|routine|schedule|Monitor|mod\b|mods\b|CLAUDE\.md|rules|memory|permission|auto mode|stream-json|--bg|headless|-p\b|marketplace|session/i
|
|
12
|
+
const ADDED = /^Added/
|
|
13
|
+
const CACHE_MAX_AGE_MS = DAY_MS
|
|
14
|
+
|
|
15
|
+
/** What the gap between the cursor and the installed version holds. */
|
|
16
|
+
export interface Gap {
|
|
17
|
+
readonly releases: number
|
|
18
|
+
readonly lines: readonly string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DueInput {
|
|
22
|
+
/** The installed Claude Code version, or null when it could not be read. */
|
|
23
|
+
readonly installed: string | null
|
|
24
|
+
readonly cursor: Cursor | null
|
|
25
|
+
/** Null when the feed was not read, so only the age path can decide. */
|
|
26
|
+
readonly gap: Gap | null
|
|
27
|
+
readonly now: Date
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type DueReason =
|
|
31
|
+
| 'no-cursor'
|
|
32
|
+
| 'week'
|
|
33
|
+
| 'vocabulary'
|
|
34
|
+
| 'current'
|
|
35
|
+
| 'recent'
|
|
36
|
+
| 'unknown-version'
|
|
37
|
+
|
|
38
|
+
export interface DueResult {
|
|
39
|
+
readonly due: boolean
|
|
40
|
+
readonly reason: DueReason
|
|
41
|
+
/** Releases in the gap, or null when it was not read. */
|
|
42
|
+
readonly releases: number | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function passesVocabulary(line: string): boolean {
|
|
46
|
+
return ADDED.test(line) && VOCABULARY.test(line)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A cursor date is a calendar day with no time, read at UTC midnight. An
|
|
50
|
+
// unreadable one gives NaN, which fails the age test and leaves the vocabulary
|
|
51
|
+
// path to decide.
|
|
52
|
+
function ageInDays(date: string, now: Date): number {
|
|
53
|
+
return (now.getTime() - Date.parse(`${date}T00:00:00Z`)) / DAY_MS
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decides whether a digest is due.
|
|
58
|
+
*
|
|
59
|
+
* The age boundary is inclusive: a cursor exactly seven days old is due, since
|
|
60
|
+
* a week has passed. An installed version at or below the cursor is never due,
|
|
61
|
+
* which covers a downgrade as well as a run straight after a digest.
|
|
62
|
+
*/
|
|
63
|
+
export function decideDue(input: DueInput): DueResult {
|
|
64
|
+
const { installed, cursor, gap, now } = input
|
|
65
|
+
const releases = gap?.releases ?? null
|
|
66
|
+
|
|
67
|
+
if (cursor === null) return { due: true, reason: 'no-cursor', releases }
|
|
68
|
+
if (installed === null) {
|
|
69
|
+
return { due: false, reason: 'unknown-version', releases }
|
|
70
|
+
}
|
|
71
|
+
if (compareVersions(installed, cursor.version) <= 0) {
|
|
72
|
+
return { due: false, reason: 'current', releases }
|
|
73
|
+
}
|
|
74
|
+
if (ageInDays(cursor.date, now) >= DUE_AFTER_DAYS) {
|
|
75
|
+
return { due: true, reason: 'week', releases }
|
|
76
|
+
}
|
|
77
|
+
if (gap?.lines.some(passesVocabulary)) {
|
|
78
|
+
return { due: true, reason: 'vocabulary', releases }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { due: false, reason: 'recent', releases }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One reading of the feed, kept so a wave of sessions starting together spends
|
|
86
|
+
* one request rather than one each.
|
|
87
|
+
*/
|
|
88
|
+
export interface DueCache {
|
|
89
|
+
readonly checkedAt: string
|
|
90
|
+
readonly installed: string
|
|
91
|
+
readonly cursor: string
|
|
92
|
+
/** Null when the read failed, so a failure is held for the day too. */
|
|
93
|
+
readonly gap: Gap | null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Scratch rather than the upstream record folder, which a records push backs
|
|
97
|
+
// whole. A file rewritten daily would add a commit to the history each time.
|
|
98
|
+
const cachePath = (root: string): string =>
|
|
99
|
+
recordDir(root, SCRATCH, 'upstream-due', 'check.json')
|
|
100
|
+
|
|
101
|
+
/** The stored reading when it is fresh and answers for this pair of versions. */
|
|
102
|
+
export function readDueCache(
|
|
103
|
+
root: string,
|
|
104
|
+
installed: string,
|
|
105
|
+
cursor: string,
|
|
106
|
+
now: Date,
|
|
107
|
+
): DueCache | null {
|
|
108
|
+
const path = cachePath(root)
|
|
109
|
+
if (!existsSync(path)) return null
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const cache = JSON.parse(readFileSync(path, 'utf8')) as DueCache
|
|
113
|
+
const age = now.getTime() - Date.parse(cache.checkedAt)
|
|
114
|
+
const isFresh = age >= 0 && age < CACHE_MAX_AGE_MS
|
|
115
|
+
const isSamePair = cache.installed === installed && cache.cursor === cursor
|
|
116
|
+
|
|
117
|
+
return isFresh && isSamePair ? cache : null
|
|
118
|
+
} catch {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function writeDueCache(
|
|
124
|
+
root: string,
|
|
125
|
+
cache: DueCache,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
await writeAtomic(cachePath(root), `${JSON.stringify(cache)}\n`)
|
|
128
|
+
}
|