@erclx/aitk 0.104.0 → 0.105.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/claude-docs/SKILL.md +4 -0
- package/claude/skills/claude-groundwork/SKILL.md +3 -2
- package/claude/skills/claude-worktree/SKILL.md +6 -1
- package/docs/agents/commands.md +2 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/tasks.md +28 -1
- package/docs/agents/test-order.md +56 -0
- package/docs/ai-workflow.md +4 -2
- package/governance/rules/core/070-planning.md +1 -0
- package/package.json +1 -1
- package/src/commands/gov.ts +135 -0
- package/src/commands/tasks.ts +108 -1
- package/src/gov/test-order.ts +383 -0
- package/src/tasks/archive.ts +158 -9
- package/src/tasks/trunk.ts +89 -0
- package/src/tasks/validate.ts +100 -29
- package/standards/groundwork.md +4 -0
- package/standards/tasks.md +15 -3
- package/tooling/astro/manifest.toml +5 -5
- package/tooling/vite-react/manifest.toml +4 -4
- package/tooling/web/configs/scripts/worktree-port.sh +44 -6
- package/tooling/web/manifest.toml +3 -3
- package/tooling/web/reference.md +4 -1
package/src/tasks/validate.ts
CHANGED
|
@@ -5,8 +5,10 @@ import {
|
|
|
5
5
|
archiveDir,
|
|
6
6
|
isReservedStem,
|
|
7
7
|
readOutcomes,
|
|
8
|
+
readPullRequest,
|
|
8
9
|
tasksDir,
|
|
9
10
|
} from '@/tasks/archive'
|
|
11
|
+
import { gitTrunkReader, type TrunkReader } from '@/tasks/trunk'
|
|
10
12
|
|
|
11
13
|
const ORDERING_FILE = 'priority.md'
|
|
12
14
|
const BACKLOG_FILE = 'backlog.md'
|
|
@@ -486,17 +488,34 @@ function citedStem(cell: string): string | undefined {
|
|
|
486
488
|
return stemOf(target)
|
|
487
489
|
}
|
|
488
490
|
|
|
491
|
+
/** What one blocker citation produced, since a row can be neither settled nor open. */
|
|
492
|
+
interface CitedResult {
|
|
493
|
+
readonly findings: readonly Finding[]
|
|
494
|
+
readonly untested: readonly Untested[]
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function nothing(): CitedResult {
|
|
498
|
+
return { findings: [], untested: [] }
|
|
499
|
+
}
|
|
500
|
+
|
|
489
501
|
/**
|
|
490
|
-
* Reports what a cited task does to the row waiting on it. A
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
* check could not parse is not evidence of a finished one.
|
|
502
|
+
* Reports what a cited task does to the row waiting on it. A file sitting in
|
|
503
|
+
* the archive settles the row, and a live file settles it only once the work it
|
|
504
|
+
* carries is on the trunk. A file carrying no outcome box settles nothing,
|
|
505
|
+
* since a file the check could not parse is not evidence of a finished one.
|
|
494
506
|
*
|
|
495
507
|
* A citation resolving in neither folder is a broken pointer rather than a
|
|
496
508
|
* closed task, and the two take different findings. Reading an absent file as
|
|
497
509
|
* archived states a specific fact about a file nobody ever wrote, which is what
|
|
498
510
|
* a renamed task or a typo produces.
|
|
499
511
|
*
|
|
512
|
+
* A closed outcome is not the same fact as landed work. The ship chain marks
|
|
513
|
+
* outcomes as its first step and opens the pull request several steps later, so
|
|
514
|
+
* a check reading the checkbox reports the row settled while the branch is
|
|
515
|
+
* still in review. The pull request the task names is what the trunk is asked
|
|
516
|
+
* about, and a task naming none leaves the row untested rather than settled,
|
|
517
|
+
* because the only local signal left is the checkbox that produced the defect.
|
|
518
|
+
*
|
|
500
519
|
* The outcome list comes off `readOutcomes` rather than a pattern of its own,
|
|
501
520
|
* so this check cannot disagree with the archive and outcome verbs about which
|
|
502
521
|
* checkboxes are outcomes and which sit inside a block a task displays.
|
|
@@ -506,42 +525,76 @@ async function checkCitedTask(
|
|
|
506
525
|
subject: string,
|
|
507
526
|
cited: string,
|
|
508
527
|
root: string,
|
|
509
|
-
|
|
528
|
+
trunk: TrunkReader,
|
|
529
|
+
): Promise<CitedResult> {
|
|
510
530
|
const live = join(tasksDir(root), `${cited}.md`)
|
|
511
531
|
|
|
512
532
|
if (!existsSync(live)) {
|
|
513
533
|
if (existsSync(join(archiveDir(root), `${cited}.md`))) {
|
|
514
|
-
return
|
|
534
|
+
return settled(group, subject, `waits on ${cited}, which is archived.`)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return {
|
|
538
|
+
findings: [
|
|
515
539
|
{
|
|
516
|
-
kind: 'blocker-
|
|
540
|
+
kind: 'blocker-unresolved',
|
|
517
541
|
group,
|
|
518
542
|
subject,
|
|
519
|
-
message: `waits on ${cited}, which is archived.`,
|
|
543
|
+
message: `waits on ${cited}, which is neither on the board nor archived.`,
|
|
520
544
|
},
|
|
521
|
-
]
|
|
545
|
+
],
|
|
546
|
+
untested: [],
|
|
522
547
|
}
|
|
523
|
-
|
|
524
|
-
return [
|
|
525
|
-
{
|
|
526
|
-
kind: 'blocker-unresolved',
|
|
527
|
-
group,
|
|
528
|
-
subject,
|
|
529
|
-
message: `waits on ${cited}, which is neither on the board nor archived.`,
|
|
530
|
-
},
|
|
531
|
-
]
|
|
532
548
|
}
|
|
533
549
|
|
|
534
|
-
const
|
|
535
|
-
|
|
550
|
+
const text = await readFile(live, 'utf8')
|
|
551
|
+
const { open, closed } = readOutcomes(text)
|
|
552
|
+
if (open.length > 0 || closed.length === 0) return nothing()
|
|
536
553
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
554
|
+
const pullRequest = readPullRequest(text)
|
|
555
|
+
if (pullRequest === undefined) {
|
|
556
|
+
return untestedRow(
|
|
540
557
|
group,
|
|
541
558
|
subject,
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
559
|
+
`waits on ${cited}, which closed every outcome but names no pull request, so nothing tests whether the work reached the trunk.`,
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const landed = await trunk(pullRequest)
|
|
564
|
+
if (landed === undefined) {
|
|
565
|
+
return untestedRow(
|
|
566
|
+
group,
|
|
567
|
+
subject,
|
|
568
|
+
`waits on ${cited}, whose pull request #${pullRequest} could not be read against the trunk.`,
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (!landed) return nothing()
|
|
573
|
+
|
|
574
|
+
return settled(
|
|
575
|
+
group,
|
|
576
|
+
subject,
|
|
577
|
+
`waits on ${cited}, whose pull request #${pullRequest} reached the trunk.`,
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function settled(
|
|
582
|
+
group: BoardGroup,
|
|
583
|
+
subject: string,
|
|
584
|
+
message: string,
|
|
585
|
+
): CitedResult {
|
|
586
|
+
return {
|
|
587
|
+
findings: [{ kind: 'blocker-settled', group, subject, message }],
|
|
588
|
+
untested: [],
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function untestedRow(
|
|
593
|
+
group: BoardGroup,
|
|
594
|
+
subject: string,
|
|
595
|
+
message: string,
|
|
596
|
+
): CitedResult {
|
|
597
|
+
return { findings: [], untested: [{ group, subject, message }] }
|
|
545
598
|
}
|
|
546
599
|
|
|
547
600
|
/**
|
|
@@ -564,6 +617,7 @@ async function checkCitedTask(
|
|
|
564
617
|
async function checkParked(
|
|
565
618
|
rows: readonly BoardRow[],
|
|
566
619
|
root: string,
|
|
620
|
+
trunk: TrunkReader,
|
|
567
621
|
): Promise<{ findings: Finding[]; untested: Untested[] }> {
|
|
568
622
|
const findings: Finding[] = []
|
|
569
623
|
const untested: Untested[] = []
|
|
@@ -578,7 +632,15 @@ async function checkParked(
|
|
|
578
632
|
const contested = readPaths(cell)
|
|
579
633
|
|
|
580
634
|
if (cited) {
|
|
581
|
-
|
|
635
|
+
const result = await checkCitedTask(
|
|
636
|
+
row.group,
|
|
637
|
+
subject,
|
|
638
|
+
cited,
|
|
639
|
+
root,
|
|
640
|
+
trunk,
|
|
641
|
+
)
|
|
642
|
+
findings.push(...result.findings)
|
|
643
|
+
untested.push(...result.untested)
|
|
582
644
|
}
|
|
583
645
|
|
|
584
646
|
const held = contested.filter((path) =>
|
|
@@ -613,12 +675,21 @@ function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
|
|
|
613
675
|
return { ok: false, reason, message }
|
|
614
676
|
}
|
|
615
677
|
|
|
678
|
+
export interface ValidateOptions {
|
|
679
|
+
/** Overridden by tests, which supply the trunk rather than reaching for git. */
|
|
680
|
+
readonly trunk?: TrunkReader
|
|
681
|
+
}
|
|
682
|
+
|
|
616
683
|
/**
|
|
617
684
|
* Reports what every board row claims against what the tree holds. It writes
|
|
618
685
|
* nothing: a row is a session's claim about readiness, and a validator that
|
|
619
686
|
* repaired one would be asserting the claim it exists to test.
|
|
620
687
|
*/
|
|
621
|
-
export async function validateBoard(
|
|
688
|
+
export async function validateBoard(
|
|
689
|
+
root: string,
|
|
690
|
+
options: ValidateOptions = {},
|
|
691
|
+
): Promise<ValidateOutcome> {
|
|
692
|
+
const trunk = options.trunk ?? gitTrunkReader(root)
|
|
622
693
|
const dir = tasksDir(root)
|
|
623
694
|
if (!existsSync(dir)) {
|
|
624
695
|
return refuse('no-board', `No task board at ${dir}.`)
|
|
@@ -647,7 +718,7 @@ export async function validateBoard(root: string): Promise<ValidateOutcome> {
|
|
|
647
718
|
: []
|
|
648
719
|
|
|
649
720
|
const stems = await listTaskStems(dir)
|
|
650
|
-
const parked = await checkParked(rows, root)
|
|
721
|
+
const parked = await checkParked(rows, root, trunk)
|
|
651
722
|
|
|
652
723
|
const findings = [
|
|
653
724
|
...checkMapping(rows, backlog, stems, dir),
|
package/standards/groundwork.md
CHANGED
|
@@ -122,6 +122,10 @@ Each spike carries four things:
|
|
|
122
122
|
|
|
123
123
|
Cost is a report rather than a limit, and it is what makes the next spike estimable before anyone commits to it. Record it even when it comes to a single read.
|
|
124
124
|
|
|
125
|
+
A spike also leaves files behind, and they split on whether the track cites them rather than on whether they are markdown. An input the run reads, being a fixture page, an arm script, or a copied asset, is re-runnable and cited by nothing, so it stays outside the track wherever the surface driving the spike puts it. Evidence the record cites, being a recording, a render, or a frame pulled from one, is what a later reader opens to check a claim, so it lives in `evidence/` inside the track beside the file citing it. Name the split rather than the file types, which is what keeps this from going stale on the next kind of artifact a spike produces.
|
|
126
|
+
|
|
127
|
+
`evidence/` takes no number, since numbering is the read order over the files a reader opens in sequence and an artifact is reached from the claim that cites it instead.
|
|
128
|
+
|
|
125
129
|
Reach for a test harness the project already carries before building one. A track needing an experiment no existing harness can express has found a finding, and it belongs in the folder rather than in a new abstraction.
|
|
126
130
|
|
|
127
131
|
One method error is worth naming, because it is made rather than imagined. Counting matches in a transcript overstates whether a file was read, since an instruction naming a path puts that path in the transcript whether or not anything opened it. The check is the tool call.
|
package/standards/tasks.md
CHANGED
|
@@ -132,7 +132,11 @@ Add no fourth readiness group in place of this file. The three group names are t
|
|
|
132
132
|
|
|
133
133
|
## Validation
|
|
134
134
|
|
|
135
|
-
`aitk tasks validate` reads the columns above and reports where a row's claim and the tree disagree: a plan pointer resolving to no file, a task file reached by neither surface, a task on both surfaces or in two groups, and two `## Run now` rows touching a path in common. It also re-takes the two blocker kinds a command can settle, reporting a parked row whose cited task
|
|
135
|
+
`aitk tasks validate` reads the columns above and reports where a row's claim and the tree disagree: a plan pointer resolving to no file, a task file reached by neither surface, a task on both surfaces or in two groups, and two `## Run now` rows touching a path in common. It also re-takes the two blocker kinds a command can settle, reporting a parked row whose cited task reached the trunk and one whose cited file nothing under `## Run now` still holds. Both halves read a citation out of the cell rather than parsing it into fields, and a row citing neither is reported as untested, which is where the three kinds resting on a person's judgment land. Run it when the readiness claim is made rather than on a schedule, since the board is gitignored per-machine scratch and no shared moment exists to hang it on. It reports and never writes, so a session fixes the row it names.
|
|
136
|
+
|
|
137
|
+
A cited task is settled by being archived, or by closing every outcome and carrying a `Pull request:` line the trunk holds. The closed checkbox alone settles nothing, because the ship chain marks outcomes as its first step and opens the pull request several steps later, so a row read off the checkbox reports settled while the branch is still in review. A task that closed every outcome and names no pull request, and one whose pull request the run could not read against the trunk, are both reported as untested. Degrading either back to the checkbox would reproduce the defect under a name claiming it was fixed.
|
|
138
|
+
|
|
139
|
+
The trunk is read as the clone already holds it, `origin/main` first and local `main` behind it, and no run fetches. A validate happens several times a sweep and a fetch per run is a cost this check does not carry, so a clone behind the remote under-reports rather than claiming work landed.
|
|
136
140
|
|
|
137
141
|
A task file is accounted for when a row on `priority.md` or a line on `backlog.md` names it, and reported when neither does. One check across both surfaces is what lets a task move between them without the move looking like a dropped file, and a task named by both is reported for the same reason a task in two groups is: it claims two things about itself and only one of them can hold. A project carrying no `backlog.md` is read as an empty backlog rather than refused, which leaves the one-to-one mapping this check ran before the second surface existed.
|
|
138
142
|
|
|
@@ -252,8 +256,16 @@ Archiving a task does not archive its plan. `claude-docs` owns the plans sweep a
|
|
|
252
256
|
|
|
253
257
|
The row is matched by the link in its first cell rather than by a pattern against the whole line. A row names the task it is about in the first cell, so a link anywhere after that is a reference, such as a blocker pointing at what it waits on. Matching the line would delete the referring task's row too, on a board that is gitignored and has nothing to recover it from.
|
|
254
258
|
|
|
255
|
-
Sweep the plan before archiving the task. The sweep finds its work by scanning the live folder, so a task archived first is beyond its reach for good, and the plan is left with no live task citing it and an archived task pointing at a path nothing will retarget. The archive refuses
|
|
259
|
+
Sweep the plan before archiving the task. The sweep finds its work by scanning the live folder, so a task archived first is beyond its reach for good, and the plan is left with no live task citing it and an archived task pointing at a path nothing will retarget. The archive refuses the last task pointing at a live plan for that reason, which puts the ordering under a gate rather than under a convention the unattended caller cannot follow.
|
|
260
|
+
|
|
261
|
+
The gate counts the other live tasks citing the same plan rather than reading which folder the plan sits in. A plan several tasks share stays in the live folder by design, because the sweep is correct to leave a plan another live task still cites, so a gate reading the folder alone refuses every one of those tasks and the board and the sweep block each other with neither in the wrong. Counting the citations asks the question the gate means: a plan nothing else holds is one the sweep has yet to reach, and a plan a sibling still holds is one the sweep already decided about.
|
|
262
|
+
|
|
263
|
+
The count resolves the target against `.claude/tasks/` and against the project root both, so `../plans/x.md` and `.claude/plans/x.md` land on the same file and one plan two tasks spelled differently counts once. `aitk tasks plan-citations` exposes that count for a caller that wants it, and the gate reads it.
|
|
264
|
+
|
|
265
|
+
The `claude-docs` sweep states the rule rather than calling that verb, which is a duplication accepted with a reason rather than an oversight. A skill reaches a target the moment it merges and the CLI reaches one only when a release publishes, so a body calling a verb the installed `aitk` predates gets no record back and sweeps nothing. The two spellings therefore have to agree by hand until a release carries the verb, and the failure they guard against is a plan stranded by the form its citation was written in.
|
|
266
|
+
|
|
267
|
+
A caller reads the outcome off the record's `reason` field and never off the exit code. An operator's shell profile may wrap `aitk` in a function that runs the binary and then another command, taking its status from the second, which masks an ordinary refusal exactly as it masks an absent verb.
|
|
256
268
|
|
|
257
|
-
|
|
269
|
+
Surviving a shared plan is not the same as sanctioning one. `standards/plan.md` puts one concern in one plan file, so a plan serving several tasks is a shape to correct rather than to build on, and the gate only stops it from deadlocking the board.
|
|
258
270
|
|
|
259
271
|
A task with an open outcome stays on the board. Close it, or cut it from the task when the work is being abandoned, so what was dropped is recorded rather than inferred from an archived file. The sweep is gated on the same condition, so archiving around an open outcome also leaves the plan behind.
|
|
@@ -15,17 +15,17 @@ packages = [
|
|
|
15
15
|
]
|
|
16
16
|
|
|
17
17
|
[scripts]
|
|
18
|
-
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) astro dev"
|
|
18
|
+
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && astro dev"
|
|
19
19
|
"build" = "astro check && astro build"
|
|
20
|
-
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) astro preview"
|
|
20
|
+
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && astro preview"
|
|
21
21
|
"astro" = "astro"
|
|
22
22
|
"typecheck" = "astro check"
|
|
23
23
|
"setup" = "./scripts/setup.sh"
|
|
24
24
|
|
|
25
25
|
[scripts.override]
|
|
26
|
-
"screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 4321) bash scripts/screenshot.sh"
|
|
27
|
-
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) astro dev"
|
|
28
|
-
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) astro preview"
|
|
26
|
+
"screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 4321) && export PREVIEW_PORT && bash scripts/screenshot.sh"
|
|
27
|
+
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && astro dev"
|
|
28
|
+
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && astro preview"
|
|
29
29
|
|
|
30
30
|
[gitignore]
|
|
31
31
|
"# Astro" = [".astro/"]
|
|
@@ -11,13 +11,13 @@ packages = [
|
|
|
11
11
|
]
|
|
12
12
|
|
|
13
13
|
[scripts]
|
|
14
|
-
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) vite"
|
|
14
|
+
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && vite"
|
|
15
15
|
"build" = "tsc --noEmit && vite build"
|
|
16
|
-
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) vite preview"
|
|
16
|
+
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && vite preview"
|
|
17
17
|
"typecheck" = "tsc --noEmit"
|
|
18
18
|
"setup" = "./scripts/setup.sh"
|
|
19
19
|
|
|
20
20
|
[scripts.override]
|
|
21
21
|
"build" = "tsc --noEmit && vite build"
|
|
22
|
-
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) vite"
|
|
23
|
-
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) vite preview"
|
|
22
|
+
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && vite"
|
|
23
|
+
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && vite preview"
|
|
@@ -3,10 +3,18 @@ set -euo pipefail
|
|
|
3
3
|
|
|
4
4
|
# Prints a port for this working directory: the base itself in a normal
|
|
5
5
|
# checkout, and the base plus a per-worktree offset in a linked git worktree,
|
|
6
|
-
# so two worktrees of one repository never serve on one port.
|
|
6
|
+
# so two worktrees of one repository never serve on one port. A folder left
|
|
7
|
+
# behind after its worktree was removed is refused rather than served, since
|
|
8
|
+
# every port it derives lands on the one the main checkout wanted.
|
|
7
9
|
|
|
8
10
|
base="${1:-0}"
|
|
9
11
|
band=50
|
|
12
|
+
worktrees_dir=".claude/worktrees"
|
|
13
|
+
|
|
14
|
+
refuse() {
|
|
15
|
+
echo "worktree-port: $1 is a leftover worktree folder, because $2." >&2
|
|
16
|
+
echo "worktree-port: a port derived here collides with the main checkout, so remove the folder, or set WORKTREE_PORT_OFFSET to serve from it anyway." >&2
|
|
17
|
+
}
|
|
10
18
|
|
|
11
19
|
offset() {
|
|
12
20
|
if [[ -n "${WORKTREE_PORT_OFFSET:-}" ]]; then
|
|
@@ -14,23 +22,53 @@ offset() {
|
|
|
14
22
|
return
|
|
15
23
|
fi
|
|
16
24
|
|
|
17
|
-
local git_dir common_dir name
|
|
18
|
-
|
|
25
|
+
local here git_dir common_dir toplevel dir name
|
|
26
|
+
here=$(pwd -P)
|
|
27
|
+
|
|
28
|
+
if ! git_dir=$(git rev-parse --git-dir 2>/dev/null); then
|
|
29
|
+
# Git refuses outright when a `.git` file names an administrative directory
|
|
30
|
+
# that is gone, which is what removing a worktree by hand leaves behind.
|
|
31
|
+
# Outside a repository there is no pointer at all and the base is correct.
|
|
32
|
+
dir=$here
|
|
33
|
+
while [[ "$dir" != / ]]; do
|
|
34
|
+
if [[ -f "$dir/.git" ]]; then
|
|
35
|
+
refuse "$dir" "its .git file names an administrative directory that is gone"
|
|
36
|
+
return 1
|
|
37
|
+
fi
|
|
38
|
+
[[ -d "$dir/.git" ]] && break
|
|
39
|
+
dir=$(dirname "$dir")
|
|
40
|
+
done
|
|
19
41
|
echo 0
|
|
20
42
|
return
|
|
21
|
-
|
|
43
|
+
fi
|
|
44
|
+
|
|
22
45
|
common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || {
|
|
23
46
|
echo 0
|
|
24
47
|
return
|
|
25
48
|
}
|
|
49
|
+
toplevel=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
|
50
|
+
echo 0
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
toplevel=$(cd "$toplevel" && pwd -P)
|
|
26
54
|
|
|
27
55
|
if [[ "$(cd "$git_dir" && pwd -P)" == "$(cd "$common_dir" && pwd -P)" ]]; then
|
|
56
|
+
# The main checkout answers here, and so does every directory under it,
|
|
57
|
+
# including a folder whose `.git` was deleted along with its worktree,
|
|
58
|
+
# since git then walks upward and reports the parent repository. Location
|
|
59
|
+
# is the only signal separating the two, so a directory sitting under the
|
|
60
|
+
# worktrees folder is refused rather than handed the base port.
|
|
61
|
+
if [[ "$here/" == "$toplevel/$worktrees_dir/"?* ]]; then
|
|
62
|
+
refuse "$here" "no worktree is registered for it"
|
|
63
|
+
return 1
|
|
64
|
+
fi
|
|
28
65
|
echo 0
|
|
29
66
|
return
|
|
30
67
|
fi
|
|
31
68
|
|
|
32
|
-
name=$(basename "$
|
|
69
|
+
name=$(basename "$toplevel")
|
|
33
70
|
echo $(($(printf '%s' "$name" | cksum | cut -d' ' -f1) % band + 1))
|
|
34
71
|
}
|
|
35
72
|
|
|
36
|
-
|
|
73
|
+
value=$(offset) || exit 1
|
|
74
|
+
echo $((base + value))
|
|
@@ -42,13 +42,13 @@ packages = [
|
|
|
42
42
|
"test:run" = "vitest run --reporter=verbose"
|
|
43
43
|
"test:ui" = "vitest --ui"
|
|
44
44
|
"test:coverage" = "vitest run --coverage"
|
|
45
|
-
"test:e2e" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) playwright test"
|
|
46
|
-
"test:e2e:ui" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) playwright test --ui"
|
|
45
|
+
"test:e2e" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && playwright test"
|
|
46
|
+
"test:e2e:ui" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && playwright test --ui"
|
|
47
47
|
"test:e2e:report" = "playwright show-report"
|
|
48
48
|
"check:full" = "./scripts/verify.sh && bun run test:e2e"
|
|
49
49
|
|
|
50
50
|
[scripts.override]
|
|
51
|
-
"screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 4173) bash scripts/screenshot.sh"
|
|
51
|
+
"screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 4173) && export PREVIEW_PORT && bash scripts/screenshot.sh"
|
|
52
52
|
|
|
53
53
|
[gitignore]
|
|
54
54
|
"# Build" = ["dist/"]
|
package/tooling/web/reference.md
CHANGED
|
@@ -16,7 +16,7 @@ Golden config files live in `tooling/web/configs/` and are copied into the targe
|
|
|
16
16
|
- `.vscode/extensions.json` and `.vscode/settings.json`: editor wiring for ESLint, Tailwind, Playwright, Vitest.
|
|
17
17
|
- `.github/workflows/verify.yml`: `static-checks`, `unit-tests`, `build-verify`, and `e2e-tests` jobs.
|
|
18
18
|
- `scripts/verify.sh`: extends base verify with typecheck, lint, unit tests, and build in the full order.
|
|
19
|
-
- `scripts/worktree-port.sh`: prints a base port plus this working directory's offset. Called with no argument it prints the offset alone.
|
|
19
|
+
- `scripts/worktree-port.sh`: prints a base port plus this working directory's offset. Called with no argument it prints the offset alone. It refuses a folder left under the worktrees directory after its worktree was removed, rather than printing a port for it.
|
|
20
20
|
|
|
21
21
|
## What stays in per-stack adapters
|
|
22
22
|
|
|
@@ -40,6 +40,9 @@ Two worktrees of one repository run the same stack, so a fixed port makes the se
|
|
|
40
40
|
- Derive every served port from `scripts/worktree-port.sh`. Never write a port literal into a script string.
|
|
41
41
|
- Read `WORKTREE_PORT_OFFSET` in a config and add it to the stack's default port. Unset yields the default, so a plain clone keeps the port it has always served on.
|
|
42
42
|
- Draw the offset from a band of 50, hashed from the worktree folder name. Two worktrees can hash to one offset, so set `WORKTREE_PORT_OFFSET` by hand to break a tie.
|
|
43
|
+
- Expect a non-zero exit and no port from a folder left under `.claude/worktrees/` once its worktree is gone. Git reports the parent repository from inside one, so the helper cannot read it as a worktree and every base it serves would land on the main checkout's port. Both shapes refuse, and they reach differently. A folder whose own `.git` was deleted refuses only under that directory, since location is the only thing separating it from an ordinary subdirectory the base port is correct for. A folder whose `.git` names a pruned administrative directory refuses wherever it sits, because a pointer to nothing is broken regardless of where the folder is.
|
|
44
|
+
- Call it as `VAR=$(bash scripts/worktree-port.sh) && export VAR && <server>`, never as the shorter `VAR=$(bash scripts/worktree-port.sh) <server>`. An assignment prefix discards the exit status of its own substitution, so the shorter form starts the server with `VAR` set to the empty string, every config reads that back as an offset of zero, and the refusal lands on the port it was raised to protect. The assignment alone carries the status, which is what the `&&` reads.
|
|
45
|
+
- Set `WORKTREE_PORT_OFFSET` by hand to serve from such a folder anyway. That is the one override, and it is checked before any directory test.
|
|
43
46
|
- Force-replace `dev` and `preview` through `[scripts.override]`. Both stacks' scaffolds define those keys, and a plain `[scripts]` entry never replaces a key the scaffold already wrote.
|
|
44
47
|
- Set `strictPort` on every dev and preview server. A server that walks to the next free port serves where nothing is looking for it.
|
|
45
48
|
- Set Playwright `reuseExistingServer: false`. Reuse attaches to whatever answers on the port, which reports a pass against another branch's code and prints nothing to say so.
|