@erclx/canon 5.40.0 → 5.41.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 +34 -17
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/REQUIREMENT.md +1 -0
- package/claude/skills/auto-ship/SKILL.md +6 -0
- package/claude/skills/auto-ship/references/fan-in.md +59 -0
- package/claude/skills/create-skill/references/skill.md +1 -1
- package/claude/skills/decision-escalate/REQUIREMENT.md +1 -1
- package/claude/skills/decision-escalate/SKILL.md +1 -2
- package/claude/skills/git-branch/REQUIREMENT.md +17 -6
- package/claude/skills/git-branch/SKILL.md +63 -7
- package/claude/skills/plan-feature/references/batches.md +48 -1
- package/claude/skills/plan-feature/references/plan.md +2 -0
- package/claude/skills/plan-intake-answer/REQUIREMENT.md +1 -1
- package/claude/skills/plan-intake-answer/SKILL.md +1 -2
- package/claude/skills/review-address/references/rebase-conflicts.md +7 -0
- package/claude/skills/review-ui/SKILL.md +2 -0
- package/claude/skills/review-ui/references/close-out.md +47 -0
- package/claude/skills/review-ui/references/post.md +3 -1
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -0
- package/claude/skills/role-worker/REQUIREMENT.md +1 -0
- package/claude/skills/role-worker/SKILL.md +9 -0
- package/claude/skills/teach-workspace/REQUIREMENT.md +1 -1
- package/claude/skills/teach-workspace/SKILL.md +1 -2
- package/docs/workflow/parallel-features.md +1 -0
- package/docs/workflow/skill-map.md +1 -1
- package/package.json +1 -1
- package/src/claude/cases/git.ts +4 -0
- package/src/commands/pr.ts +32 -9
- package/src/commands/records.ts +11 -3
- package/src/context/shape-gated.ts +1 -0
- package/src/markdown/scope.ts +6 -2
- package/src/pr/frames.ts +53 -9
- package/src/records/validate.ts +1 -0
- package/src/tasks/answers.ts +79 -2
- package/src/tasks/fan-in.ts +176 -0
- package/src/tasks/stack.ts +1 -1
- package/tooling/astro/reference.md +80 -39
- package/tooling/base/reference.md +63 -32
- package/tooling/base/seeds/canon/context/ci.md +7 -15
- package/tooling/base/seeds/canon/context/development.md +14 -14
- package/tooling/claude/reference.md +40 -22
- package/tooling/cloudflare/reference.md +31 -12
- package/tooling/go/reference.md +47 -23
- package/tooling/nextjs/reference.md +52 -19
- package/tooling/php/reference.md +35 -16
- package/tooling/python/reference.md +47 -30
- package/tooling/shell/reference.md +6 -5
- package/tooling/vite-react/reference.md +49 -21
- package/tooling/web/reference.md +130 -70
|
@@ -61,6 +61,14 @@ Announce the pull request as the ship chain's pull request step returns, carryin
|
|
|
61
61
|
- Say the reach was unread rather than clear when the installed binary carries no such subcommand, and leave the undeclared list to the pull request
|
|
62
62
|
- Carry a row this build turned up with its title, origin, and surface. Send it alone only when the session ends before a pull request opens.
|
|
63
63
|
|
|
64
|
+
### A fan-in member
|
|
65
|
+
|
|
66
|
+
A member of a fan-in set opens no pull request, so its done message stands in for the announcement. `auto-ship` sends it after the push.
|
|
67
|
+
|
|
68
|
+
- Carry the branch, the head commit, and the integrator the plan names in place of a pull request number
|
|
69
|
+
- Carry no reach, since the integrator's announcement carries it once for the whole set
|
|
70
|
+
- An integrator is an ordinary worker and owes the announcement above
|
|
71
|
+
|
|
64
72
|
### Re-review and blocks
|
|
65
73
|
|
|
66
74
|
Ask for the re-review as `review-address` Step 8 returns, carrying what was addressed and the PR's new CI state. That transition is the other moment only this session knows, and the message starts the re-review.
|
|
@@ -117,5 +125,6 @@ Never concede on the name alone or proceed as if the sets never collided. A work
|
|
|
117
125
|
## Lifetime
|
|
118
126
|
|
|
119
127
|
- The role runs from the worktree entry to the merge of the branch it built, and nothing carries to a second row, which is a new build under a new plan
|
|
128
|
+
- A fan-in member's role ends at its push, since the integrator merges the branch and fixes on its own branch whatever the merge surfaces
|
|
120
129
|
- A talk session outlives the merge and ends when the operator says the conversation is over
|
|
121
130
|
- Treat this body as possibly older than the branch under it. A plugin skill loads from the marketplace cache, so a session changing its own skills may hold the copy from before the last update.
|
|
@@ -62,4 +62,4 @@ Two failures land specifically on where the folder sits. A workspace resolved ag
|
|
|
62
62
|
- Creating a wiki folder a project never chose
|
|
63
63
|
- What the verbs do internally, which is the CLI domain's own contract rather than this skill's, and the records backup that carries the folder off one disk
|
|
64
64
|
- What a lesson renders as and how a quiz behaves once rendered, which the lesson-craft reference shapes and a rendering layer executes
|
|
65
|
-
- Deciding when to fire. The
|
|
65
|
+
- Deciding when to fire. The description states `Do NOT auto-trigger`, so opening a workspace is the learner's call rather than a description match.
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: teach-workspace
|
|
3
|
-
description: Opens and runs a learning workspace on one subject, holding a mission, resources, numbered lessons, reference pages, a glossary, and learning records that survive across sessions, proposes where a durable page from one belongs once it outgrows the workspace, and lands the confirmed pages from a branch. Use when asked to "teach me X", "open a learning workspace", "I want to learn X", "quiz me on this", "continue the lesson", "resume my workspace on X", "promote this reference page", or "fold the promoted pages in". Do NOT use to write project documentation, which belongs to the surface owning that document, and do NOT use to answer one question, which is an ordinary reply.
|
|
4
|
-
disable-model-invocation: true
|
|
3
|
+
description: Opens and runs a learning workspace on one subject, holding a mission, resources, numbered lessons, reference pages, a glossary, and learning records that survive across sessions, proposes where a durable page from one belongs once it outgrows the workspace, and lands the confirmed pages from a branch. Use when asked to "teach me X", "open a learning workspace", "I want to learn X", "quiz me on this", "continue the lesson", "resume my workspace on X", "promote this reference page", or "fold the promoted pages in". Do NOT use to write project documentation, which belongs to the surface owning that document, and do NOT use to answer one question, which is an ordinary reply. Do NOT auto-trigger.
|
|
5
4
|
argument-hint: <subject to learn, the topic of a workspace to resume or promote, or fold>
|
|
6
5
|
metadata:
|
|
7
6
|
family: answer
|
|
@@ -41,6 +41,7 @@ Assert the orchestrator role in one warm session with `canon:role-orchestrator`.
|
|
|
41
41
|
|
|
42
42
|
Merge order matters only when two branches touch the same file. Expect to rebase over `CLAUDE.md`, `tooling/**`, `canon/context/context-model.md`, `docs/agents/`, and any folder's `index.md`. Resolve an `index.md` conflict with `canon indexes regen` over the rebased tree.
|
|
43
43
|
|
|
44
|
+
- **Fan-in.** Batches of one change sharing edge files ship as one pull request, opened by an integrator that merges each member branch. See `claude/skills/plan-feature/references/batches.md`.
|
|
44
45
|
- **Merge order.** Merge the smallest hotspot footprint first, and a branch touching `CLAUDE.md` or a context entry last.
|
|
45
46
|
- **Rebase.** The branch merging second runs `/review-address`, which trial-merges against `origin/main` and rebases. Push, then merge.
|
|
46
47
|
- **Clean up.** Run `/git-worktree cleanup` to remove merged worktrees. To start fresh, `ExitWorktree(action: "keep")`, then `/session-worktree <new-name>`.
|
|
@@ -67,7 +67,7 @@ Groups run in the order a project meets them. Every name `canon claude skills li
|
|
|
67
67
|
| `canon:docs-sync` | A change left `README.md` or `docs/` stale |
|
|
68
68
|
| `canon:git-stage` | Staged changes span several concerns |
|
|
69
69
|
| `canon:git-commit` | Staged changes are one concern |
|
|
70
|
-
| `canon:git-branch` | A branch
|
|
70
|
+
| `canon:git-branch` | A branch needs creating, naming, or renaming |
|
|
71
71
|
| `canon:git-pr` | A pull request needs a title and body |
|
|
72
72
|
|
|
73
73
|
## After the pull request opens
|
package/package.json
CHANGED
package/src/claude/cases/git.ts
CHANGED
|
@@ -6,6 +6,10 @@ export const GIT_CASES: readonly SkillCase[] = [
|
|
|
6
6
|
prompt: 'Come up with a conventional name for this branch.',
|
|
7
7
|
expect: 'git-branch',
|
|
8
8
|
},
|
|
9
|
+
{
|
|
10
|
+
prompt: 'I am on main and about to start a feature, create its branch.',
|
|
11
|
+
expect: 'git-branch',
|
|
12
|
+
},
|
|
9
13
|
{
|
|
10
14
|
prompt: "Turn what's staged into one commit message.",
|
|
11
15
|
expect: 'git-commit',
|
package/src/commands/pr.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
pruneFramesWithLegacy,
|
|
58
58
|
pushFrame,
|
|
59
59
|
readFrame,
|
|
60
|
+
referenceSlug,
|
|
60
61
|
type TreeEntry,
|
|
61
62
|
} from '@/pr/frames'
|
|
62
63
|
import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
|
|
@@ -184,6 +185,7 @@ const LOCAL_REFUSALS: Record<LocalRefusal | 'gh-failed', string> = {
|
|
|
184
185
|
interface FramesOptions extends ReadOptions {
|
|
185
186
|
readonly add?: string
|
|
186
187
|
readonly box?: string
|
|
188
|
+
readonly reference?: string
|
|
187
189
|
readonly head?: string
|
|
188
190
|
readonly pass?: string
|
|
189
191
|
readonly theme?: string
|
|
@@ -196,6 +198,7 @@ type FramesCommandRefusal =
|
|
|
196
198
|
| 'bad-mode'
|
|
197
199
|
| 'no-number'
|
|
198
200
|
| 'bad-box'
|
|
201
|
+
| 'bad-reference'
|
|
199
202
|
| 'bad-head'
|
|
200
203
|
| 'bad-pass'
|
|
201
204
|
| 'bad-theme'
|
|
@@ -212,13 +215,15 @@ const FRAMES_REFUSALS: Record<FramesCommandRefusal, string> = {
|
|
|
212
215
|
'no-number':
|
|
213
216
|
'--add and --drop write for one pull request, so name its number.',
|
|
214
217
|
'bad-box':
|
|
215
|
-
'--add takes --box <n>, a positive whole number naming the checklist box.',
|
|
218
|
+
'--add takes --box <n>, a positive whole number naming the checklist box, or --reference <page>/<frame>, never both.',
|
|
219
|
+
'bad-reference':
|
|
220
|
+
'--reference takes <page>/<frame>, each half opening on a letter or digit and holding only letters, digits, dots, underscores, and dashes, with no `--` in either, so the capture files under a name that reads back as the frame.',
|
|
216
221
|
'bad-head':
|
|
217
222
|
'--head takes a commit sha, 7 to 40 lowercase hex characters. A branch name or a path would file the frame under a folder that names no commit.',
|
|
218
223
|
'bad-pass':
|
|
219
224
|
'--add takes --pass <stamp>, the instant the pass started in compact UTC such as 20261002T154450Z, so a second pass never replaces an earlier one.',
|
|
220
225
|
'bad-theme':
|
|
221
|
-
'--theme takes dark or light and goes with --add, so a second shot of one box never replaces the first.',
|
|
226
|
+
'--theme takes dark or light and goes with --add --box, so a second shot of one box never replaces the first.',
|
|
222
227
|
'bad-days': '--prune takes a positive whole number of days.',
|
|
223
228
|
'unreadable-frame':
|
|
224
229
|
'The file named by --add is missing, empty, or not a PNG, so it would embed as a broken image. Nothing is pushed.',
|
|
@@ -1011,6 +1016,10 @@ export function register(program: Command): void {
|
|
|
1011
1016
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
1012
1017
|
.option('--add <file>', 'Push this PNG as a frame')
|
|
1013
1018
|
.option('--box <n>', 'The checklist box the frame shows, with --add')
|
|
1019
|
+
.option(
|
|
1020
|
+
'--reference <address>',
|
|
1021
|
+
'The design reference the capture shows, as <page>/<frame>, with --add in place of --box',
|
|
1022
|
+
)
|
|
1014
1023
|
.option(
|
|
1015
1024
|
'--head <sha>',
|
|
1016
1025
|
"The head the frame was driven at, with --add, defaulting to the pull request's",
|
|
@@ -1035,7 +1044,8 @@ export function register(program: Command): void {
|
|
|
1035
1044
|
`An add writes only refs/heads/${FRAMES_BRANCH}, through the GitHub git data API,`,
|
|
1036
1045
|
'so nothing is checked out and no other branch is touched. A frame lands',
|
|
1037
1046
|
'at pr-<number>/<short-head>/<pass>/box-<n>.png, or box-<n>-<theme>.png',
|
|
1038
|
-
'with --theme,
|
|
1047
|
+
'with --theme, or ref-<page>--<frame>.png for a design reference capture',
|
|
1048
|
+
'passed as --reference <page>/<frame> in place of --box, and `link` names that',
|
|
1039
1049
|
`path on ${FRAMES_BRANCH}, so it survives a rewrite that drops other pull`,
|
|
1040
1050
|
'requests and resolves until its own pull request is dropped. The pass',
|
|
1041
1051
|
'segment keeps a second pass at one head and box from replacing an image',
|
|
@@ -1056,10 +1066,11 @@ export function register(program: Command): void {
|
|
|
1056
1066
|
' ok an add pushed, or a drop or prune finished',
|
|
1057
1067
|
' bad-mode not exactly one of --add, --drop, --prune',
|
|
1058
1068
|
' no-number --add or --drop named no pull request',
|
|
1059
|
-
' bad-box --add
|
|
1069
|
+
' bad-box --add with both --box and --reference, or neither',
|
|
1070
|
+
' bad-reference --reference is not <page>/<frame> in the safe name form',
|
|
1060
1071
|
' bad-head --head is not 7 to 40 lowercase hex characters',
|
|
1061
1072
|
' bad-pass --add without a compact UTC --pass stamp',
|
|
1062
|
-
' bad-theme --theme is not dark or light, or no --add',
|
|
1073
|
+
' bad-theme --theme is not dark or light, or no --add, or rides a --reference',
|
|
1063
1074
|
' bad-days --prune without a positive number of days',
|
|
1064
1075
|
' unreadable-frame the --add file is missing, empty, or not a PNG',
|
|
1065
1076
|
' read-only GitHub refused the write, as on a fork token',
|
|
@@ -3084,10 +3095,18 @@ function framesArgumentRefusal(
|
|
|
3084
3095
|
if (number === undefined || !POSITIVE_WHOLE.test(number)) return 'no-number'
|
|
3085
3096
|
if (
|
|
3086
3097
|
opts.add !== undefined &&
|
|
3087
|
-
(opts.box === undefined
|
|
3098
|
+
(opts.box === undefined) === (opts.reference === undefined)
|
|
3088
3099
|
) {
|
|
3089
3100
|
return 'bad-box'
|
|
3090
3101
|
}
|
|
3102
|
+
if (opts.add !== undefined && opts.box !== undefined) {
|
|
3103
|
+
if (!POSITIVE_WHOLE.test(opts.box)) return 'bad-box'
|
|
3104
|
+
}
|
|
3105
|
+
if (opts.reference !== undefined) {
|
|
3106
|
+
if (opts.add === undefined || referenceSlug(opts.reference) === undefined) {
|
|
3107
|
+
return 'bad-reference'
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3091
3110
|
if (opts.head !== undefined && !isFrameHead(opts.head)) return 'bad-head'
|
|
3092
3111
|
if (
|
|
3093
3112
|
opts.add !== undefined &&
|
|
@@ -3097,7 +3116,9 @@ function framesArgumentRefusal(
|
|
|
3097
3116
|
}
|
|
3098
3117
|
if (
|
|
3099
3118
|
opts.theme !== undefined &&
|
|
3100
|
-
(opts.add === undefined ||
|
|
3119
|
+
(opts.add === undefined ||
|
|
3120
|
+
opts.reference !== undefined ||
|
|
3121
|
+
!isFrameTheme(opts.theme))
|
|
3101
3122
|
) {
|
|
3102
3123
|
return 'bad-theme'
|
|
3103
3124
|
}
|
|
@@ -3199,7 +3220,8 @@ async function runFrames(
|
|
|
3199
3220
|
if (head === undefined || head === '') return refuseFrames('no-object-head')
|
|
3200
3221
|
if (!isFrameHead(head)) return refuseFrames('bad-head')
|
|
3201
3222
|
|
|
3202
|
-
const box = Number(opts.box)
|
|
3223
|
+
const box = opts.box === undefined ? undefined : Number(opts.box)
|
|
3224
|
+
const reference = opts.reference
|
|
3203
3225
|
const pass = opts.pass ?? ''
|
|
3204
3226
|
const theme = opts.theme as FrameTheme | undefined
|
|
3205
3227
|
const result = await pushFrame(api, {
|
|
@@ -3207,6 +3229,7 @@ async function runFrames(
|
|
|
3207
3229
|
head,
|
|
3208
3230
|
pass,
|
|
3209
3231
|
box,
|
|
3232
|
+
reference,
|
|
3210
3233
|
theme,
|
|
3211
3234
|
bytes: bytes ?? new Uint8Array(),
|
|
3212
3235
|
})
|
|
@@ -3215,7 +3238,7 @@ async function runFrames(
|
|
|
3215
3238
|
return finish(
|
|
3216
3239
|
{
|
|
3217
3240
|
number: pull,
|
|
3218
|
-
box,
|
|
3241
|
+
...(box === undefined ? { reference } : { box }),
|
|
3219
3242
|
...(theme === undefined ? {} : { theme }),
|
|
3220
3243
|
head,
|
|
3221
3244
|
pass,
|
package/src/commands/records.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
plural,
|
|
45
45
|
} from '@/cli/ui'
|
|
46
46
|
import { currentWorktreeRoot, mainWorktreeRoot } from '@/git/worktree'
|
|
47
|
+
import { fanInFindings } from '@/tasks/fan-in'
|
|
47
48
|
import { readLivePlans, stackFindings } from '@/tasks/stack'
|
|
48
49
|
|
|
49
50
|
/** Returned when a record carries a finding, which is the gating result. */
|
|
@@ -742,7 +743,7 @@ async function runValidate(
|
|
|
742
743
|
}
|
|
743
744
|
|
|
744
745
|
/**
|
|
745
|
-
* Appends the
|
|
746
|
+
* Appends the plans findings that read across files. They sit in the command
|
|
746
747
|
* layer because `src/records/` sits below `src/tasks/`.
|
|
747
748
|
*/
|
|
748
749
|
async function validateWithStacks(
|
|
@@ -752,9 +753,16 @@ async function validateWithStacks(
|
|
|
752
753
|
const outcome = await validateRecords(root, kind)
|
|
753
754
|
if (!outcome.ok || kind !== 'plans') return outcome
|
|
754
755
|
|
|
755
|
-
const
|
|
756
|
+
const live = await readLivePlans(root)
|
|
756
757
|
|
|
757
|
-
return {
|
|
758
|
+
return {
|
|
759
|
+
...outcome,
|
|
760
|
+
findings: [
|
|
761
|
+
...outcome.findings,
|
|
762
|
+
...stackFindings(live),
|
|
763
|
+
...fanInFindings(live),
|
|
764
|
+
],
|
|
765
|
+
}
|
|
758
766
|
}
|
|
759
767
|
|
|
760
768
|
function report(
|
package/src/markdown/scope.ts
CHANGED
|
@@ -128,6 +128,10 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
128
128
|
'claude/skills/youtube-transcripts/SKILL.md',
|
|
129
129
|
'.claude/skills/internal-',
|
|
130
130
|
'docs/',
|
|
131
|
+
'tooling/',
|
|
132
|
+
'wiki/',
|
|
133
|
+
'README.md',
|
|
134
|
+
'CONTRIBUTING.md',
|
|
131
135
|
]
|
|
132
136
|
|
|
133
137
|
/**
|
|
@@ -136,8 +140,8 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
136
140
|
* entries, the docs pages, and the skill bodies and references through a stage
|
|
137
141
|
* of its own before this measure existed, and waiting on `GATED_PATHS` would
|
|
138
142
|
* reopen that hole. The seeds, the stack references, the wiki, and the root
|
|
139
|
-
* guides were never held to it,
|
|
140
|
-
*
|
|
143
|
+
* guides were never held to it, so they joined `GATED_PATHS` only once their
|
|
144
|
+
* rewrite brought them to zero.
|
|
141
145
|
*/
|
|
142
146
|
const ALWAYS_GATED: readonly DensityKind[] = ['bullet-run']
|
|
143
147
|
|
package/src/pr/frames.ts
CHANGED
|
@@ -19,6 +19,9 @@ const PASS_PATTERN = /^\d{8}T\d{6}Z$/
|
|
|
19
19
|
/** How many times an add rebuilds on a tip another writer moved. */
|
|
20
20
|
const ADD_ATTEMPTS = 3
|
|
21
21
|
|
|
22
|
+
/** One half of a reference address. It opens on a letter or digit and holds no separator, so `..` and `/` cannot survive it. */
|
|
23
|
+
const REFERENCE_PART = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
24
|
+
|
|
22
25
|
const FOLDER_PATTERN = /^pr-(\d+)$/
|
|
23
26
|
|
|
24
27
|
const DAY_MS = 86_400_000
|
|
@@ -77,7 +80,8 @@ export interface FrameInput {
|
|
|
77
80
|
readonly number: number
|
|
78
81
|
readonly head: string
|
|
79
82
|
readonly pass: string
|
|
80
|
-
readonly box
|
|
83
|
+
readonly box?: number
|
|
84
|
+
readonly reference?: string
|
|
81
85
|
readonly theme?: FrameTheme
|
|
82
86
|
readonly bytes: Uint8Array
|
|
83
87
|
}
|
|
@@ -158,6 +162,39 @@ export function frameLink(repo: string, path: string): string {
|
|
|
158
162
|
return `https://github.com/${repo}/blob/${FRAMES_BRANCH}/${path}?raw=true`
|
|
159
163
|
}
|
|
160
164
|
|
|
165
|
+
/**
|
|
166
|
+
* The file-name slug of a `<page>/<frame>` address, or undefined when the
|
|
167
|
+
* address would not round-trip. Each half must open on a letter or digit and
|
|
168
|
+
* hold only letters, digits, dots, underscores, and dashes, and neither may
|
|
169
|
+
* carry `--`, so the joined slug reads back as the same two names and a path
|
|
170
|
+
* never gains a directory or escapes the pass folder.
|
|
171
|
+
*/
|
|
172
|
+
export function referenceSlug(address: string): string | undefined {
|
|
173
|
+
const parts = address.split('/')
|
|
174
|
+
if (parts.length !== 2) return undefined
|
|
175
|
+
const [page, frame] = parts as [string, string]
|
|
176
|
+
const valid = [page, frame].every(
|
|
177
|
+
(part) => REFERENCE_PART.test(part) && !part.includes('--'),
|
|
178
|
+
)
|
|
179
|
+
return valid ? `${page}--${frame}` : undefined
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Where a design reference capture lands, beside the evidence frames of one
|
|
184
|
+
* pass. The slug repeats for one reference, so a second capture in a pass
|
|
185
|
+
* overwrites only itself. Undefined when the address has no valid slug.
|
|
186
|
+
*/
|
|
187
|
+
export function referencePath(
|
|
188
|
+
number: number,
|
|
189
|
+
head: string,
|
|
190
|
+
pass: string,
|
|
191
|
+
address: string,
|
|
192
|
+
): string | undefined {
|
|
193
|
+
const slug = referenceSlug(address)
|
|
194
|
+
if (slug === undefined) return undefined
|
|
195
|
+
return `pr-${number}/${head.slice(0, SHORT_HEAD_LENGTH)}/${pass}/ref-${slug}.png`
|
|
196
|
+
}
|
|
197
|
+
|
|
161
198
|
/** The frame's bytes, or undefined when the file is absent, empty, or not a PNG. */
|
|
162
199
|
export async function readFrame(path: string): Promise<Uint8Array | undefined> {
|
|
163
200
|
const bytes = await readFile(path).catch(() => undefined)
|
|
@@ -179,17 +216,24 @@ export async function pushFrame(
|
|
|
179
216
|
api: FramesApi,
|
|
180
217
|
input: FrameInput,
|
|
181
218
|
): Promise<PushResult> {
|
|
182
|
-
const path =
|
|
183
|
-
input.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
219
|
+
const path =
|
|
220
|
+
input.reference === undefined
|
|
221
|
+
? framePath(
|
|
222
|
+
input.number,
|
|
223
|
+
input.head,
|
|
224
|
+
input.pass,
|
|
225
|
+
input.box ?? 0,
|
|
226
|
+
input.theme,
|
|
227
|
+
)
|
|
228
|
+
: referencePath(input.number, input.head, input.pass, input.reference)
|
|
229
|
+
if (path === undefined) return { kind: 'refused', reason: 'push-failed' }
|
|
189
230
|
const blob = await api.createBlob(Buffer.from(input.bytes).toString('base64'))
|
|
190
231
|
if (blob === undefined) return { kind: 'refused', reason: 'push-failed' }
|
|
191
232
|
const entry: TreeEntry = { path, mode: '100644', type: 'blob', sha: blob }
|
|
192
|
-
const message =
|
|
233
|
+
const message =
|
|
234
|
+
input.reference === undefined
|
|
235
|
+
? `chore(frames): add box ${input.box} of #${input.number}`
|
|
236
|
+
: `chore(frames): add reference ${input.reference} of #${input.number}`
|
|
193
237
|
|
|
194
238
|
for (let attempt = 0; attempt < ADD_ATTEMPTS; attempt += 1) {
|
|
195
239
|
const tip = await api.readTip()
|
package/src/records/validate.ts
CHANGED
package/src/tasks/answers.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
3
|
import { basename, isAbsolute, relative, resolve } from 'node:path'
|
|
4
|
+
import { branchRefs } from '@/git/worktree'
|
|
5
|
+
import { fanInBranch, fanInFindings, readFanInMembers } from '@/tasks/fan-in'
|
|
4
6
|
import { isUnder } from '@/tasks/paths'
|
|
5
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
JUDGED_APART_REMEDY,
|
|
9
|
+
readLivePlans,
|
|
10
|
+
type StackPlan,
|
|
11
|
+
stackPairs,
|
|
12
|
+
} from '@/tasks/stack'
|
|
6
13
|
import { recordDir, recordDirs } from '@/roots/record'
|
|
7
14
|
import {
|
|
8
15
|
hasStagedBatches,
|
|
@@ -49,6 +56,28 @@ export interface PlanAnswers {
|
|
|
49
56
|
|
|
50
57
|
export type AnswersOutcome = PlanAnswers | AnswersRefused
|
|
51
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Whether `refs/remotes/origin/<branch>` exists, or `null` when the read
|
|
61
|
+
* itself failed. Injected so the gate is testable without a repository.
|
|
62
|
+
*/
|
|
63
|
+
export type RemoteBranchReader = (
|
|
64
|
+
root: string,
|
|
65
|
+
branch: string,
|
|
66
|
+
) => Promise<boolean | null>
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reads the remote-tracking ref as the last fetch left it, so a member pushed
|
|
70
|
+
* since then reads as absent and holds its integrator, which fails safe. The
|
|
71
|
+
* local head is not read, since it exists mid-build and a pushed branch is the
|
|
72
|
+
* only signal a member writes when it is done.
|
|
73
|
+
*/
|
|
74
|
+
export const originBranchPushed: RemoteBranchReader = async (root, branch) => {
|
|
75
|
+
const report = await branchRefs(branch, root)
|
|
76
|
+
if (!report.readable) return null
|
|
77
|
+
|
|
78
|
+
return report.refs.includes(`refs/remotes/origin/${branch}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
52
81
|
/**
|
|
53
82
|
* A reference that named a live plan, carrying the absolute path a reader opens
|
|
54
83
|
* and the root-relative spelling a record reports.
|
|
@@ -217,6 +246,7 @@ export function resolvePlanReference(
|
|
|
217
246
|
export async function planAnswers(
|
|
218
247
|
root: string,
|
|
219
248
|
reference: string,
|
|
249
|
+
readRemoteBranch: RemoteBranchReader = originBranchPushed,
|
|
220
250
|
): Promise<AnswersOutcome> {
|
|
221
251
|
const resolved = resolvePlanReference(root, reference)
|
|
222
252
|
if (!resolved.ok) return resolved
|
|
@@ -233,7 +263,8 @@ export async function planAnswers(
|
|
|
233
263
|
}
|
|
234
264
|
|
|
235
265
|
const stem = basename(resolved.path, '.md')
|
|
236
|
-
const
|
|
266
|
+
const live = await readLivePlans(root)
|
|
267
|
+
const pairs = stackPairs(live)
|
|
237
268
|
|
|
238
269
|
for (const { child, parent, shared } of pairs) {
|
|
239
270
|
if (child !== stem) continue
|
|
@@ -244,6 +275,8 @@ export async function planAnswers(
|
|
|
244
275
|
})
|
|
245
276
|
}
|
|
246
277
|
|
|
278
|
+
open.push(...(await fanInEntries(root, stem, text, live, readRemoteBranch)))
|
|
279
|
+
|
|
247
280
|
return {
|
|
248
281
|
ok: true,
|
|
249
282
|
plan: resolved.plan,
|
|
@@ -252,6 +285,50 @@ export async function planAnswers(
|
|
|
252
285
|
}
|
|
253
286
|
}
|
|
254
287
|
|
|
288
|
+
/**
|
|
289
|
+
* A plan whose fan-in set disagrees holds on `Fan-in set`, on either side,
|
|
290
|
+
* since an integrator listing an archived member would otherwise read that
|
|
291
|
+
* member's leftover branch as done. An integrator whose set agrees holds on
|
|
292
|
+
* `Fan-in pending` until every member branch is on origin.
|
|
293
|
+
*/
|
|
294
|
+
async function fanInEntries(
|
|
295
|
+
root: string,
|
|
296
|
+
stem: string,
|
|
297
|
+
text: string,
|
|
298
|
+
live: readonly StackPlan[],
|
|
299
|
+
readRemoteBranch: RemoteBranchReader,
|
|
300
|
+
): Promise<OpenQuestion[]> {
|
|
301
|
+
const entries: OpenQuestion[] = fanInFindings(live)
|
|
302
|
+
.filter(({ record }) => record === `${stem}.md`)
|
|
303
|
+
.map(({ message }) => ({ label: 'Fan-in set', why: message }))
|
|
304
|
+
|
|
305
|
+
const branches = readFanInMembers(text).map(fanInBranch)
|
|
306
|
+
if (branches.length === 0 || entries.length > 0) return entries
|
|
307
|
+
|
|
308
|
+
const pushed = await Promise.all(
|
|
309
|
+
branches.map((branch) => readRemoteBranch(root, branch)),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
if (pushed.includes(null)) {
|
|
313
|
+
return [
|
|
314
|
+
{
|
|
315
|
+
label: 'Fan-in pending',
|
|
316
|
+
why: 'git could not read the member branches on origin, so the set is unread rather than done.',
|
|
317
|
+
},
|
|
318
|
+
]
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const absent = branches.filter((_, index) => pushed[index] !== true)
|
|
322
|
+
if (absent.length === 0) return []
|
|
323
|
+
|
|
324
|
+
return [
|
|
325
|
+
{
|
|
326
|
+
label: 'Fan-in pending',
|
|
327
|
+
why: `waits on ${absent.map((branch) => `\`${branch}\``).join(', ')} reaching origin. Fetch, then read again.`,
|
|
328
|
+
},
|
|
329
|
+
]
|
|
330
|
+
}
|
|
331
|
+
|
|
255
332
|
function refuse(
|
|
256
333
|
reason: AnswerRefusal,
|
|
257
334
|
message: string,
|