docks-kit 0.15.0 → 0.15.2

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.
@@ -4,14 +4,16 @@
4
4
  * the kit-managed snapshot, the effect-solutions toolchain callback, and the
5
5
  * snapshot write.
6
6
  */
7
- import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync } from "node:fs"
7
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync } from "node:fs"
8
+ import { dirname, relative, resolve } from "node:path"
8
9
  import { p, spawnProcess, writeFileIfChanged } from "./exec"
9
10
  import { bunBootstrap } from "./bun"
10
11
  import type { Ctx } from "./index"
11
- import { compareCodepoints } from "./jq"
12
+ import { compareCodepoints, isObject, parseJson } from "./jq"
12
13
  import type { EngineServices } from "./services"
13
14
  import { ensure, field } from "./toolchain"
14
15
  import { payloadText } from "../payload"
16
+ import { ExitError } from "./parseArgs"
15
17
 
16
18
  export interface SkillsState {
17
19
  present: number
@@ -26,16 +28,18 @@ export async function skillsSync(ctx: Ctx): Promise<SkillsState> {
26
28
  if (!ctx.dryRun) mkdirSync(skillsDir, { recursive: true })
27
29
 
28
30
  await syncUniversal(ctx, state, skillsDir, manifest)
29
- if (ctx.prune) await reconcileRemovals(ctx, manifest, snapshot)
31
+ const failedRemovals = ctx.prune ? await reconcileRemovals(ctx, manifest, snapshot) : []
30
32
  await syncEffectSolutionsCli(ctx)
31
- updateSnapshot(ctx, manifest, snapshot)
33
+ updateSnapshot(ctx, manifest, snapshot, failedRemovals)
32
34
  return state
33
35
  }
34
36
 
35
37
  /** skills::_skills_cli — the pinned npx package spec. */
36
38
  function skillsCli(ctx: Ctx): string {
37
- const v = field(ctx, "skills-cli", "verified")
38
- return v !== "" ? `skills@${v}` : "skills"
39
+ const version = field(ctx, "skills-cli", "verified")
40
+ if (version !== "") return `skills@${version}`
41
+ ctx.services.logger.err("Universal skills sync aborted because SoT/toolchain.json has no verified skills-cli pin")
42
+ throw new ExitError(1)
39
43
  }
40
44
 
41
45
  /** skills::_normalize_manifest — cleaned slugs, one per line. */
@@ -131,7 +135,7 @@ function healClaudeSymlink(ctx: Ctx, skillsDir: string, base: string): boolean {
131
135
  const canonical = p(skillsDir, base)
132
136
  const claudeSkillsDir = p(ctx.home, ".claude", "skills")
133
137
  const claudeLink = p(claudeSkillsDir, base)
134
- const relTarget = `../../.agents/skills/${base}`
138
+ const relTarget = relative(dirname(claudeLink), canonical)
135
139
 
136
140
  if (!isDir(canonical)) return false
137
141
 
@@ -186,31 +190,23 @@ function removeLink(path: string): boolean {
186
190
  }
187
191
  }
188
192
 
189
- /** skills::_link_or_copy — real symlink preferred, copy fallback. */
193
+ /** skills::_link_or_copy — create a symlink without replacing its source. */
190
194
  export function linkOrCopy(target: string, link: string): boolean {
195
+ const resolvedLink = resolve(link)
196
+ if (resolve(dirname(resolvedLink), target) === resolvedLink) return true
191
197
  removeLink(link)
192
198
  try {
193
199
  symlinkSync(target, link)
194
200
  } catch {
195
- // fall through to the copy fallback below
196
- }
197
- if (lstat(link)?.isSymbolicLink() === true) return true
198
- try {
199
- // Resolve a relative target against the link's parent, like ln does.
200
- const resolved = target.startsWith("/") ? target : p(link.slice(0, link.lastIndexOf("/")), target)
201
- cpSync(resolved, link, { recursive: true })
202
- } catch {
203
- // fall through to the existence check below
201
+ return false
204
202
  }
205
- return existsSync(link)
203
+ return lstat(link)?.isSymbolicLink() === true
206
204
  }
207
205
 
208
206
  function linkOrCopyWithWarnings(target: string, link: string, services: EngineServices): boolean {
209
207
  const linked = linkOrCopy(target, link)
210
208
  if (!linked) {
211
- services.logger.warn(`could not create ${link} (symlink and copy both failed)`)
212
- } else if (lstat(link)?.isSymbolicLink() !== true) {
213
- services.logger.warn(`symlinks unsupported here — ${link} is a copy refreshed on sync`)
209
+ services.logger.warn(`could not create symlink ${link}`)
214
210
  }
215
211
  return linked
216
212
  }
@@ -255,7 +251,10 @@ export function effectSolutionsInstall(
255
251
 
256
252
  async function syncEffectSolutionsCli(ctx: Ctx): Promise<void> {
257
253
  const { clearProgress, progress, warn } = ctx.services.logger
258
- if (!/"effect-kit@docks"[ \t]*:[ \t]*true/.test(payloadText("SoT/.claude/settings.json"))) return
254
+ const settings = parseJson(payloadText("SoT/.claude/settings.json"))
255
+ if (settings === undefined || !isObject(settings)) return
256
+ const enabledPlugins = settings["enabledPlugins"]
257
+ if (enabledPlugins === undefined || !isObject(enabledPlugins) || enabledPlugins["effect-kit@docks"] !== true) return
259
258
 
260
259
  progress("Checking effect-solutions CLI...")
261
260
  const result = await ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
@@ -267,7 +266,7 @@ async function syncEffectSolutionsCli(ctx: Ctx): Promise<void> {
267
266
 
268
267
  // ----------------------------------------------------- prune + snapshot ----
269
268
 
270
- async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): Promise<void> {
269
+ async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string): Promise<Array<string>> {
271
270
  const { change, clearProgress, echo, progress, warn } = ctx.services.logger
272
271
  if (!existsSync(snapshot)) {
273
272
  if (ctx.dryRun) {
@@ -275,15 +274,18 @@ async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string):
275
274
  `[dry-run] (--prune) no kit-managed-skills snapshot yet; first real sync writes ${snapshot}, then future --prune runs reconcile against it`
276
275
  )
277
276
  }
278
- return
277
+ return []
279
278
  }
280
279
 
281
280
  const current = normalizeManifest(manifest)
281
+ const currentBases = new Set(current.map((slug) => slug.slice(slug.lastIndexOf("/") + 1)))
282
282
  let removed = 0
283
283
  let failed = 0
284
+ const failedSlugs: Array<string> = []
284
285
  for (const slug of readSlugs(snapshot)) {
285
286
  if (current.includes(slug)) continue
286
287
  const base = slug.slice(slug.lastIndexOf("/") + 1)
288
+ if (currentBases.has(base)) continue
287
289
  if (ctx.dryRun) {
288
290
  echo(`[dry-run] kit-managed skill no longer in SoT — would remove: ${base}`)
289
291
  continue
@@ -298,6 +300,7 @@ async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string):
298
300
  } else {
299
301
  warn(`Failed to remove kit-managed skill: ${base}`)
300
302
  failed++
303
+ failedSlugs.push(slug)
301
304
  }
302
305
  }
303
306
 
@@ -306,13 +309,14 @@ async function reconcileRemovals(ctx: Ctx, manifest: string, snapshot: string):
306
309
  ctx.nextStepTriggers.skillsRestart = true
307
310
  }
308
311
  if (failed > 0) warn(`${failed} skill remove(s) failed — re-run with --prune or run: npx skills remove --global <name> -y`)
312
+ return failedSlugs
309
313
  }
310
314
 
311
- function updateSnapshot(ctx: Ctx, manifest: string, snapshot: string): void {
315
+ function updateSnapshot(ctx: Ctx, manifest: string, snapshot: string, failedRemovals: ReadonlyArray<string>): void {
312
316
  if (ctx.dryRun) return
313
317
 
314
318
  mkdirSync(ctx.agentsDir, { recursive: true })
315
- const sorted = [...new Set(normalizeManifest(manifest))].sort(compareCodepoints)
319
+ const sorted = [...new Set([...normalizeManifest(manifest), ...failedRemovals])].sort(compareCodepoints)
316
320
  writeFileIfChanged(snapshot, sorted.length > 0 ? `${sorted.join("\n")}\n` : "")
317
321
  }
318
322
 
@@ -155,25 +155,29 @@ export async function ensure(ctx: Ctx, tool: ToolId, installFn: InstallFn): Prom
155
155
 
156
156
  if (!present(ctx, tool)) {
157
157
  const latest = await latestVersion(ctx, tool)
158
- if (ctx.dryRun) {
159
- echo(`[dry-run] would install ${tool} (${latest !== "" ? latest : "latest"}, gated by toolchain.json verified pin)`)
160
- return 0
161
- }
162
158
  let target: string
163
159
  if (latest === "") {
164
- target = field(ctx, tool, "verified")
165
- if (target !== "" && field(ctx, tool, "pinnable") === "true") {
166
- warn(`${tool} latest version unknown (offline?) installing kit-verified ${target} instead`)
167
- } else {
168
- target = ""
169
- warn(`${tool} latest version unknown (offline?) and not pinnable — installing latest unverified`)
160
+ const verified = field(ctx, tool, "verified")
161
+ if (verified === "" || field(ctx, tool, "pinnable") !== "true") {
162
+ warn(`${tool} install skipped — latest version is unknown and no kit-verified pinnable version is available`)
163
+ return 0
170
164
  }
165
+ target = verified
166
+ if (ctx.dryRun) {
167
+ echo(`[dry-run] would install ${tool} (${target}, kit-verified fallback because latest is unknown)`)
168
+ return 0
169
+ }
170
+ warn(`${tool} latest version unknown (offline?) — installing kit-verified ${target} instead`)
171
171
  } else {
172
+ if (ctx.dryRun) {
173
+ echo(`[dry-run] would install ${tool} (${latest}, gated by toolchain.json verified pin)`)
174
+ return 0
175
+ }
172
176
  const g = await gate(ctx, tool, "install", latest)
173
177
  if (!g.proceed) return 0
174
- target = g.target
178
+ target = g.target !== "" ? g.target : latest
175
179
  }
176
- return await installFn("install", target !== "" ? target : latest, ctx.services)
180
+ return await installFn("install", target, ctx.services)
177
181
  }
178
182
 
179
183
  const installed = await installedVersion(ctx, tool)
@@ -242,7 +246,7 @@ export async function report(ctx: Ctx): Promise<void> {
242
246
  const toolId = tool as ToolId
243
247
  if (present(ctx, toolId)) {
244
248
  installed = await installedVersion(ctx, toolId)
245
- status = "ok"
249
+ status = installed === "" ? "unknown" : "ok"
246
250
  if (floor !== "" && installed !== "" && isNewer(floor, installed)) {
247
251
  status = "below-floor"
248
252
  } else if (verified !== "" && installed !== "" && isNewer(installed, verified)) {
package/cli/src/engine.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { CliError } from "effect/unstable/cli"
1
2
  import { Console, Effect } from "effect"
2
3
  import { spawnSync } from "node:child_process"
3
4
  import { runEngineNative } from "./engine-native"
5
+ import { ExitError } from "./engine-native/parseArgs"
4
6
  import { makeEngineServices } from "./engine-native/services"
5
7
  import { kitHome } from "./kitHome"
6
8
  import { DependencyManagerService, LoggerService, PlatformService } from "./services"
@@ -28,6 +30,19 @@ const requireSupportedHost = () => {
28
30
  export const compiled =
29
31
  process.argv[1] !== undefined && process.argv[1].startsWith("/$bunfs/")
30
32
 
33
+ export class EngineCaptureError extends ExitError {
34
+ constructor(readonly diagnostic: string, code: number) {
35
+ super(code)
36
+ this.name = "EngineCaptureError"
37
+ this.message = diagnostic
38
+ }
39
+ }
40
+
41
+ const failureMessage = (error: unknown): string => {
42
+ if (error instanceof Error && error.message !== "") return error.message
43
+ return typeof error === "string" && error !== "" ? error : "unknown error"
44
+ }
45
+
31
46
  export const engine = (args: ReadonlyArray<string>) =>
32
47
  Effect.gen(function* () {
33
48
  yield* requireSupportedHost()
@@ -37,7 +52,13 @@ export const engine = (args: ReadonlyArray<string>) =>
37
52
  const logger = yield* LoggerService
38
53
  const deps = yield* DependencyManagerService
39
54
  const platform = yield* PlatformService
40
- const code = yield* Effect.promise(() => runEngineNative(args, { logger, deps, platform }))
55
+ const code = yield* Effect.tryPromise({
56
+ try: () => runEngineNative(args, { logger, deps, platform }),
57
+ catch: (error) => new CliError.UserError({
58
+ cause: error,
59
+ userMessage: `engine operation '${args.join(" ") || "default"}' failed: ${failureMessage(error)}`
60
+ })
61
+ })
41
62
  if (code !== 0) {
42
63
  yield* Effect.sync(() => process.exit(code))
43
64
  }
@@ -50,19 +71,27 @@ export const engineCapture = (args: ReadonlyArray<string>) =>
50
71
  if (bashEngineRequested()) {
51
72
  return yield* bail(bashRemovedMessage, 2)
52
73
  }
53
- return yield* Effect.sync(() => {
74
+ const res = yield* Effect.sync(() =>
54
75
  // Child process (raw channel): runEngineNative writes straight to
55
- // process.stdout, so in-process capture isn't possible.
56
- const res = spawnSync(process.execPath, compiled ? [...args] : [`${kitHome()}/cli/src/main.ts`, ...args], {
76
+ // process.stdout, so in-process capture is not possible.
77
+ spawnSync(process.execPath, compiled ? [...args] : [`${kitHome()}/cli/src/main.ts`, ...args], {
57
78
  env: { ...process.env, DOCKS_KIT_ENGINE: "native-raw" },
58
79
  encoding: "utf8",
59
80
  stdio: ["ignore", "pipe", "inherit"]
60
81
  })
61
- if (res.error !== undefined || res.status !== 0) {
62
- makeEngineServices().logger.warn(`engine capture failed (${args.join(" ")} exited ${res.status ?? "spawn-error"})`)
63
- }
64
- return res.stdout ?? ""
65
- })
82
+ )
83
+ if (res.error !== undefined || res.status !== 0) {
84
+ const reasons = new Array<string>()
85
+ if (typeof res.status === "number") reasons.push(`exit ${res.status}`)
86
+ if (res.signal !== null) reasons.push(`signal ${res.signal}`)
87
+ if (res.error !== undefined) reasons.push(`spawn error: ${res.error.message}`)
88
+ if (reasons.length === 0) reasons.push("no child status")
89
+ const diagnostic = `engine capture failed for '${args.join(" ") || "default"}': ${reasons.join("; ")}`
90
+ makeEngineServices().logger.err(diagnostic)
91
+ const code = typeof res.status === "number" && res.status !== 0 ? res.status : 1
92
+ return yield* Effect.fail(new EngineCaptureError(diagnostic, code))
93
+ }
94
+ return res.stdout ?? ""
66
95
  })
67
96
 
68
97
  /** Print a message to stderr and exit — for CLI-side validation failures. */
@@ -1,7 +1,7 @@
1
1
  // Generated by cli/scripts/generate-sot-payload.ts. DO NOT EDIT.
2
2
  // Edit SoT/, notification.mp3, or package.json, then run: bun cli/scripts/generate-sot-payload.ts
3
3
 
4
- export const GENERATED_PACKAGE_VERSION = "0.15.0"
4
+ export const GENERATED_PACKAGE_VERSION = "0.15.2"
5
5
 
6
6
  export const GENERATED_PAYLOAD_TEXT = {
7
7
  "SoT/.agents/skills.txt": "# Universal AI-agent skill manifest intentionally empty.\n# Global skill discovery is opt-in: add one <owner>/<repo> slug per line.\n# EngineNative ignores comments and blank lines.\n",
@@ -12,7 +12,7 @@ export const GENERATED_PAYLOAD_TEXT = {
12
12
  "SoT/.claude/settings.json": "{\n \"$schema\": \"https://json.schemastore.org/claude-code-settings.json\",\n \"minimumVersion\": \"2.1.219\",\n \"model\": \"opus\",\n \"effortLevel\": \"high\",\n \"autoMemoryEnabled\": true,\n \"skillListingMaxDescChars\": 2048,\n \"respectGitignore\": true,\n \"cleanupPeriodDays\": 14,\n \"skillListingBudgetFraction\": 0.05,\n \"env\": {\n \"CLAUDE_CODE_MAX_OUTPUT_TOKENS\": \"64000\",\n \"CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR\": \"1\",\n \"CLAUDE_CODE_AUTO_COMPACT_WINDOW\": \"468000\",\n \"CLAUDE_CODE_NO_FLICKER\": \"1\"\n },\n \"permissions\": {\n \"defaultMode\": \"auto\",\n \"allow\": [\n \"Read\",\n \"Glob\",\n \"Grep\",\n \"WebFetch\",\n \"WebSearch\",\n \"Edit(./)\",\n \"Bash(git *)\",\n \"Bash(git add *)\",\n \"Bash(git commit *)\",\n \"Bash(git status *)\",\n \"Bash(git diff *)\",\n \"Bash(git log *)\",\n \"Bash(git branch *)\",\n \"Bash(git checkout *)\",\n \"Bash(git switch *)\",\n \"Bash(git stash *)\",\n \"Bash(git fetch *)\",\n \"Bash(git pull *)\",\n \"Bash(git tag *)\",\n \"Bash(git show *)\",\n \"Bash(git blame *)\",\n \"Bash(git worktree *)\",\n \"Bash(gh *)\",\n \"Bash(pnpm *)\",\n \"Bash(npm *)\",\n \"Bash(npx *)\",\n \"Bash(node *)\",\n \"Bash(docker *)\",\n \"Bash(docker-compose *)\",\n \"Bash(ls *)\",\n \"Bash(cat *)\",\n \"Bash(find *)\",\n \"Bash(grep *)\",\n \"Bash(head *)\",\n \"Bash(tail *)\",\n \"Bash(wc *)\",\n \"Bash(sort *)\",\n \"Bash(uniq *)\",\n \"Bash(diff *)\",\n \"Bash(which *)\",\n \"Bash(pwd *)\",\n \"Bash(date *)\",\n \"Bash(mkdir *)\",\n \"Bash(basename *)\",\n \"Bash(dirname *)\",\n \"Bash(realpath *)\",\n \"Bash(jq *)\",\n \"Bash(curl *)\",\n \"Bash(tree *)\",\n \"Bash(sed *)\",\n \"Bash(awk *)\",\n \"Bash(cut *)\",\n \"Bash(tr *)\",\n \"Bash(tee *)\",\n \"Bash(echo *)\",\n \"Bash(printf *)\",\n \"Bash(env *)\",\n \"Bash(printenv *)\",\n \"Bash(uname *)\",\n \"Bash(file *)\",\n \"Bash(stat *)\",\n \"Bash(du *)\",\n \"Bash(id *)\",\n \"Bash(whoami *)\",\n \"Bash(php *)\",\n \"Bash(composer *)\",\n \"Bash(python3 *)\",\n \"Bash(python *)\",\n \"Bash(pip *)\",\n \"Bash(pip3 *)\"\n ],\n \"deny\": [\n \"Read(**/.env)\",\n \"Read(**/.env.local)\",\n \"Read(**/secrets/**)\",\n \"Read(**/*.key)\",\n \"Read(**/*.pem)\",\n \"Read(**/*.p12)\",\n \"Read(**/.credentials*)\",\n \"Edit(**/.env)\",\n \"Edit(**/.env.local)\",\n \"Edit(**/secrets/**)\",\n \"Bash(sudo *)\",\n \"Bash(rm -rf /)\",\n \"Bash(rm -rf / *)\",\n \"Bash(rm -rf ~)\",\n \"Bash(rm -rf ~ *)\",\n \"Bash(rm -rf $HOME)\",\n \"Bash(rm -rf $HOME *)\",\n \"Bash(> /dev *)\",\n \"Bash(dd if= *)\",\n \"Bash(mkfs *)\",\n \"Bash(eval *)\",\n \"Bash(chmod 777 *)\",\n \"Bash(chmod -R 777 *)\",\n \"Bash(git push --force origin main *)\",\n \"Bash(git push --force origin master *)\",\n \"Bash(git push -f origin main *)\",\n \"Bash(git push -f origin master *)\"\n ],\n \"ask\": [\n \"Bash(git clean *)\",\n \"Bash(docker volume rm *)\",\n \"Bash(docker system prune *)\"\n ]\n },\n \"hooks\": {\n \"SessionStart\": [\n {\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"__DOCKS_KIT_BUN__\",\n \"args\": [\"__DOCKS_KIT_SESSION_START__\"],\n \"timeout\": 5\n }\n ]\n }\n ],\n \"Notification\": [\n {\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"__DOCKS_KIT_BUN__\",\n \"args\": [\"__DOCKS_KIT_NOTIFY__\"],\n \"timeout\": 10,\n \"async\": true\n }\n ]\n }\n ],\n \"PostToolUseFailure\": [\n {\n \"matcher\": \"Bash\",\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"echo '{\\\"hookSpecificOutput\\\":{\\\"hookEventName\\\":\\\"PostToolUseFailure\\\",\\\"additionalContext\\\":\\\"Last bash command failed. Repository / file state may have shifted \\u2014 re-read affected files before retrying. If the failure is a missing dependency or env mismatch, surface it to the user rather than retrying blindly.\\\"}}'\",\n \"timeout\": 5\n }\n ]\n }\n ],\n \"SubagentStop\": [\n {\n \"hooks\": [\n {\n \"type\": \"prompt\",\n \"prompt\": \"You are a quality gate for subagent outputs in a multi-agent code-analysis pipeline.\\n\\nEvaluate the subagent's `last_assistant_message` field (in the JSON below) against these requirements:\\n\\n1. ALLOW (return `{}`): Mode-selection or no-issues responses. Examples: \\\"Which mode do you prefer\\\", \\\"select an option\\\", \\\"no issues / problems / violations / blockers found\\\".\\n\\n2. ALLOW (return `{}`): Output contains at least one concrete file:line citation \\u2014 e.g. `src/auth.ts:42`, `lib/db.ts:100-115`, or path references that include line numbers.\\n\\n3. BLOCK (return `{\\\"decision\\\":\\\"block\\\",\\\"reason\\\":\\\"<one-line explanation>\\\"}`): Output claims about code or findings WITHOUT concrete file:line citations. Vague references like \\\"the auth handler\\\" or \\\"near the database code\\\" are not acceptable as the only evidence.\\n\\nSubagent invocation JSON:\\n$ARGUMENTS\\n\\nReturn ONLY the JSON decision (no commentary, no markdown fences).\",\n \"timeout\": 30\n }\n ]\n }\n ]\n },\n \"statusLine\": {\n \"type\": \"command\",\n \"command\": \"__DOCKS_KIT_STATUSLINE__\",\n \"refreshInterval\": 5\n },\n \"enabledPlugins\": {\n \"docks@docks\": true,\n \"plan-lifecycle@docks\": true,\n \"effect-kit@docks\": true,\n \"php-lsp@claude-plugins-official\": true,\n \"typescript-lsp@claude-plugins-official\": true\n },\n \"extraKnownMarketplaces\": {\n \"docks\": {\n \"source\": {\n \"source\": \"github\",\n \"repo\": \"DocksDocks/docks\"\n }\n }\n },\n \"alwaysThinkingEnabled\": true,\n \"showThinkingSummaries\": true,\n \"viewMode\": \"default\",\n \"theme\": \"dark-daltonized\",\n \"skipDangerousModePermissionPrompt\": true\n}\n",
13
13
  "SoT/.claude/bin/statusline.mjs": "const ESC = \"\\x1b[\"\nconst PIPE = `${ESC}90m | ${ESC}0m`\nconst DOT = `${ESC}90m • ${ESC}0m`\nconst DIM = `${ESC}2m${ESC}38;2;156;162;175m`\n\nfunction isRecord(value) {\n return typeof value === \"object\" && value !== null && !Array.isArray(value)\n}\n\nfunction finitePercentage(value) {\n return typeof value === \"number\" && Number.isFinite(value) && value >= 0 && value <= 100\n ? value\n : undefined\n}\n\nfunction roundHalfEven(value) {\n const lower = Math.floor(value)\n const fraction = value - lower\n if (fraction < 0.5) return lower\n if (fraction > 0.5) return lower + 1\n return lower % 2 === 0 ? lower : lower + 1\n}\n\nfunction pathBasename(path) {\n const parts = path.split(/[\\\\/]+/).filter((part) => part !== \"\")\n return parts.at(-1) ?? \"\"\n}\n\nfunction modelName(input) {\n const model = isRecord(input.model) && typeof input.model.display_name === \"string\"\n ? input.model.display_name\n : \"\"\n const suffix = model.indexOf(\" (\")\n return suffix === -1 ? model : model.slice(0, suffix)\n}\n\nfunction workingDirectory(input, cwd) {\n if (isRecord(input.workspace) && typeof input.workspace.current_dir === \"string\" && input.workspace.current_dir !== \"\") {\n return input.workspace.current_dir\n }\n if (typeof input.cwd === \"string\" && input.cwd !== \"\") return input.cwd\n return cwd\n}\n\nfunction compactWindow(env, total) {\n const raw = env.CLAUDE_CODE_AUTO_COMPACT_WINDOW\n if (typeof raw !== \"string\" || !/^[0-9]+$/.test(raw)) return total\n const parsed = Number(raw)\n return Number.isSafeInteger(parsed) && parsed >= 1000 && parsed < total ? parsed : total\n}\n\nfunction formatTokensK(value) {\n if (value < 1000) return `${value}k`\n if (value % 1000 === 0) return `${value / 1000}M`\n return `${(roundHalfEven(value / 100) / 10).toFixed(1)}M`\n}\n\nfunction contextSegment(input, env) {\n if (!isRecord(input.context_window)) return \"\"\n const used = finitePercentage(input.context_window.used_percentage)\n if (used === undefined) return \"\"\n\n const total = input.context_window.context_window_size\n if (typeof total !== \"number\" || !Number.isFinite(total) || total <= 0) {\n return `${ESC}38;2;130;160;230mctx ${roundHalfEven(used)}%${ESC}0m`\n }\n\n const usedK = roundHalfEven((used / 100) * (total / 1000))\n const effectiveK = Math.trunc(compactWindow(env, total) / 1000)\n if (effectiveK <= 0) return `${ESC}38;2;130;160;230mctx ${roundHalfEven(used)}%${ESC}0m`\n const effectivePercentage = roundHalfEven((usedK / effectiveK) * 100)\n return `${ESC}38;2;130;160;230mctx ${effectivePercentage}%${ESC}0m ${DIM}(${formatTokensK(usedK)}/${formatTokensK(effectiveK)})${ESC}0m`\n}\n\nfunction resetDelta(value, nowMs) {\n if (typeof value !== \"number\" || !Number.isFinite(value) || value < 0) return \"\"\n const seconds = Math.trunc(value) - Math.floor(nowMs / 1000)\n if (seconds <= 0) return \"now\"\n const days = Math.trunc(seconds / 86_400)\n if (days > 0) return `${days}d`\n const hours = Math.trunc((seconds % 86_400) / 3_600)\n if (hours > 0) return `${hours}h`\n return `${Math.trunc((seconds % 3_600) / 60)}m`\n}\n\nfunction quotaWindow(value, label, color, nowMs) {\n if (!isRecord(value)) return \"\"\n const used = finitePercentage(value.used_percentage)\n if (used === undefined) return \"\"\n const delta = resetDelta(value.resets_at, nowMs)\n const reset = delta === \"\" ? \"\" : ` ${DIM}(${delta})${ESC}0m`\n return `${ESC}38;2;${color}m${label} ${roundHalfEven(used)}%${ESC}0m${reset}`\n}\n\nfunction decodeStdout(result) {\n const stdout = result?.stdout\n if (stdout === undefined || stdout === null) return \"\"\n return typeof stdout === \"string\" ? stdout : stdout.toString()\n}\n\nfunction resolveBranch(directory, which, spawnSync) {\n const git = which(\"git\")\n if (typeof git !== \"string\" || git === \"\") return \"\"\n const commands = [\n [git, \"-C\", directory, \"symbolic-ref\", \"--short\", \"HEAD\"],\n [git, \"-C\", directory, \"rev-parse\", \"--short\", \"HEAD\"]\n ]\n for (const command of commands) {\n try {\n const result = spawnSync(command, { stdin: \"ignore\", stdout: \"pipe\", stderr: \"ignore\" })\n if (result?.success === true) return decodeStdout(result).trim()\n } catch {\n return \"\"\n }\n }\n return \"\"\n}\n\nexport function formatStatusline(input, options = {}) {\n if (!isRecord(input)) return \"\"\n const env = isRecord(options.env) ? options.env : process.env\n const nowMs = typeof options.nowMs === \"number\" ? options.nowMs : Date.now()\n const cwd = typeof options.cwd === \"string\" ? options.cwd : process.cwd()\n const branch = typeof options.branch === \"string\" ? options.branch : \"\"\n const directory = workingDirectory(input, cwd)\n\n const model = `${ESC}38;5;208m${ESC}1m${modelName(input)}${ESC}22m${ESC}0m`\n const folder = `${ESC}1m${ESC}38;2;76;208;222m${pathBasename(directory)}${ESC}22m${ESC}0m`\n const branchSegment = branch === \"\" ? \"\" : `${DOT}${ESC}1m${ESC}38;2;192;103;222m${branch}${ESC}22m${ESC}0m`\n const context = contextSegment(input, env)\n\n const rateLimits = isRecord(input.rate_limits) ? input.rate_limits : {}\n const fiveHour = quotaWindow(rateLimits.five_hour, \"5h\", \"100;200;200\", nowMs)\n const sevenDay = quotaWindow(rateLimits.seven_day, \"7d\", \"230;180;90\", nowMs)\n const quota = fiveHour === \"\" && sevenDay === \"\"\n ? \"\"\n : `${PIPE}${fiveHour}${fiveHour !== \"\" && sevenDay !== \"\" ? DOT : \"\"}${sevenDay}`\n\n return `${model}${PIPE}${folder}${branchSegment}${context === \"\" ? \"\" : `${PIPE}${context}`}${quota}`\n}\n\nexport async function main(options = {}) {\n const readStdin = options.readStdin ?? (() => Bun.stdin.text())\n const writeStdout = options.writeStdout ?? ((value) => process.stdout.write(value))\n let raw\n try {\n raw = await readStdin()\n } catch {\n return 0\n }\n\n let input\n try {\n input = JSON.parse(raw)\n } catch {\n return 0\n }\n if (!isRecord(input)) return 0\n\n const cwd = typeof options.cwd === \"string\" ? options.cwd : process.cwd()\n const directory = workingDirectory(input, cwd)\n const branch = resolveBranch(\n directory,\n options.which ?? ((name) => Bun.which(name)),\n options.spawnSync ?? ((argv, spawnOptions) => Bun.spawnSync(argv, spawnOptions))\n )\n const output = formatStatusline(input, {\n env: options.env ?? process.env,\n nowMs: options.nowMs ?? Date.now(),\n cwd,\n branch\n })\n if (output !== \"\") writeStdout(`${output}\\n`)\n return 0\n}\n\nif (import.meta.main) process.exit(await main())\n",
14
14
  "SoT/.claude/bin/session-start.mjs": "import { readFileSync } from \"node:fs\"\nimport { homedir } from \"node:os\"\n\nfunction isRecord(value) {\n return typeof value === \"object\" && value !== null && !Array.isArray(value)\n}\n\nfunction nonEmpty(value, fallback) {\n return typeof value === \"string\" && value !== \"\" ? value : fallback\n}\n\nfunction pad(value) {\n return String(value).padStart(2, \"0\")\n}\n\nfunction configuredEffort(home, readText) {\n try {\n const parsed = JSON.parse(readText(`${home}/.claude/settings.json`))\n return isRecord(parsed) ? nonEmpty(parsed.effortLevel, \"default\") : \"default\"\n } catch {\n return \"default\"\n }\n}\n\nfunction localZone(now) {\n const part = new Intl.DateTimeFormat(\"en-US\", { timeZoneName: \"short\" })\n .formatToParts(now)\n .find((value) => value.type === \"timeZoneName\")\n return part?.value ?? \"\"\n}\n\nexport function sessionStartLines(options = {}) {\n const env = isRecord(options.env) ? options.env : process.env\n const now = options.now instanceof Date ? options.now : new Date()\n const home = typeof options.home === \"string\" ? options.home : homedir()\n const readText = options.readText ?? ((path) => readFileSync(path, \"utf8\"))\n const weekday = new Intl.DateTimeFormat(\"en-US\", { weekday: \"long\" }).format(now)\n const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`\n const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`\n const effort = nonEmpty(env.CLAUDE_CODE_EFFORT_LEVEL, configuredEffort(home, readText))\n const context = env.CLAUDE_CODE_DISABLE_1M_CONTEXT === \"1\" ? \"200K\" : \"1M\"\n const compactWindow = nonEmpty(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW, \"full\")\n const subagent = nonEmpty(env.CLAUDE_CODE_SUBAGENT_MODEL, \"default\")\n return [\n `[CONTEXT] Current date: ${weekday}, ${date} ${time} ${localZone(now)}`,\n `[CONFIG] Context: ${context} | Compact-window: ${compactWindow} | Effort: ${effort} | Thinking: adaptive | Subagent: ${subagent}`\n ]\n}\n\nexport async function main(options = {}) {\n const writeStdout = options.writeStdout ?? ((value) => process.stdout.write(value))\n writeStdout(`${sessionStartLines(options).join(\"\\n\")}\\n`)\n return 0\n}\n\nif (import.meta.main) process.exit(await main())\n",
15
- "SoT/.claude/bin/notify.mjs": "const DEFAULT_SOUND = `${import.meta.dir}/../notification.mp3`\n\nexport function selectPlayer(options = {}) {\n const platform = options.platform ?? process.platform\n const sound = options.sound ?? DEFAULT_SOUND\n const which = options.which ?? ((name) => Bun.which(name))\n if (platform === \"darwin\") {\n const afplay = which(\"afplay\")\n if (typeof afplay === \"string\" && afplay !== \"\") return [afplay, sound]\n }\n const ffplay = which(\"ffplay\")\n if (typeof ffplay === \"string\" && ffplay !== \"\") {\n return [ffplay, \"-nodisp\", \"-autoexit\", \"-loglevel\", \"quiet\", sound]\n }\n const paplay = which(\"paplay\")\n if (typeof paplay === \"string\" && paplay !== \"\") return [paplay, sound]\n const aplay = which(\"aplay\")\n if (typeof aplay === \"string\" && aplay !== \"\") return [aplay, \"-q\", sound]\n return undefined\n}\n\nexport async function main(options = {}) {\n const sound = options.sound ?? DEFAULT_SOUND\n const fileExists = options.fileExists ?? ((path) => Bun.file(path).exists())\n if (!await fileExists(sound)) return 0\n const command = selectPlayer({ ...options, sound })\n if (command === undefined) return 0\n const spawnSync = options.spawnSync ?? ((argv, spawnOptions) => Bun.spawnSync(argv, spawnOptions))\n const result = spawnSync(command, { stdin: \"ignore\", stdout: \"ignore\", stderr: \"ignore\" })\n return typeof result.exitCode === \"number\" ? result.exitCode : 1\n}\n\nif (import.meta.main) process.exit(await main())\n",
15
+ "SoT/.claude/bin/notify.mjs": "const DEFAULT_SOUND = `${import.meta.dir}/../notification.mp3`\n\nexport function selectPlayer(options = {}) {\n const platform = options.platform ?? process.platform\n const sound = options.sound ?? DEFAULT_SOUND\n const which = options.which ?? ((name) => Bun.which(name))\n if (platform === \"darwin\") {\n const afplay = which(\"afplay\")\n if (typeof afplay === \"string\" && afplay !== \"\") return [afplay, sound]\n }\n const ffplay = which(\"ffplay\")\n if (typeof ffplay === \"string\" && ffplay !== \"\") {\n return [ffplay, \"-nodisp\", \"-autoexit\", \"-loglevel\", \"quiet\", sound]\n }\n const paplay = which(\"paplay\")\n if (typeof paplay === \"string\" && paplay !== \"\") return [paplay, sound]\n const aplay = which(\"aplay\")\n if (typeof aplay === \"string\" && aplay !== \"\") return [aplay, \"-q\", sound]\n return undefined\n}\n\nexport async function main(options = {}) {\n const sound = options.sound ?? DEFAULT_SOUND\n const fileExists = options.fileExists ?? ((path) => Bun.file(path).exists())\n if (!await fileExists(sound)) return 0\n const command = selectPlayer({ ...options, sound })\n if (command === undefined) return 0\n const spawnSync = options.spawnSync ?? ((argv, spawnOptions) => Bun.spawnSync(argv, spawnOptions))\n spawnSync(command, { stdin: \"ignore\", stdout: \"ignore\", stderr: \"ignore\" })\n return 0\n}\n\nif (import.meta.main) process.exit(await main())\n",
16
16
  "SoT/.codex/AGENTS.md": "# AGENTS.md\n\n## Research Before Implementation\n\nBefore writing or modifying code that uses an API, hook, method, or config surface you have not verified in this session, research current documentation first.\n\nResearch workflow:\n1. Prefer official documentation and primary sources for the specific library, framework, or API.\n2. If a local docs or MCP tool is available, use it before broad web search.\n3. Only then proceed to implementation.\n\nResearch when:\n- Installing or configuring a dependency.\n- Using an API, hook, method, or pattern not verified in this session.\n- Upgrading or migrating between versions.\n- Any task where relying on memory could cause stale syntax or behavior.\n\nDo not:\n- Assume API signatures, method names, or config options from memory.\n- Generate framework code without checking current docs first.\n- Skip research because the library seems familiar.\n\n<constraint>\nResearch the codebase before editing. Never change code you have not read.\n</constraint>\n\n## Agentic Harness Heuristics\n\nModel-agnostic operating rules for coding-agent work.\n\n1. Persistence. Keep going until the user's request is actually handled. Only yield when the problem is solved or a concrete blocker is identified. Resolve in the fewest useful tool loops — once you can answer the core request with evidence, answer. Before ending a turn, check the last paragraph: if it is a plan, a question you can answer yourself, or a promise of work not done, do that work now.\n2. Default to parallel. When multiple reads, searches, inspections, or independent checks can run without depending on each other, run them together.\n3. Multi-pass search. First-pass search often misses — vary the wording before concluding something does not exist.\n4. Trace symbols. Before modifying a symbol, trace its definition and usages. Do not infer behavior from one call site.\n5. Linter-loop 3-strike rule. Do not loop more than 3 times fixing the same lint/test failure without reassessing the diagnosis.\n6. Read-before-edit TTL. If you have not read a file recently, re-read it before editing. User edits can make cached context stale.\n7. Big-file rule. For files over 1000 lines, prefer targeted search plus scoped reads over whole-file reads.\n8. Task hygiene. Track meaningful deliverables, not operational sub-steps. Mark work complete as soon as it is done.\n9. Literal-instruction rule. Treat explicit user requirements as checklists with success criteria. Do not silently broaden scope.\n10. Context hygiene. Prefer a fresh session at task boundaries over carrying stale context; preserve useful state before quality decays. Never stop, summarize, or suggest a new session on account of context limits.\n11. Autonomy calibration. For minor choices (naming, formatting, defaults, equivalent approaches), pick a reasonable option and note it — do not ask. Ask first only for scope changes, destructive actions, or decisions that change the deliverable. When the user is describing a problem or asking a question rather than requesting a change, the deliverable is your assessment — report findings and stop; do not apply fixes until asked.\n12. Capability triggering. Search or fetch current documentation when the answer depends on current or version-specific information. When work fans out across independent items, parallelize or delegate; never delegate work you can complete directly. For verification, prefer a fresh-context check over self-critique. On long tasks, keep running notes and re-read them between phases.\n\n<constraint>\nTreat these heuristics as protocol. If a turn violates an applicable rule, self-correct before continuing.\n</constraint>\n\nReuse before invention: inventory existing code, components, conventions, and dependencies; extend them instead of creating a parallel pattern. Load only the narrow skills supported by the task and repository evidence. If a request establishes a new React/Tailwind system and no convention exists, prefer current shadcn/ui `base-*` components backed by Base UI; otherwise preserve the existing stack. Treat probe, production access, publish, push, release, and deploy as literal current-request effects—never infer external authority from a plan, schedule, review, or old receipt.\n\nFor Docks plan reviews, cross-company review is standing-authorized; do not ask for export consent. This never overrides a host or platform security denial.\n\nUse a narrow-to-broad verification ladder: direct acceptance while iterating, focused regressions next, and one full CI at the pre-commit or release boundary. Reuse still-matching evidence; rerun full CI only after a relevant edit invalidates it.\n\n<constraint>\nNo secrets in committed config. Treat plugin marketplaces, installers, and downloaded artifacts as untrusted until verified.\n</constraint>\n",
17
17
  "SoT/.codex/config.toml": "model = \"gpt-5.6-sol\"\nmodel_reasoning_effort = \"high\"\nplan_mode_reasoning_effort = \"high\"\nmodel_reasoning_summary = \"concise\"\nmodel_verbosity = \"low\"\npersonality = \"pragmatic\"\nweb_search = \"live\"\nproject_doc_max_bytes = 131072\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\napprovals_reviewer = \"auto_review\"\n\n[sandbox_workspace_write]\nnetwork_access = true\n\n[features]\nmemories = true\n\n[memories]\ndedicated_tools = true\nmax_rollout_age_days = 30\n\n[agents]\nmax_threads = 12\nmax_depth = 2\n\n[tui]\nstatus_line_use_colors = true\nstatus_line = [\n \"model-with-reasoning\",\n \"current-dir\",\n \"git-branch\",\n \"context-used\",\n \"five-hour-limit\",\n \"weekly-limit\",\n]\n\n[plugins.\"docks@docks\"]\nenabled = true\n\n[plugins.\"plan-lifecycle@docks\"]\nenabled = true\n\n[plugins.\"effect-kit@docks\"]\nenabled = true\n",
18
18
  "SoT/.codex/plugins/marketplace.json": "{\n \"name\": \"docks\",\n \"interface\": {\n \"displayName\": \"DocksDocks\"\n },\n \"plugins\": [\n {\n \"name\": \"docks\",\n \"source\": {\n \"source\": \"git-subdir\",\n \"url\": \"https://github.com/DocksDocks/docks.git\",\n \"path\": \"./plugins/docks\",\n \"ref\": \"main\"\n },\n \"policy\": {\n \"installation\": \"AVAILABLE\",\n \"authentication\": \"ON_INSTALL\"\n },\n \"category\": \"Productivity\"\n },\n {\n \"name\": \"effect-kit\",\n \"source\": {\n \"source\": \"git-subdir\",\n \"url\": \"https://github.com/DocksDocks/docks.git\",\n \"path\": \"./plugins/effect-kit\",\n \"ref\": \"main\"\n },\n \"policy\": {\n \"installation\": \"AVAILABLE\",\n \"authentication\": \"ON_INSTALL\"\n },\n \"category\": \"Productivity\"\n },\n {\n \"name\": \"plan-lifecycle\",\n \"source\": {\n \"source\": \"git-subdir\",\n \"url\": \"https://github.com/DocksDocks/docks.git\",\n \"path\": \"./plugins/plan-lifecycle\",\n \"ref\": \"main\"\n },\n \"policy\": {\n \"installation\": \"AVAILABLE\",\n \"authentication\": \"ON_INSTALL\"\n },\n \"category\": \"Productivity\"\n }\n ]\n}\n",
@@ -40,4 +40,4 @@ export const GENERATED_PAYLOAD_PATHS = [
40
40
  "notification.mp3"
41
41
  ] as const
42
42
 
43
- export const GENERATED_PAYLOAD_HASH = "dd259569b864fe7c2e1c54e8e0155d529b1e2f0f2b515f32178976c55ff9e7e3"
43
+ export const GENERATED_PAYLOAD_HASH = "66c1b63b01b6c1ca6a39a1fe96ed8cda72774e9dcc6bfdc0a4ad6abe9f3a028e"
@@ -10,26 +10,89 @@ const isKitHome = (dir: string): boolean => {
10
10
  }
11
11
  }
12
12
 
13
- /**
14
- * Resolve the optional checkout/package home used for display and updates:
15
- * DOCKS_KIT_HOME env → nearest ancestor of cwd (repo-checkout usage) →
16
- * the package's own root (bunx / bun add -g usage) → standalone executable
17
- * directory. Payload availability is independent of this location.
18
- */
19
- export const kitHome = (): string => {
20
- const env = process.env["DOCKS_KIT_HOME"]
21
- if (env !== undefined && env !== "") {
22
- if (isKitHome(env)) return resolve(env)
23
- throw new Error(`DOCKS_KIT_HOME=${env} is not a docks-kit package root (package.json name must be "docks-kit")`)
13
+ const explicitKitHome = (dir: string, source: string): string => {
14
+ const packageJson = join(dir, "package.json")
15
+ let text: string
16
+ try {
17
+ text = readFileSync(packageJson, "utf8")
18
+ } catch (error) {
19
+ const code =
20
+ typeof error === "object" && error !== null && "code" in error
21
+ ? String(error.code)
22
+ : undefined
23
+ if (code === "ENOENT") {
24
+ throw new Error(`DOCKS_KIT_HOME=${source} does not contain package.json`)
25
+ }
26
+ const detail = error instanceof Error ? error.message : String(error)
27
+ const category = code === undefined ? "" : ` (${code})`
28
+ throw new Error(
29
+ `DOCKS_KIT_HOME=${source} package.json cannot be read${category}: ${detail}`
30
+ )
31
+ }
32
+
33
+ let manifest: unknown
34
+ try {
35
+ manifest = JSON.parse(text)
36
+ } catch (error) {
37
+ const detail = error instanceof Error ? error.message : String(error)
38
+ throw new Error(`DOCKS_KIT_HOME=${source} package.json contains invalid JSON: ${detail}`)
39
+ }
40
+
41
+ if (
42
+ typeof manifest !== "object" ||
43
+ manifest === null ||
44
+ !("name" in manifest) ||
45
+ manifest.name !== "docks-kit"
46
+ ) {
47
+ throw new Error(
48
+ `DOCKS_KIT_HOME=${source} is not a docks-kit package root (package.json name must be "docks-kit")`
49
+ )
24
50
  }
25
- let dir = process.cwd()
51
+ return dir
52
+ }
53
+
54
+ const findKitHome = (start: string | undefined): string | undefined => {
55
+ if (start === undefined || start === "") return undefined
56
+ let dir = resolve(start)
26
57
  for (;;) {
27
58
  if (isKitHome(dir)) return dir
28
59
  const parent = dirname(dir)
29
- if (parent === dir) break
60
+ if (parent === dir) return undefined
30
61
  dir = parent
31
62
  }
32
- const packageRoot = resolve(import.meta.dir, "..", "..")
33
- if (isKitHome(packageRoot)) return packageRoot
34
- return dirname(process.execPath)
35
63
  }
64
+
65
+ export interface KitHomeSources {
66
+ readonly env: string | undefined
67
+ /** `import.meta.dir` is a Bun extension, so a non-Bun loader leaves it undefined. */
68
+ readonly moduleDir: string | undefined
69
+ readonly execPath: string
70
+ readonly cwd: string
71
+ }
72
+
73
+ /**
74
+ * Resolve DOCKS_KIT_HOME, then the nearest kit ancestor of the module,
75
+ * executable, or working directory, then the executable directory. Running
76
+ * installation sources take priority so an unrelated checkout cannot replace
77
+ * the installation that is executing.
78
+ */
79
+ export const resolveKitHome = (sources: KitHomeSources): string => {
80
+ if (sources.env !== undefined && sources.env !== "") {
81
+ return explicitKitHome(resolve(sources.env), sources.env)
82
+ }
83
+
84
+ return (
85
+ findKitHome(sources.moduleDir) ??
86
+ findKitHome(dirname(sources.execPath)) ??
87
+ findKitHome(sources.cwd) ??
88
+ dirname(sources.execPath)
89
+ )
90
+ }
91
+
92
+ export const kitHome = (): string =>
93
+ resolveKitHome({
94
+ env: process.env["DOCKS_KIT_HOME"],
95
+ moduleDir: import.meta.dir,
96
+ execPath: process.execPath,
97
+ cwd: process.cwd()
98
+ })
package/cli/src/main.ts CHANGED
@@ -14,6 +14,7 @@ import { toolchainCommand } from "./commands/toolchain"
14
14
  import { updateCommand } from "./commands/update"
15
15
  import { GENERATED_PACKAGE_VERSION } from "./generated/sotPayload"
16
16
  import { prepareArgv } from "./argv"
17
+ import { runEngineNative } from "./engine-native"
17
18
 
18
19
 
19
20
  const root = Command.make("docks-kit", {}, () =>
@@ -64,8 +65,17 @@ const bareVersionFormatter: CliOutput.Formatter = {
64
65
  // PUBLIC engine execution lives at the engine.ts seam after the CLI has
65
66
  // parsed/normalized pickers, --flag value forms, and non-engine commands.
66
67
  if (process.env["DOCKS_KIT_ENGINE"] === "native-raw") {
67
- const { runEngineNative } = await import("./engine-native")
68
- process.exit(await runEngineNative(process.argv.slice(2)))
68
+ const rawArgs = process.argv.slice(2)
69
+ const rawOperation = rawArgs.join(" ") || "default"
70
+ try {
71
+ process.exit(await runEngineNative(rawArgs))
72
+ } catch (error) {
73
+ let detail = "unknown error"
74
+ if (error instanceof Error && error.message !== "") detail = error.message
75
+ else if (typeof error === "string" && error !== "") detail = error
76
+ process.stderr.write(`docks-kit raw operation '${rawOperation}' failed: ${detail}\n`)
77
+ process.exit(1)
78
+ }
69
79
  }
70
80
 
71
81
  // Validate and normalize before parsing because the kit refuses to guess at unrecognized
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, readdirSync, readlinkSync, existsSync } from "node:fs"
2
2
  import { homedir } from "node:os"
3
- import { join } from "node:path"
3
+ import { dirname, join, resolve } from "node:path"
4
+ import { pluginUserScopeInstalled } from "./engine-native/claudeSync"
4
5
  import { payloadText } from "./payload"
5
6
 
6
7
  export { homedir }
@@ -36,18 +37,22 @@ export const deployedClaudeSettings = (): any | undefined => {
36
37
  return existsSync(p) ? readJson(p) : undefined
37
38
  }
38
39
 
39
- const tomlModelText = (text: string): string | undefined => {
40
- const m = text.match(/^model\s*=\s*"([^"]+)"/m)
41
- return m?.[1]
40
+ export function topLevelTomlString(text: string, setting: string): string | undefined {
41
+ for (const line of text.split(/\r?\n/)) {
42
+ if (/^\s*\[/.test(line)) return undefined
43
+ const assignment = line.match(/^\s*([A-Za-z0-9_-]+)\s*=\s*"([^"]+)"/)
44
+ if (assignment?.[1] === setting) return assignment[2]
45
+ }
46
+ return undefined
42
47
  }
43
48
 
44
49
  const tomlModel = (path: string): string | undefined => {
45
50
  if (!existsSync(path)) return undefined
46
- return tomlModelText(readFileSync(path, "utf8"))
51
+ return topLevelTomlString(readFileSync(path, "utf8"), "model")
47
52
  }
48
53
 
49
54
  export const sotCodexModel = (): string | undefined =>
50
- tomlModelText(payloadText("SoT/.codex/config.toml"))
55
+ topLevelTomlString(payloadText("SoT/.codex/config.toml"), "model")
51
56
 
52
57
  export const deployedCodexModel = (): string | undefined =>
53
58
  tomlModel(join(homedir(), ".codex", "config.toml"))
@@ -67,7 +72,7 @@ export const pluginsView = (): Array<{
67
72
  return [...names].sort().map((plugin) => ({
68
73
  plugin,
69
74
  sot: plugin in sot ? (sot[plugin] ? "true" : "false") : "absent",
70
- installed: plugin in installed
75
+ installed: pluginUserScopeInstalled(installedPath, plugin)
71
76
  }))
72
77
  }
73
78
 
@@ -83,7 +88,8 @@ export const skillsView = (): Array<{
83
88
  .map((l) => l.replace(/#.*$/, "").trim())
84
89
  .filter((l) => l.length > 0)
85
90
  .map((slug) => slug.split("/").pop() as string)
86
- const skillsDir = join(homedir(), ".agents", "skills")
91
+ const home = homedir()
92
+ const skillsDir = join(home, ".agents", "skills")
87
93
  const installed = existsSync(skillsDir)
88
94
  ? readdirSync(skillsDir, { withFileTypes: true })
89
95
  .filter((e) => e.isDirectory())
@@ -91,10 +97,11 @@ export const skillsView = (): Array<{
91
97
  : []
92
98
  const names = new Set([...declared, ...installed])
93
99
  return [...names].sort().map((skill) => {
94
- const link = join(homedir(), ".claude", "skills", skill)
100
+ const link = join(home, ".claude", "skills", skill)
95
101
  let claudeSymlink = false
96
102
  try {
97
- claudeSymlink = readlinkSync(link).includes(".agents/skills")
103
+ const target = resolve(dirname(link), readlinkSync(link))
104
+ claudeSymlink = target === resolve(skillsDir, skill) && existsSync(target)
98
105
  } catch {
99
106
  /* not a symlink or missing */
100
107
  }
@@ -1,5 +1,3 @@
1
- import { existsSync } from "node:fs"
2
- import { join } from "node:path"
3
1
  import {
4
2
  GENERATED_PAYLOAD_BASE64,
5
3
  GENERATED_PAYLOAD_PATHS,
@@ -22,7 +20,6 @@ export function payloadPaths(prefix: string): ReadonlyArray<PayloadPath> {
22
20
  return GENERATED_PAYLOAD_PATHS.filter((path) => path.startsWith(prefix))
23
21
  }
24
22
 
25
- export function payloadDisplayPath(path: PayloadPath, kitHome?: string): string {
26
- if (kitHome === undefined || !existsSync(join(kitHome, "package.json"))) return `embedded:${path}`
27
- return `${kitHome.replace(/[\\/]+$/, "")}/${path}`
23
+ export function payloadDisplayPath(path: PayloadPath): string {
24
+ return `embedded:${path}`
28
25
  }
package/docks-kit CHANGED
@@ -30,12 +30,12 @@ if [[ -x "$REPO_DIR/cli/dist/$KIT_BIN" ]]; then
30
30
  break
31
31
  fi
32
32
  done < "$REPO_DIR/package.json"
33
- IFS= read -r BIN_VERSION < <("$REPO_DIR/cli/dist/$KIT_BIN" --version 2>/dev/null || true)
33
+ BIN_VERSION="$("$REPO_DIR/cli/dist/$KIT_BIN" --version 2>/dev/null || true)"
34
34
  BIN_VERSION="${BIN_VERSION%$'\r'}"
35
- if [[ -z "$CHECKOUT_VERSION" || "$BIN_VERSION" == "$CHECKOUT_VERSION" ]]; then
35
+ if [[ -n "$CHECKOUT_VERSION" && "$BIN_VERSION" == "$CHECKOUT_VERSION" ]]; then
36
36
  exec "$REPO_DIR/cli/dist/$KIT_BIN" "$@"
37
37
  fi
38
- echo "[docks-kit] ignoring stale cli/dist/$KIT_BIN ${BIN_VERSION:-<unknown>}; checkout is $CHECKOUT_VERSION — running from source" >&2
38
+ echo "[docks-kit] ignoring stale cli/dist/$KIT_BIN ${BIN_VERSION:-<unknown>}; checkout is ${CHECKOUT_VERSION:-<unknown>} — running from source" >&2
39
39
  fi
40
40
 
41
41
  # Locate bun: PATH, then the known install locations that sit off the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docks-kit",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "description": "Portable AI coding agent config kit — SoT sync engine + typed CLI for Claude Code, Codex, and universal agent skills",
5
5
  "type": "module",
6
6
  "license": "MIT",