rvlib-shipkit 0.35.5 → 0.35.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.7
4
+
5
+ - **`shipkit ci` no longer pre-warms a release survey nothing in the gate reads.** `runCi` started `collectReports` next to the tasks and awaited it before the checks, to hide ~3s of npm registry and `gh` network behind the task DAG. Its only consumer was ever the lib-drift probe, which asks what is PUBLISHED and therefore belongs to `shipkit releases` and the bump gate rather than to a gate that asks whether this change is good. Measured on a repo whose drift checks had left the gate: warm no-op 4.3s with the pre-warm, 1.2s without. Deleted rather than made conditional, because nothing is left to condition it on and a second mechanism guessing which check wants a survey is exactly the source-sniffing this scanner already retired. `collectChecks` creates, shares and cleans up the share path when it was not handed one, so the checks that DO want a survey still pay for exactly one between them, and `shipkit check` is unchanged. Honest cost: a repo that keeps a lib-drift check inside its gate loses the overlap and is up to 3s slower there.
6
+ - **`export const gate = false` was parsed, filtered on, and never honoured** (the fix itself shipped in 0.35.6, which carried no note for it). The CLI declares `--only` as `Option.Array('--only', [])`, so a plain `shipkit ci` hands the check filter an EMPTY ARRAY and never `undefined`. The guard tested `only == null`, which is false for `[]`, so every gate run read as a narrowed one and no check ever opted out: a repo could declare fifteen world probes off-gate and watch all fifteen run anyway. `matchesOnly`, two hundred lines up in the same file, already used the right predicate, and the `only` field's own doc comment already said "Empty or absent means everything". Both readers now call one exported `isNarrowed`, so `--only` has a single meaning and no second place to get it wrong. Pinned by three tests that call `collectChecks` the way the CLI calls it: the gate skips a `gate = false` check, `shipkit check` still runs it, and naming it in `--only` still reaches it.
7
+ - **The adopt path writes `.vscode/tasks.json` alone, and derives what goes in it.** 0.35.6 wrote `settings.json` too, which turns eslint off and hands formatting to an oxc extension: landing that in a repo that lints with eslint silences the linter that repo chose, and that is an answer only its own author gets to give. It also wrote a canned `👁 TSC` watcher pointed at `tsconfig.json`. Against a solution config (`files: []` plus `references`) that watcher needs `--build`, type checks ZERO files without it, and reports a clean problems pane forever, which is worse than no task at all. The TSC subtask is now written only when the root tsconfig resolves inputs, `build/vscode-tasks.md` stays the owner of the per-project analysis for every other watcher, and `init` names that page in its output rather than implying the file is finished.
8
+ - **A symlinked `.vscode/` is left alone.** It is a dotfiles pattern pointing at a SHARED directory, and writing through it landed files outside the repo being wired.
9
+ - **A `repo.config.ts` with no `name` no longer kills `init` with an opaque `TypeError`.** The config loader validates nothing and hands back the raw default export, and the port arithmetic iterates the name it is given. That resolution now sits inside the same guard as the write, falls back to the directory name, and a failure there costs one WARN after everything else has already landed.
10
+ - **`shipkit start` advertises the port the hub will actually listen on.** The scaffold derived it from `package.json` while `init` names the config from the CALLER first, so a dir found holding a forked manifest got a tasks file pointing at a port nothing answers on. The caller now passes the port it resolved.
11
+
12
+ ## 0.35.6
13
+
14
+ - **`shipkit init` furnishes an adopted repo with the folder-open half of the scaffold.** The day-one shape was written only for a dir with no commit history, so every repo wired AFTER it already existed got `repo.config.ts` and the scripts and no `.vscode/` at all: opening the folder started no watcher, and the hub the config advertises had no way in. `init` now writes `.vscode/settings.json` and `.vscode/tasks.json` on both its paths, the wiring run and the re-run, through the same writers and the same templates the start scaffold uses. The split it holds: a file that changes no output of any command lands on adopt, and anything saying what the repo IS (`tsconfig.json`, `src/index.ts`) or what an agent may DO in it (the verify skill, the `.claude/` policy files, the `agent/` wiki, the `.rv-reflections/` seed) still needs an explicit caller, because a repo with a history of its own already answered those. Create only, so a `.vscode/` a repo was forked with survives byte for byte, and a second run writes nothing and says so. The advertised port is resolved from `repo.config.ts` (`serve.port`, else derived from the config name), which is where the hub reads it, rather than from the folder name or a stale `package.json`. A `.vscode` path that cannot hold the two files (a read only tree, a path that is a file) costs one WARN naming the errno: this write runs after `repo.config.ts` is on disk and before the publish chain, and it is the least important thing the verb does, so it never kills the run.
15
+
3
16
  ## 0.35.5
4
17
 
5
18
  - **The `TaskDef.inputs` doc comment stated only half the safety rule.** It said undeclared means never cached, so forgetting costs speed and never correctness, which is true for forgetting a whole task and false for the mistake that actually ships a false green: a list MISSING a path the command reads. The key cannot move when that path changes, so the answer replays over a tree it no longer describes. Observed twice in the field in one day. The comment now carries the asymmetry, names `['.']` as the declaration that cannot under declare, and says that a task carries no code closure, so the script it runs belongs in `inputs` too.
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.35.5",
3
+ "version": "0.35.7",
4
+ "funding": "https://github.com/sponsors/rvion",
4
5
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
5
6
  "license": "MIT",
6
7
  "type": "module",
@@ -8,7 +8,7 @@ export class InitCommand extends Command {
8
8
 
9
9
  static override usage = Command.Usage({
10
10
  category: 'Setup',
11
- description: 'wire THE CURRENT DIRECTORY as a shipkit repo, like git init: cwd only, never creates a project dir. scaffolds repo.config.ts + ci/check/contracts package.json scripts, prints how the floor resolved, and runs the bootstrap chain around it: git init, then first commit + PRIVATE github repo + push once the scaffold is in. Idempotent on an already-wired repo. To create or adopt ~/dev/<name> from anywhere: `shipkit start <name>`',
11
+ description: 'wire THE CURRENT DIRECTORY as a shipkit repo, like git init: cwd only, never creates a project dir. scaffolds repo.config.ts + ci/check/contracts package.json scripts + the create-only .vscode/ that starts the watchers and the hub on folder open, prints how the floor resolved, and runs the bootstrap chain around it: git init, then first commit + PRIVATE github repo + push once the scaffold is in. Idempotent on an already-wired repo. To create or adopt ~/dev/<name> from anywhere: `shipkit start <name>`',
12
12
  examples: [
13
13
  ['wire the repo you are standing in', 'shipkit init'],
14
14
  ['local git and the first commit, never touch github', 'shipkit init --no-github'],
@@ -77,8 +77,14 @@ export type CheckOpts = {
77
77
  * check's name AND its path), because the id an operator types is whichever
78
78
  * one they last saw on the wire. */
79
79
  export function matchesOnly(only: string[] | undefined, ...names: string[]): boolean {
80
- if (only == null || only.length === 0) return true
81
- return only.some((want) => names.some((n) => n.toLowerCase() === want.toLowerCase()))
80
+ if (!isNarrowed(only)) return true
81
+ return only!.some((want) => names.some((n) => n.toLowerCase() === want.toLowerCase()))
82
+ }
83
+
84
+ /** did the operator narrow this run? The ONE reading of `--only` everywhere: an
85
+ * empty list is not a selection, it is the absence of one. */
86
+ export function isNarrowed(only: string[] | undefined): boolean {
87
+ return only != null && only.length > 0
82
88
  }
83
89
 
84
90
  export type CheckRun = {
@@ -210,8 +216,11 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
210
216
  // and that can sit red for months with nothing wrong, is not part of
211
217
  // that answer at any interval. `shipkit check` still runs it, and
212
218
  // naming it in `--only` still runs it, so opting out is never a way to
213
- // make a check unreachable.
214
- if (o.gateOnly === true && !d.gate && o.only == null) continue
219
+ // make a check unreachable. "not narrowed" is an EMPTY list as much as
220
+ // an absent one: the CLI's --only is an Option.Array defaulting to [],
221
+ // so a `== null` test here reads every plain gate run as narrowed and
222
+ // opts nothing out (matchesOnly above uses the same predicate).
223
+ if (o.gateOnly === true && !d.gate && !isNarrowed(o.only)) continue
215
224
  const blocking = d.blocking ?? c.checkDefaults?.blocking
216
225
  if (blocking == null) undeclared.push(rel)
217
226
  if (o.onlyBlocking === true && blocking !== true) continue
@@ -13,10 +13,6 @@ import { runFixPass } from './run-fixers.ts'
13
13
  import { formatLine } from './wire.ts'
14
14
  import { runTasks, selectTasks, type TaskResult } from './task-runner.ts'
15
15
  import { writeStatus } from './write-status.ts'
16
- import { rmSync } from 'fs'
17
-
18
- import { SHARE_ENV } from '../release/releases-share.ts'
19
- import { collectReports } from '../release/run-releases.ts'
20
16
  import { withContractsScope } from '../contracts/contracts-once.ts'
21
17
  import { envTier, FULL_TESTS_ENV, gateUnits, type TestTier } from '../ledger/gate-units.ts'
22
18
  import { Ledger, stampReplayed, type UnitOutcome } from '../ledger/ledger.ts'
@@ -178,18 +174,6 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
178
174
  if ((o.onlyIds ?? []).includes(u.id))
179
175
  sink(` fix: add a '${u.id}:raw' script holding the tool, then \`shipkit ci --only ${u.id}\` runs it (meta/rv-conventions/package-json.md)`)
180
176
  }
181
- // the release survey is ~4s of network that five discovered checks need
182
- // and the TASKS do not. Started HERE it overlaps lint/typecheck/test and
183
- // costs the run nothing; started inside the first check that wants it, it
184
- // is 4s nobody can overlap. Failures are the checks' to report, not this
185
- // line's: a survey nobody can run just means the checks do it themselves.
186
- const sharePath = join(root, 'tmp/shipkit-ci', `releases-${process.pid}-${Date.now()}.json`)
187
- const priorShare = process.env[SHARE_ENV]
188
- process.env[SHARE_ENV] = sharePath
189
- // a NARROWED run pre-warms nothing: `--only cycles` asks one question, and
190
- // paying a 4s network survey to answer it is the same bug --only was
191
- // fixed for
192
- const willRunChecks = o.skipChecks !== true && (o.onlyIds ?? []).length === 0
193
177
  // REPLAY, before the DAG sees the list. A replayed task is removed from
194
178
  // the run entirely, which is also what makes its dependents correct: the
195
179
  // runner treats a filtered dep as satisfied, and only a GREEN is ever
@@ -212,11 +196,6 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
212
196
  // this is the same "a filtered dep counts as satisfied" the runner
213
197
  // already applies to --skip and opt-in tasks.
214
198
  .map((t) => (t.deps?.some((d) => replayedTasks.has(d)) === true ? { ...t, deps: t.deps.filter((d) => !replayedTasks.has(d)) } : t))
215
- // the pre-warm exists to hide the survey behind the TASKS. When every task
216
- // replayed there is nothing to hide it behind, and starting it anyway is a
217
- // 4s network call the run may never need: the five checks that want it
218
- // fetch it themselves, and when they replay too, nobody pays for it at all.
219
- const survey = willRunChecks && toRun.length > 0 ? collectReports(root, c).catch(() => null) : null
220
199
  // a NAMED task that replayed leaves the list, so its name has to leave the
221
200
  // filters with it: the runner validates `--only`/`--with` against the list
222
201
  // it was handed and answers `unknown task id`. `bun run typecheck` and
@@ -268,12 +247,14 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
268
247
  // a narrowed run says what it did NOT look at: a green that only ever
269
248
  // asked one question must not read like the full gate
270
249
  if ((o.onlyIds ?? []).length > 0 && o.skipChecks !== true) sink(`skip: checks not named by --only (${(o.onlyIds ?? []).join(', ')})`)
271
- if (survey != null) await survey
250
+ // no release survey is pre-warmed here. It is ~3s of registry and `gh`
251
+ // network, and the only thing that ever read it is the lib-drift probe,
252
+ // which asks what is PUBLISHED and so has no business in a gate that asks
253
+ // whether this change is good. collectChecks owns the share path, so the
254
+ // checks that do want it still pay for exactly one survey between them.
272
255
  const checks = o.skipChecks === true
273
256
  ? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
274
257
  : await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks, only: o.onlyIds, fresh: o.fresh, ledger, reportHome: false, gateOnly: true })
275
- if (priorShare == null) delete process.env[SHARE_ENV]; else process.env[SHARE_ENV] = priorShare
276
- for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }
277
258
  const color = process.stdout.isTTY && process.env.NO_COLOR == null
278
259
  for (const l of checks.lines) { collected.push(formatLine(l, false)); print(formatLine(l, color)) }
279
260
  for (const u of checks.undeclared) {
package/src/init-repo.ts CHANGED
@@ -15,6 +15,8 @@ import { reportToMotherhome } from './core/motherhome.ts'
15
15
  import { ensureGitRepo, publishRepo, type Run } from './git-bootstrap.ts'
16
16
  import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
17
17
  import { gitignorePolicy } from './rules/SK004-gitignore-policy.ts'
18
+ import { editorErgonomics } from './scaffold/project-scaffold.ts'
19
+ import { defaultHubPort } from './serve/hub-port.ts'
18
20
 
19
21
  // This lands as SOURCE in the repo being wired, so its comments answer to that
20
22
  // repo's comment style, not to this file's. Dash punctuation is banned there
@@ -94,6 +96,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
94
96
  // the repos that need ONE WAY IN most are the ones that already exist,
95
97
  // so a re-run REPAIRS rather than only seeding a fresh checkout
96
98
  await repairOneWayIn(root, cfg, p)
99
+ furnishEditor(root, cfg, p)
97
100
  const mh = reportToMotherhome(root, cfg)
98
101
  if (mh.note != null) p(mh.note)
99
102
  if (wired && opts.noPublish !== true) await publishRepo(root, { github: opts.github, gh: opts.gh, print: p })
@@ -127,6 +130,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
127
130
  const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
128
131
  p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
129
132
  await repairOneWayIn(root, defineRepo({ name }), p)
133
+ furnishEditor(root, { name }, p)
130
134
  // what the gate actually runs, in ONE line: a thin floor must be seen at
131
135
  // init time rather than discovered after a bad commit slipped through.
132
136
  // The per-task resolution is diagnostics, and a `skip:` naming a script
@@ -157,6 +161,32 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
157
161
  return 0
158
162
  }
159
163
 
164
+ /** the folder-open story, on the adopt path as well as the start one.
165
+ *
166
+ * `start` scaffolds the whole day-one shape, but only for a dir with no
167
+ * commit history, so every repo that already existed when it was wired got
168
+ * repo.config.ts and the scripts and no .vscode/ at all: opening the folder
169
+ * started nothing, and the hub the config advertises had no way in. These two
170
+ * files are the only half of that scaffold an adopted repo can be handed
171
+ * unasked, because they change no output of any command (project-scaffold.ts
172
+ * owns the line). Create-only, so a .vscode/ the repo brought with it wins. */
173
+ function furnishEditor(root: string, cfg: { name?: string; serve?: { port?: number } }, p: (line: string) => void): void {
174
+ // the least important thing init does, and it runs AFTER repo.config.ts is
175
+ // on disk: an unwritable .vscode (a read-only tree, a path that is a file),
176
+ // or a config with no name at all, must not abort the run before the
177
+ // publish chain, and must not pass in silence either. The port resolution
178
+ // is INSIDE the try for that reason: it reads a config nothing validates
179
+ try {
180
+ const name = repoName(root, undefined, cfg.name)
181
+ const port = cfg.serve?.port ?? defaultHubPort(name)
182
+ if (editorErgonomics(root, { name, port }).length === 0) return void p('ok: .vscode/ is this repo\'s own, left untouched')
183
+ p(`ok: .vscode/tasks.json written — opening the folder starts the hub on ${port}`)
184
+ p(' next: add this stack\'s own watchers (dev server, css, tests) — build/vscode-tasks.md carries the shapes')
185
+ } catch (e) {
186
+ p(`WARN no .vscode/tasks.json here: ${e instanceof Error ? e.message : String(e)} — opening the folder starts nothing, everything else landed`)
187
+ }
188
+ }
189
+
160
190
  /** ONE WAY IN, both halves, through the SAME fixers `shipkit check --fix` runs
161
191
  * (build/shipkit.md). Nothing here is a second implementation: rule SK001
162
192
  * routes the package.json scripts, rule SK013 writes the harness policy, and
@@ -10,7 +10,7 @@
10
10
  // package.json rewrite reformats the file it edits.
11
11
  // Templates live beside this file in files/ and ship in the npm tarball, so
12
12
  // none of them may name a machine-local tool or a private path.
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
13
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
14
14
  import { dirname, join } from 'path'
15
15
  import { defaultHubPort } from '../serve/hub-port.ts'
16
16
 
@@ -23,6 +23,10 @@ export type ScaffoldOpts = {
23
23
  print?: (line: string) => void
24
24
  /** the changelog's first dated entry — injected so callers stay testable */
25
25
  today?: string
26
+ /** the hub port the caller resolved for the config it is about to wire.
27
+ * Absent falls back to the manifest name, which is only right when this
28
+ * run wrote the manifest too */
29
+ port?: number
26
30
  /** write the .claude/ POLICY files: a pre-approved permission list and an
27
31
  * attribution silencer. Both are decisions about what agents may do in
28
32
  * someone's repo and what its git history says, so they are opt-in: only
@@ -99,15 +103,95 @@ function put(root: string, rel: string, body: string): boolean {
99
103
  return true
100
104
  }
101
105
 
106
+ export type ErgonomicsOpts = {
107
+ /** repo name, substituted into the templates */
108
+ name: string
109
+ /** the port the hub actually listens on, resolved by the caller from the
110
+ * repo config (`serve.port`, else the name-derived default), so the task
111
+ * advertises a url something answers on */
112
+ port: number
113
+ }
114
+
115
+ /** the folder-open story, and the ONE file of it a repo shipkit did not create
116
+ * can be handed unasked.
117
+ *
118
+ * What lands is `tasks.json` alone. `settings.json` turns eslint off and hands
119
+ * formatting to an oxc extension, so writing it into a repo that lints with
120
+ * eslint silences the linter that repo chose: that is an answer about what the
121
+ * repo IS, and a repo with a history of its own already gave it. A tasks file
122
+ * is the opposite: with no `folderOpen` task nothing starts at all, so there
123
+ * is no existing answer to overrule.
124
+ *
125
+ * What lands INSIDE it is DERIVED, never assumed. `build/vscode-tasks.md` owns
126
+ * this artifact and its method is per-project analysis, so the only subtasks
127
+ * written here are the ones true of every wired repo (the hub, whose script
128
+ * `init` writes) plus a TSC watcher when this repo actually has a project for
129
+ * one. A canned watcher pointed at a solution tsconfig (`files: []` plus
130
+ * `references`) type checks ZERO files and reports a clean problems pane
131
+ * forever, which is worse than no task. Every other watcher a stack earns
132
+ * (a dev server, css, tests) stays the analysis step the owner page describes,
133
+ * and the caller says so out loud.
134
+ *
135
+ * Create-only: an adopted repo often carries a `.vscode/` from whatever it was
136
+ * forked from, and that one wins byte for byte. Returns the paths it wrote. */
137
+ export function editorErgonomics(root: string, opts: ErgonomicsOpts): string[] {
138
+ // a symlinked .vscode is a dotfiles pattern pointing at a SHARED dir, and
139
+ // writing through it lands files outside the repo being wired
140
+ if (isLink(join(root, '.vscode'))) return []
141
+ const body = tasksTemplate({ NAME: opts.name, PORT: String(opts.port) }, typecheckableProject(root))
142
+ return put(root, '.vscode/tasks.json', body) ? ['.vscode/tasks.json'] : []
143
+ }
144
+
145
+ const isLink = (path: string): boolean => {
146
+ try {
147
+ return lstatSync(path).isSymbolicLink()
148
+ } catch {
149
+ return false
150
+ }
151
+ }
152
+
153
+ /** the tasks template with the TSC subtask REMOVED when this repo has no
154
+ * project it would check. One template, one subtraction: forking the file
155
+ * would put two copies of the house task shape in the tree. */
156
+ export function tasksTemplate(vars: Record<string, string>, withTsc: boolean): string {
157
+ const body = template('vscode-tasks.json', vars)
158
+ if (withTsc) return body
159
+ const parsed = JSON.parse(body.replace(/^\s*\/\/.*$/gm, '')) as { version: string; tasks: Array<Record<string, unknown>> }
160
+ parsed.tasks = parsed.tasks.filter((t) => t['label'] !== TSC_LABEL)
161
+ for (const t of parsed.tasks) {
162
+ const deps = t['dependsOn']
163
+ if (Array.isArray(deps)) t['dependsOn'] = deps.filter((d) => d !== TSC_LABEL)
164
+ }
165
+ return JSON.stringify(parsed, null, 3) + '\n'
166
+ }
167
+
168
+ const TSC_LABEL = '👁 TSC'
169
+
170
+ /** whether `tsc --project tsconfig.json` here would check anything at all. A
171
+ * solution config (`files: []` plus `references`) needs `--build`, which the
172
+ * house watcher does not pass, and answers zero files without it. */
173
+ export function typecheckableProject(root: string): boolean {
174
+ try {
175
+ const raw = readFileSync(join(root, 'tsconfig.json'), 'utf8')
176
+ const cfg = JSON.parse(raw.replace(/^\s*\/\/.*$/gm, '')) as { files?: unknown[]; include?: unknown[]; references?: unknown[] }
177
+ if ((cfg.references ?? []).length === 0) return true
178
+ return (cfg.files ?? []).length > 0 || (cfg.include ?? []).length > 0
179
+ } catch {
180
+ return false
181
+ }
182
+ }
183
+
102
184
  export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
103
185
  const p = opts.print ?? console.log
104
- // the hub resolves its port from the CONFIG name, which init takes from
105
- // package.json — a dir whose folder name differs would otherwise get a
106
- // tasks.json advertising a port nothing listens on
107
- const configName = packageName(root) ?? opts.name
186
+ // the hub resolves its port from repo.config, whose name `init` takes from
187
+ // the CALLER first and the manifest only after (init-repo.ts repoName). A
188
+ // dir that arrived holding a forked package.json would otherwise get a
189
+ // tasks.json advertising a port nothing listens on, so the caller that
190
+ // knows the name it is about to wire passes the port it resolves
191
+ const port = opts.port ?? defaultHubPort(packageName(root) ?? opts.name)
108
192
  const vars = {
109
193
  NAME: opts.name,
110
- PORT: String(defaultHubPort(configName)),
194
+ PORT: String(port),
111
195
  DATE: opts.today ?? new Date().toISOString().slice(0, 10),
112
196
  }
113
197
  const written: string[] = []
@@ -116,8 +200,12 @@ export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
116
200
 
117
201
  write('src/index.ts', 'export {}\n')
118
202
  write('tsconfig.json', template('tsconfig.json', vars))
203
+ // tasks AFTER the tsconfig, so the TSC subtask is derived from the project
204
+ // this run just wrote rather than from the empty dir it found
205
+ ;(editorErgonomics(root, { name: opts.name, port }).length > 0 ? written : skipped).push('.vscode/tasks.json')
206
+ // settings.json only here: it turns eslint off and hands formatting to an
207
+ // oxc extension, which is a decision only the repo's creator gets to make
119
208
  write('.vscode/settings.json', template('vscode-settings.json', vars))
120
- write('.vscode/tasks.json', template('vscode-tasks.json', vars))
121
209
  write('.claude/skills/verify/SKILL.md', template('verify-skill.md', vars))
122
210
  // a permission grant and a git-history policy are decisions about someone
123
211
  // else's repo, so they land only for a caller that declared a house, and
package/src/start-repo.ts CHANGED
@@ -10,6 +10,7 @@ import { join } from 'path'
10
10
  import { ensureGitRepo, ensureIgnoreBaseline, firstGhLine, isScratch, publishRepo, runner, type Run } from './git-bootstrap.ts'
11
11
  import { initRepo } from './init-repo.ts'
12
12
  import { scaffoldProject, TOOLCHAIN } from './scaffold/project-scaffold.ts'
13
+ import { defaultHubPort } from './serve/hub-port.ts'
13
14
 
14
15
  export type StartOpts = {
15
16
  /** parent dir for repos — default ~/dev */
@@ -115,7 +116,11 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
115
116
  // include lines: a caller that declares a house gets the house
116
117
  // defaults, a bare `shipkit start` never has its agent
117
118
  // permissions widened by a scaffold
118
- try { scaffoldProject(dir, { name, print, claudePolicy: (opts.include ?? []).length > 0 }) }
119
+ // the port comes from the name being WIRED, which is the one on the
120
+ // command line: init takes the caller's name over the manifest's,
121
+ // and a dir found holding a forked package.json carries a name
122
+ // nothing will listen on
123
+ try { scaffoldProject(dir, { name, port: defaultHubPort(name), print, claudePolicy: (opts.include ?? []).length > 0 }) }
119
124
  catch (e) { console.error(`WARN scaffold incomplete in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
120
125
  // the wired scripts call node_modules binaries, so a scaffold
121
126
  // whose scripts exit 127 is not a started repo. Skips mirror the