@erclx/aitk 0.89.0 → 0.90.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.
Files changed (34) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/claude-groundwork/SKILL.md +1 -1
  3. package/claude/skills/claude-intake/SKILL.md +8 -1
  4. package/claude/skills/claude-intake-answer/REQUIREMENT.md +48 -0
  5. package/claude/skills/claude-intake-answer/SKILL.md +90 -0
  6. package/claude/skills/claude-orchestrate/SKILL.md +5 -3
  7. package/claude/skills/claude-tasks/SKILL.md +1 -1
  8. package/claude/skills/claude-worktree/REQUIREMENT.md +8 -0
  9. package/claude/skills/claude-worktree/SKILL.md +22 -0
  10. package/claude/skills/toolkit-feedback/SKILL.md +1 -1
  11. package/claude/skills/youtube-transcripts/REQUIREMENT.md +1 -1
  12. package/claude/skills/youtube-transcripts/SKILL.md +1 -1
  13. package/docs/agents/commands.md +3 -0
  14. package/docs/agents/index.md +1 -0
  15. package/docs/agents/intake.md +77 -0
  16. package/docs/operating-model.md +4 -2
  17. package/package.json +1 -1
  18. package/src/cli.ts +4 -0
  19. package/src/commands/intake.ts +406 -0
  20. package/src/intake/folder.ts +280 -0
  21. package/src/intake/items.ts +174 -0
  22. package/standards/intake.md +9 -0
  23. package/standards/skill.md +1 -1
  24. package/tooling/astro/configs/astro.config.mjs +11 -0
  25. package/tooling/astro/configs/playwright.config.ts +4 -3
  26. package/tooling/astro/manifest.toml +5 -3
  27. package/tooling/astro/reference.md +3 -3
  28. package/tooling/vite-react/configs/playwright.config.ts +4 -3
  29. package/tooling/vite-react/configs/vite.config.ts +10 -0
  30. package/tooling/vite-react/manifest.toml +4 -2
  31. package/tooling/vite-react/reference.md +4 -4
  32. package/tooling/web/configs/scripts/worktree-port.sh +36 -0
  33. package/tooling/web/manifest.toml +3 -3
  34. package/tooling/web/reference.md +12 -0
@@ -0,0 +1,174 @@
1
+ import { bodyLines } from '@/markdown/scan'
2
+
3
+ /**
4
+ * The index points at items and answers nothing itself, so it carries no
5
+ * answer slot for this verb to reach. It also displays the item format inside a
6
+ * fence, which the fence walk already masks, so skipping it by name is about
7
+ * what the file is rather than about the block it holds.
8
+ */
9
+ export const INDEX_FILE = '00-overview.md'
10
+
11
+ /**
12
+ * An item heading, labeled per cluster file rather than per folder.
13
+ *
14
+ * The label carries an optional letter suffix because a pass that splits one
15
+ * finding after the fact numbers the halves `3a` and `3b` rather than
16
+ * renumbering every item below them. A pattern accepting digits alone parses
17
+ * such a file without complaint and drops those items, leaving them
18
+ * unanswerable through this verb with nothing reporting the gap.
19
+ */
20
+ const HEADING = /^###\s+(\d+[a-z]*)\.\s*(.*)$/i
21
+
22
+ /** A bolded field bullet, which is every line an item carries. */
23
+ const FIELD = /^-\s+\*\*([^*]+):\*\*\s*(.*)$/
24
+
25
+ /**
26
+ * Any heading, which ends the item above it.
27
+ *
28
+ * A numbered `###` is tested first and opens the next item, so what reaches
29
+ * here is a heading that is not an item and therefore closes the one open. The
30
+ * narrower test for `##` alone let a `### Notes` block stay inside the item
31
+ * above it, and the stray slot such a block carries then displaced the real
32
+ * one, leaving the item reading as answered while its own slot sat empty and
33
+ * sending a write into the wrong section.
34
+ */
35
+ const SECTION = /^#{1,6}\s/
36
+
37
+ export interface IntakeItem {
38
+ /** The label as the heading spells it, such as `3` or `3a`. */
39
+ readonly label: string
40
+ readonly title: string
41
+ /** Line the heading sits on, 1-based against the whole file. */
42
+ readonly line: number
43
+ /** Line the answer slot sits on, absent when the item carries no slot. */
44
+ readonly answerLine: number | undefined
45
+ /** Text in the slot, absent when the slot is empty and the item is unread. */
46
+ readonly answer: string | undefined
47
+ readonly open: string | undefined
48
+ readonly suggested: string | undefined
49
+ readonly worth: string | undefined
50
+ }
51
+
52
+ interface Draft {
53
+ label: string
54
+ title: string
55
+ line: number
56
+ answerLine: number | undefined
57
+ answer: string | undefined
58
+ open: string | undefined
59
+ suggested: string | undefined
60
+ worth: string | undefined
61
+ }
62
+
63
+ function seal(draft: Draft): IntakeItem {
64
+ return { ...draft }
65
+ }
66
+
67
+ /**
68
+ * Reads every item a cluster file holds, in file order.
69
+ *
70
+ * The walk runs over `bodyLines` rather than a raw split so the item format
71
+ * block a folder copies into its own files parses as the sample it is. A
72
+ * heading counted out of a fence shifts nothing on its own, but it offers an
73
+ * answer slot no reader owns and the write-back would land inside the sample.
74
+ */
75
+ export function readItems(text: string): IntakeItem[] {
76
+ const items: IntakeItem[] = []
77
+ let draft: Draft | undefined
78
+
79
+ for (const line of bodyLines(text)) {
80
+ if (line.fenced) continue
81
+
82
+ const heading = HEADING.exec(line.text)
83
+
84
+ if (heading) {
85
+ if (draft) items.push(seal(draft))
86
+ draft = {
87
+ label: heading[1].toLowerCase(),
88
+ title: heading[2].trim(),
89
+ line: line.number,
90
+ answerLine: undefined,
91
+ answer: undefined,
92
+ open: undefined,
93
+ suggested: undefined,
94
+ worth: undefined,
95
+ }
96
+ continue
97
+ }
98
+
99
+ if (!draft) continue
100
+
101
+ if (SECTION.test(line.text)) {
102
+ items.push(seal(draft))
103
+ draft = undefined
104
+ continue
105
+ }
106
+
107
+ const field = FIELD.exec(line.text)
108
+ if (!field) continue
109
+
110
+ const value = field[2].trim()
111
+
112
+ switch (field[1].trim().toLowerCase()) {
113
+ case 'you':
114
+ draft.answerLine = line.number
115
+ draft.answer = value === '' ? undefined : value
116
+ break
117
+ case 'open':
118
+ draft.open = value
119
+ break
120
+ case 'suggested':
121
+ draft.suggested = value
122
+ break
123
+ case 'worth it':
124
+ draft.worth = value
125
+ break
126
+ }
127
+ }
128
+
129
+ if (draft) items.push(seal(draft))
130
+
131
+ return items
132
+ }
133
+
134
+ /**
135
+ * Puts a selection in one item's slot, leaving every other line as it was.
136
+ *
137
+ * The rewrite replaces the whole line rather than patching inside it, which is
138
+ * the reason this is a verb at all. A stream editor expands an unescaped
139
+ * ampersand in the replacement to the whole match and exits zero on a
140
+ * non-match, so an answer carrying one would rewrite the line it anchored to
141
+ * and a missed slot would report success with the answer lost.
142
+ *
143
+ * The answer occupies one line, and the caller owes that guarantee. A line
144
+ * break splices a bare continuation into the item matching none of the patterns
145
+ * the reader tests, so the slot reads back as the text before the break while
146
+ * the item counts as answered, which puts correcting it behind the refusal on
147
+ * an item that already carries one.
148
+ */
149
+ export function writeAnswerLine(
150
+ text: string,
151
+ answerLine: number,
152
+ answer: string,
153
+ ): string {
154
+ const lines = text.split('\n')
155
+ lines[answerLine - 1] = `- **You:** ${answer}`
156
+ return lines.join('\n')
157
+ }
158
+
159
+ /** An item nobody has reached, which is an empty slot rather than a missing one. */
160
+ export function isUnread(item: IntakeItem): boolean {
161
+ return item.answerLine !== undefined && item.answer === undefined
162
+ }
163
+
164
+ /**
165
+ * An item carrying no slot at all, which the format says ships on every one.
166
+ *
167
+ * Such an item is neither unread nor answered, and counting it as either hides
168
+ * it: as answered it drops out of the work a reader is told remains, and as
169
+ * unread it joins a list whose every entry the answer verb then refuses. It is
170
+ * reported on its own so the file gets fixed.
171
+ */
172
+ export function isMalformed(item: IntakeItem): boolean {
173
+ return item.answerLine === undefined
174
+ }
@@ -89,6 +89,7 @@ Where an item touches a task already on the board, say so in the index rather th
89
89
  ```
90
90
 
91
91
  - `Problem:`, `Fix:`, `Worth it:`, and the empty `You:` slot ship on every item. The other two are conditional.
92
+ - Number items per cluster file, and give a finding split after the fact a letter suffix on the number it came from, as in `3a` beside `3`. Renumbering the items below it instead moves every label a reader or an answer already cited.
92
93
  - `Suggested:` is required whenever `Open:` is present. A bare question invites a bare answer, and `ok` against two defensible options carries no information. Where the answer is the operator's preference rather than a technical call, say so in that form rather than inventing a default.
93
94
  - `Overlaps:` never replaces `Worth it:`. The items where a live board task might be the thing that is wrong are exactly the ones whose verdict matters most.
94
95
 
@@ -106,10 +107,18 @@ That inverts the plan file's contract, where a blank answer slot means accept th
106
107
 
107
108
  Never fill a `You:` slot, and never infer a disposition from an empty one. On a resume pass, report unread items by count rather than deciding them.
108
109
 
110
+ A slot is filled two ways. The operator types into the cluster file, or answers in chat and a verb lands the selection on the item. Both put the answer on the item, which is what keeps retrieval working, and neither lets a session decide one. An answer given in conversation and never written back leaves the item unread, since the file rather than the conversation is the record.
111
+
112
+ An item already carrying an answer is refused rather than overwritten, whichever route the second answer arrives by. A filled slot is a decision already made, and revising one is the operator editing their own line.
113
+
109
114
  ## Retrieval
110
115
 
111
116
  Answers live on items, so one pass over the folder reports every touched slot.
112
117
 
118
+ A session with the toolkit CLI on PATH reads the folder through `aitk intake list`, which reports per-folder counts bare and one folder's items with `--json`, and takes `--unread` to keep only the empty slots. It is the surface under test, and it skips the index and every fenced sample, which the greps below cannot do.
119
+
120
+ The greps stay for a reader without the CLI, and they overcount by whatever the folder displays in a fence.
121
+
113
122
  ```bash
114
123
  awk '/^### /{h=FILENAME": "$0} /^- \*\*You:\*\*./{print h; print " "$0}' *.md
115
124
  ```
@@ -208,7 +208,7 @@ Without this skill, a session <observed failure>, <observed failure>.
208
208
 
209
209
  ### Output and tuning
210
210
 
211
- - Skill success lines emit the full relative path from the project root (`<dir>/<file>`) for any file written, updated, or deleted. Bare filenames are not clickable in the terminal.
211
+ - Skill success lines emit the full relative path from the project root (`<dir>/<file>`) for any file written, updated, or deleted. A bare filename names a file the reader cannot open. The `## Output` section of the project's instruction file sets the form that path takes, so a skill body states which path is emitted and leaves the form to that section.
212
212
  - Codify a skill's posted or generated output as a fenced template, and keep the body consistent with every capability the frontmatter description names.
213
213
  - When a skill gathers user input or pre-seeds a template, attach a concrete proposed default to every question, derived from project context. Accept "use defaults" as a bulk-confirm.
214
214
  - Separate correctness axes (routing, sourcing, escalation, decline) from shape axes (line count, formatting, variant sprawl) when tuning a skill. Tighten only on correctness regressions. Do not convert soft caps to hard caps for aesthetic drift when correctness passes.
@@ -3,9 +3,14 @@ import tailwindcss from '@tailwindcss/vite'
3
3
  import { defineConfig } from 'astro/config'
4
4
  import path from 'path'
5
5
 
6
+ const portOffset = Number(process.env.WORKTREE_PORT_OFFSET) || 0
7
+
6
8
  export default defineConfig({
7
9
  integrations: [react()],
8
10
  site: process.env.ASTRO_SITE,
11
+ server: {
12
+ port: 4321 + portOffset,
13
+ },
9
14
  vite: {
10
15
  plugins: [tailwindcss()],
11
16
  resolve: {
@@ -13,5 +18,11 @@ export default defineConfig({
13
18
  '@': path.resolve('./src'),
14
19
  },
15
20
  },
21
+ server: {
22
+ strictPort: true,
23
+ },
24
+ preview: {
25
+ strictPort: true,
26
+ },
16
27
  },
17
28
  })
@@ -1,6 +1,7 @@
1
1
  import { defineConfig, devices } from '@playwright/test'
2
2
 
3
3
  const isCI = !!process.env.CI
4
+ const baseURL = `http://localhost:${4321 + (Number(process.env.WORKTREE_PORT_OFFSET) || 0)}`
4
5
 
5
6
  export default defineConfig({
6
7
  testDir: 'e2e',
@@ -10,7 +11,7 @@ export default defineConfig({
10
11
  reporter: isCI ? 'list' : 'html',
11
12
  use: {
12
13
  trace: 'on-first-retry',
13
- baseURL: 'http://localhost:4321',
14
+ baseURL,
14
15
  },
15
16
  projects: [
16
17
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
@@ -19,7 +20,7 @@ export default defineConfig({
19
20
  ],
20
21
  webServer: {
21
22
  command: 'bun run build && bun run preview',
22
- url: 'http://localhost:4321',
23
- reuseExistingServer: !isCI,
23
+ url: baseURL,
24
+ reuseExistingServer: false,
24
25
  },
25
26
  })
@@ -15,15 +15,17 @@ packages = [
15
15
  ]
16
16
 
17
17
  [scripts]
18
- "dev" = "astro dev"
18
+ "dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) astro dev"
19
19
  "build" = "astro check && astro build"
20
- "preview" = "astro preview"
20
+ "preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) 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=4321 bash scripts/screenshot.sh"
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"
27
29
 
28
30
  [gitignore]
29
31
  "# Astro" = [".astro/"]
@@ -17,9 +17,9 @@ The astro stack covers Astro + TypeScript projects: content sites, marketing sit
17
17
 
18
18
  ## What ships as golden configs
19
19
 
20
- - `astro.config.mjs`: `@astrojs/react` integration, `@tailwindcss/vite` in `vite.plugins`, `@/` path alias via `vite.resolve.alias`, `ASTRO_SITE` env for the `site` field.
20
+ - `astro.config.mjs`: `@astrojs/react` integration, `@tailwindcss/vite` in `vite.plugins`, `@/` path alias via `vite.resolve.alias`, `ASTRO_SITE` env for the `site` field. Port `4321` plus `WORKTREE_PORT_OFFSET` at `server.port`, with `strictPort` under `vite.server` and `vite.preview`. Astro merges the user's `vite` block into the config backing both its dev and its static preview server, and feeds `server.port` through as the preview port, so the port sits at the top level while the bind guarantee sits under `vite`.
21
21
  - `vitest.config.ts`: uses `getViteConfig` from `astro/config` (not `mergeConfig`). jsdom, globals, setup file, `passWithNoTests: true`, v8 coverage, `**/*.astro` in coverage excludes.
22
- - `playwright.config.ts`: all browsers, `webServer` runs `bun run build && bun run preview` on port 4321. Astro's dev/prod gap is wide (MDX, island hydration, asset optimization), so E2E always tests the built `dist/`.
22
+ - `playwright.config.ts`: all browsers, `webServer` runs `bun run build && bun run preview` on port `4321` plus `WORKTREE_PORT_OFFSET`, `reuseExistingServer: false`. Astro's dev/prod gap is wide (MDX, island hydration, asset optimization), so E2E always tests the built `dist/`.
23
23
  - `tsconfig.json`: extends `astro/tsconfigs/strict`, adds `skipLibCheck`, `vitest/globals` and `@testing-library/jest-dom` in types, `@/` paths.
24
24
  - `eslint.config.js`: overrides the web layer. Adds `eslint-plugin-astro` (`.astro` parser via `astro-eslint-parser`). React-hooks scoped to `.jsx`/`.tsx` only (`.astro` is not React). `src/pages/**` exempt from filename and folder naming conventions because Astro's file-based routing ties names to URL segments.
25
25
 
@@ -49,7 +49,7 @@ Add `prettier-plugin-astro` first in plugins, then `prettier-plugin-tailwindcss`
49
49
 
50
50
  Append to the `## Scripts` table:
51
51
 
52
- | `bun run dev` | Start the Astro dev server on port 4321. |
52
+ | `bun run dev` | Start the Astro dev server on port 4321, plus this worktree's port offset. |
53
53
  | `bun run build` | Run `astro check` then build the static output. |
54
54
  | `bun run preview` | Serve the built site locally. |
55
55
  | `bun run astro` | Expose the Astro CLI. |
@@ -1,6 +1,7 @@
1
1
  import { defineConfig, devices } from '@playwright/test'
2
2
 
3
3
  const isCI = !!process.env.CI
4
+ const baseURL = `http://localhost:${5173 + (Number(process.env.WORKTREE_PORT_OFFSET) || 0)}`
4
5
 
5
6
  export default defineConfig({
6
7
  testDir: 'e2e',
@@ -10,7 +11,7 @@ export default defineConfig({
10
11
  reporter: isCI ? 'list' : 'html',
11
12
  use: {
12
13
  trace: 'on-first-retry',
13
- baseURL: 'http://localhost:5173',
14
+ baseURL,
14
15
  },
15
16
  projects: [
16
17
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
@@ -19,7 +20,7 @@ export default defineConfig({
19
20
  ],
20
21
  webServer: {
21
22
  command: 'bun run dev',
22
- url: 'http://localhost:5173',
23
- reuseExistingServer: !isCI,
23
+ url: baseURL,
24
+ reuseExistingServer: false,
24
25
  },
25
26
  })
@@ -3,6 +3,8 @@ import react from '@vitejs/plugin-react'
3
3
  import path from 'path'
4
4
  import { defineConfig } from 'vite'
5
5
 
6
+ const portOffset = Number(process.env.WORKTREE_PORT_OFFSET) || 0
7
+
6
8
  export default defineConfig({
7
9
  plugins: [react(), tailwindcss()],
8
10
  resolve: {
@@ -11,4 +13,12 @@ export default defineConfig({
11
13
  },
12
14
  },
13
15
  base: process.env.VITE_BASE_URL ?? '/',
16
+ server: {
17
+ port: 5173 + portOffset,
18
+ strictPort: true,
19
+ },
20
+ preview: {
21
+ port: 4173 + portOffset,
22
+ strictPort: true,
23
+ },
14
24
  })
@@ -11,11 +11,13 @@ packages = [
11
11
  ]
12
12
 
13
13
  [scripts]
14
- "dev" = "vite"
14
+ "dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) vite"
15
15
  "build" = "tsc --noEmit && vite build"
16
- "preview" = "vite preview"
16
+ "preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) 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"
@@ -16,16 +16,16 @@ The vite-react stack covers Vite + React + TypeScript projects: web apps and Chr
16
16
 
17
17
  ## What ships as golden configs
18
18
 
19
- - `vite.config.ts`: `@vitejs/plugin-react`, `@tailwindcss/vite`, `@` path alias to `./src`, `VITE_BASE_URL` env for base path.
19
+ - `vite.config.ts`: `@vitejs/plugin-react`, `@tailwindcss/vite`, `@` path alias to `./src`, `VITE_BASE_URL` env for base path. Dev port `5173` and preview port `4173`, each plus `WORKTREE_PORT_OFFSET`, both with `strictPort`.
20
20
  - `vitest.config.ts`: merges from `vite.config.ts`, jsdom, globals, setup file, `passWithNoTests: true`, v8 coverage.
21
- - `playwright.config.ts`: all browsers, `webServer` on `bun run dev` at port 5173, trace under `use`.
21
+ - `playwright.config.ts`: all browsers, `webServer` on `bun run dev` at port `5173` plus `WORKTREE_PORT_OFFSET`, `reuseExistingServer: false`, trace under `use`.
22
22
  - `tsconfig.json`: unified, `noEmit: true`, `skipLibCheck: true`, `@/` paths, `vitest/globals` and `@testing-library/jest-dom` in types.
23
23
 
24
24
  ## Chrome extension variant
25
25
 
26
26
  When scaffolding a Chrome extension, override the installed golden configs:
27
27
 
28
- - `vite.config.ts`: use `crx({ manifest })` and `zip()` from `@crxjs/vite-plugin` instead of `react()` alone. Set `server.port: 5173`, `server.strictPort: true`, `server.hmr.clientPort: 5173`, and `chrome-extension://` in CORS origins. Drop `VITE_BASE_URL`.
28
+ - `vite.config.ts`: use `crx({ manifest })` and `zip()` from `@crxjs/vite-plugin` instead of `react()` alone. Keep the derived `server.port` and `server.strictPort: true`, set `server.hmr.clientPort` to the same derived value, and add `chrome-extension://` to CORS origins. Drop `VITE_BASE_URL`.
29
29
  - `vitest.config.ts`: use a standalone `defineConfig` (no `mergeConfig`). crxjs plugin breaks Vitest. Declare `@vitejs/plugin-react` and `@tailwindcss/vite` directly. Add `**/release/**` to excludes and `manifest.config.ts`, `**/*.d.ts` to coverage excludes.
30
30
  - `playwright.config.ts`: chromium-only (Firefox and WebKit cannot run extensions). Bundled `chromium` channel. No `baseURL` or `webServer`. Tests load the built extension directly from `dist/`.
31
31
  - `e2e/fixtures.ts`: extend Playwright base `test` with `context` (persistent context loading the extension from `dist/`) and `extensionId` (extracted from service worker URL). Rename `use` to `apply` to avoid the React hooks ESLint rule. `waitForEvent('serviceworker')` blocks until the MV3 service worker registers.
@@ -45,7 +45,7 @@ When scaffolding a Chrome extension, override the installed golden configs:
45
45
 
46
46
  Append to the `## Scripts` table:
47
47
 
48
- | `bun run dev` | Start the Vite dev server on port 5173. |
48
+ | `bun run dev` | Start the Vite dev server on port 5173, plus this worktree's port offset. |
49
49
  | `bun run build` | Typecheck then build the production bundle. |
50
50
  | `bun run preview` | Serve the built bundle locally. |
51
51
  | `bun run typecheck` | Run `tsc --noEmit`. |
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Prints a port for this working directory: the base itself in a normal
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.
7
+
8
+ base="${1:-0}"
9
+ band=50
10
+
11
+ offset() {
12
+ if [[ -n "${WORKTREE_PORT_OFFSET:-}" ]]; then
13
+ echo "$WORKTREE_PORT_OFFSET"
14
+ return
15
+ fi
16
+
17
+ local git_dir common_dir name
18
+ git_dir=$(git rev-parse --git-dir 2>/dev/null) || {
19
+ echo 0
20
+ return
21
+ }
22
+ common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || {
23
+ echo 0
24
+ return
25
+ }
26
+
27
+ if [[ "$(cd "$git_dir" && pwd -P)" == "$(cd "$common_dir" && pwd -P)" ]]; then
28
+ echo 0
29
+ return
30
+ fi
31
+
32
+ name=$(basename "$(git rev-parse --show-toplevel)")
33
+ echo $(($(printf '%s' "$name" | cksum | cut -d' ' -f1) % band + 1))
34
+ }
35
+
36
+ echo $((base + $(offset)))
@@ -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" = "playwright test"
46
- "test:e2e:ui" = "playwright test --ui"
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"
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" = "bash scripts/screenshot.sh"
51
+ "screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 4173) bash scripts/screenshot.sh"
52
52
 
53
53
  [gitignore]
54
54
  "# Build" = ["dist/"]
@@ -16,6 +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
20
 
20
21
  ## What stays in per-stack adapters
21
22
 
@@ -32,6 +33,17 @@ Framework glue lives in `tooling/vite-react/configs/` or `tooling/astro/configs/
32
33
  - Path alias `@` maps to `./src` in both tsconfig and the framework's build config.
33
34
  - Tsconfig is unified at root with `noEmit: true` in Vite stacks. Astro uses the scaffold default from `@astrojs/check`.
34
35
 
36
+ ## Ports
37
+
38
+ Two worktrees of one repository run the same stack, so a fixed port makes the second one attach to the first.
39
+
40
+ - Derive every served port from `scripts/worktree-port.sh`. Never write a port literal into a script string.
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
+ - 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
+ - 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
+ - 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
+ - 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.
46
+
35
47
  ## Anti-patterns
36
48
 
37
49
  Sticky negative knowledge. Do not relearn.