dsh-tower 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tools.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Tower tools: init, plan, mission, status, spawn, roster, adopt.
2
2
  import { Tower } from './tower.js'
3
+ import { runtimeConfig } from './config.js'
3
4
 
4
5
  const OUTPUT = {
5
6
  schema: { type: 'object' },
@@ -251,7 +252,7 @@ export function registerTowerTools(ctx) {
251
252
  define(ctx, {
252
253
  name: 'tower_spawn',
253
254
  description:
254
- 'Spawn a worker or reviewer for a mission. Provisions (or reuses) the mission git worktree and branch, generates a self-contained briefing (mission tasks, scope, deps, context, rules), and starts a background tower-worker subagent in that worktree. Records the agent in the roster and marks the mission active. Returns immediately unless wait=true.',
255
+ 'Spawn a worker or reviewer for a mission. Workers use the tower-worker preset; reviewers require a completed mission and use the read-only tower-reviewer preset. Provisions (or reuses) the mission git worktree and branch, generates a self-contained briefing, and starts a background subagent in that worktree. The child final report is delivered back to this session; tower_roster shows authoritative activity. Records the agent in the roster; reviewer spawns record the branch tip so a reviewer that modifies the branch cannot submit a review. Returns immediately unless wait=true.',
255
256
  parameters: {
256
257
  type: 'object',
257
258
  properties: {
@@ -272,9 +273,17 @@ export function registerTowerTools(ctx) {
272
273
  const tower = await Tower.open(args.repoRoot)
273
274
  const role = args.role ?? 'worker'
274
275
  if (!ROLE_ENUM.includes(role)) fail(`role must be one of ${ROLE_ENUM.join('|')}`)
276
+ const config = runtimeConfig()
277
+ const preset = role === 'reviewer' ? config.spawn.reviewerPreset : config.spawn.workerPreset
278
+ const model = role === 'reviewer' ? config.spawn.reviewerModel : config.spawn.workerModel
275
279
  const context = await tower.missionContext(args.missionId)
276
- if (role === 'reviewer' && !context.mission.worktree) {
277
- fail('reviewer needs an existing mission worktree; spawn a worker first')
280
+ if (role === 'reviewer') {
281
+ if (!context.mission.worktree) {
282
+ fail('reviewer needs an existing mission worktree; spawn a worker first')
283
+ }
284
+ if (context.mission.status !== 'completed') {
285
+ fail(`reviewer requires a completed mission awaiting review; ${context.mission.id} is ${context.mission.status}`)
286
+ }
278
287
  }
279
288
  const provisioned = await tower.provision({
280
289
  missionId: args.missionId,
@@ -311,25 +320,35 @@ export function registerTowerTools(ctx) {
311
320
  const subagents = ctx.get('subagents')
312
321
  if (!subagents) fail('subagents service is unavailable; cannot spawn')
313
322
  const providers = subagents.list()
314
- const provider = providers.includes('spawn') ? 'spawn' : providers[0]
323
+ const provider = config.spawn.provider || (providers.includes('spawn') ? 'spawn' : providers[0])
315
324
  if (!provider) fail('no subagent provider is registered')
325
+ const presets = ctx.get('agentPresets')
326
+ if (presets && typeof presets.list === 'function') {
327
+ const available = await presets.list()
328
+ if (!available.some((entry) => String(entry.id) === preset)) {
329
+ fail(`agent preset "${preset}" is not registered; check the Tower bundle's preset rows and restart DSH`)
330
+ }
331
+ }
332
+
333
+ const agentOptions = {}
334
+ for (const key of ['provider', 'model', 'reasoningEffort', 'maxTokens']) {
335
+ if (model && model[key] !== undefined && model[key] !== null && model[key] !== '') agentOptions[key] = model[key]
336
+ }
316
337
 
317
338
  const activation = await subagents.startActivation({
318
339
  provider,
319
340
  label: name,
320
341
  request: {
321
342
  cwd: worktree.path,
322
- agentPreset: 'tower-worker',
343
+ agentPreset: preset,
323
344
  prompt: [{ type: 'text', text: briefing }],
324
345
  parent: exec.agent,
325
- maxDepth: 1,
346
+ maxDepth: config.spawn.maxDepth ?? 1,
347
+ ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
326
348
  },
327
349
  signal: exec.signal,
328
350
  delivery: 'parent',
329
351
  })
330
- if (activation.result && typeof activation.result.catch === 'function') {
331
- activation.result.catch(() => {})
332
- }
333
352
 
334
353
  const agentId = String(activation.childId)
335
354
  const entry = await tower.recordSpawn({
@@ -342,16 +361,22 @@ export function registerTowerTools(ctx) {
342
361
  branch: worktree.branch,
343
362
  })
344
363
 
364
+ const recordOutcome = (state, detail) =>
365
+ tower.recordSpawnOutcome({ missionId: mission.id, name, state, detail }).catch(() => {})
366
+
345
367
  const report = {
346
368
  name,
347
369
  agentId,
348
370
  role,
349
371
  missionId: mission.id,
372
+ preset,
373
+ provider,
374
+ maxDepth: config.spawn.maxDepth ?? 1,
375
+ ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
350
376
  worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
351
377
  created: provisioned.created,
352
378
  briefingChars: briefing.length,
353
379
  rosterEntry: entry,
354
- provider,
355
380
  }
356
381
 
357
382
  if (args.wait === true) {
@@ -363,16 +388,32 @@ export function registerTowerTools(ctx) {
363
388
  let result
364
389
  try {
365
390
  result = await Promise.race([activation.result, timeout])
391
+ } catch (error) {
392
+ report.stopReason = 'failed'
393
+ report.diagnostic = String(error?.message ?? error)
394
+ await recordOutcome('failed', report.diagnostic)
395
+ throw error
366
396
  } finally {
367
397
  clearTimeout(timer)
368
398
  }
369
399
  if (result && result.__timeout) {
370
400
  report.timedOut = true
401
+ recordOutcome('unknown', 'spawn wait timed out; check tower_roster')
371
402
  } else {
372
403
  report.stopReason = result?.stopReason ?? null
373
404
  report.output = textOf(result?.output)
374
405
  report.diagnostic = result?.diagnostic ?? null
406
+ const state = report.stopReason === 'completed' ? 'inactive' : 'failed'
407
+ if (state === 'failed') report.diagnostic = report.diagnostic ?? report.stopReason
408
+ await recordOutcome(state, report.diagnostic ?? null)
375
409
  }
410
+ } else {
411
+ activation.result
412
+ .then((result) => {
413
+ const state = result?.stopReason === 'completed' ? 'inactive' : 'failed'
414
+ return recordOutcome(state, result?.diagnostic ?? result?.stopReason ?? null)
415
+ })
416
+ .catch((error) => recordOutcome('failed', String(error?.message ?? error)))
376
417
  }
377
418
  return report
378
419
  },
@@ -423,6 +464,27 @@ export function registerTowerTools(ctx) {
423
464
  },
424
465
  })
425
466
 
467
+ define(ctx, {
468
+ name: 'tower_diff',
469
+ description:
470
+ 'Read-only view of a mission branch for review: merge base, changed files, commits, and a capped patch against the recorded base. Safe for reviewers; it never modifies the repository.',
471
+ parameters: {
472
+ type: 'object',
473
+ properties: {
474
+ repoRoot: { type: 'string', description: 'Absolute path to the main checkout root.' },
475
+ missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
476
+ maxBytes: { type: 'integer', minimum: 1024, description: 'Patch cap in bytes (default 65536).' },
477
+ },
478
+ required: ['repoRoot', 'missionId'],
479
+ additionalProperties: false,
480
+ },
481
+ output: OUTPUT,
482
+ async execute(args) {
483
+ const tower = await Tower.open(args.repoRoot)
484
+ return tower.diff({ missionId: args.missionId, maxBytes: args.maxBytes })
485
+ },
486
+ })
487
+
426
488
  define(ctx, {
427
489
  name: 'tower_adopt',
428
490
  description:
package/lib/tower.js CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  withLock,
29
29
  shortHash,
30
30
  } from './store.js'
31
+ import { runtimeConfig } from './config.js'
31
32
 
32
33
  export const SCHEMA_VERSION = 1
33
34
  export const MISSION_STATUSES = [
@@ -69,7 +70,10 @@ const DEFAULT_MERGE_TIMEOUT_MS = 15 * 60 * 1000
69
70
  const MERGE_MAX_BUFFER = 64 * 1024 * 1024
70
71
 
71
72
  function mergeTimeoutMs(value) {
72
- if (value === undefined || value === null) return DEFAULT_MERGE_TIMEOUT_MS
73
+ if (value === undefined || value === null) {
74
+ const configured = runtimeConfig().merge.timeoutMs
75
+ return Number.isInteger(configured) && configured >= 1000 ? configured : DEFAULT_MERGE_TIMEOUT_MS
76
+ }
73
77
  const ms = Number(value)
74
78
  if (!Number.isInteger(ms) || ms < 1000) fail('timeoutMs must be an integer of at least 1000 milliseconds')
75
79
  return ms
@@ -675,6 +679,30 @@ export class Tower {
675
679
  return worktree
676
680
  }
677
681
 
682
+ /** Record that a spawned agent failed or went idle, so roster state is truthful. */
683
+ async recordSpawnOutcome(input) {
684
+ return withLock(this.paths, async () => {
685
+ const state = await this.load()
686
+ const entry = (state.roster ?? []).find((row) => row.name === input.name && row.missionId === input.missionId)
687
+ if (!entry) return null
688
+ const now = Date.now()
689
+ entry.state = input.state ?? 'failed'
690
+ entry.outcome = input.detail ? String(input.detail).slice(0, 1000) : null
691
+ entry.outcomeAt = now
692
+ state.revision += 1
693
+ state.updatedAt = now
694
+ validateState(state, this.paths.state)
695
+ await writeJsonAtomic(this.paths.state, state)
696
+ await appendJsonl(this.paths.activity, {
697
+ at: now,
698
+ type: 'spawn',
699
+ missionId: input.missionId,
700
+ text: `${entry.name} ${entry.state}${entry.outcome ? `: ${entry.outcome.slice(0, 160)}` : ''}`,
701
+ })
702
+ return entry
703
+ })
704
+ }
705
+
678
706
  async provision(input = {}) {
679
707
  const missionId = input.missionId
680
708
  if (typeof missionId !== 'string' || missionId.trim().length === 0) {
@@ -693,7 +721,9 @@ export class Tower {
693
721
  const branch = `tower/${mission.id.toLowerCase()}-${slugify(mission.title)}`
694
722
  const root = input.worktreeRoot
695
723
  ? path.resolve(input.worktreeRoot)
696
- : path.join(path.dirname(this.root), '.dsh-tower-worktrees', shortHash(this.commonDir))
724
+ : runtimeConfig().worktrees.root
725
+ ? path.resolve(runtimeConfig().worktrees.root)
726
+ : path.join(path.dirname(this.root), '.dsh-tower-worktrees', shortHash(this.commonDir))
697
727
  if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
698
728
  this.assertOutside(root)
699
729
  await ensureDir(root)
@@ -751,6 +781,7 @@ export class Tower {
751
781
  fail(`agent ${input.agentId} is already in the roster`)
752
782
  }
753
783
  const now = Date.now()
784
+ const spawnTip = input.cwd ? ((await tryGit(input.cwd, ['rev-parse', 'HEAD'])) ?? '').trim() || null : null
754
785
  const entry = {
755
786
  name: input.name,
756
787
  role: input.role,
@@ -760,6 +791,7 @@ export class Tower {
760
791
  missionId: input.missionId,
761
792
  cwd: input.cwd ?? null,
762
793
  branch: input.branch ?? null,
794
+ spawnTip,
763
795
  state: 'active',
764
796
  spawnedAt: now,
765
797
  }
@@ -1111,11 +1143,17 @@ export class Tower {
1111
1143
  await this.assertWorktree(mission.worktree)
1112
1144
  const tip = await revParse(mission.worktree.path, 'HEAD')
1113
1145
  const branch = mission.worktree.branch
1146
+ if (caller && caller.role === 'reviewer' && caller.spawnTip && caller.spawnTip !== tip) {
1147
+ fail(
1148
+ `reviewer ${caller.name} modified the mission branch after it was spawned (${caller.spawnTip.slice(0, 7)} -> ${tip.slice(0, 7)}); reviewers must be read-only — spawn a fresh reviewer`,
1149
+ )
1150
+ }
1114
1151
  const prior = (state.reviews ?? []).filter((review) => review.branch === branch)
1115
1152
  const reviewerRounds = prior.filter((review) => review.reviewer === reviewerName).length
1116
- if (reviewerRounds >= MAX_REVIEW_ROUNDS) {
1153
+ const maxReviewRounds = Number(runtimeConfig().review.maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
1154
+ if (reviewerRounds >= maxReviewRounds) {
1117
1155
  fail(
1118
- `reviewer ${reviewerName} reached the ${MAX_REVIEW_ROUNDS}-round cap for ${branch}; continue with a different reviewer`,
1156
+ `reviewer ${reviewerName} reached the ${maxReviewRounds}-round cap for ${branch}; continue with a different reviewer`,
1119
1157
  )
1120
1158
  }
1121
1159
  const round = prior.length === 0 ? 1 : Math.max(...prior.map((review) => review.round)) + 1
@@ -1330,6 +1368,37 @@ export class Tower {
1330
1368
  }
1331
1369
  }
1332
1370
 
1371
+ /** Read-only mission diff for reviewers: branch changes against the merge base. */
1372
+ async diff(input = {}) {
1373
+ const state = await this.load()
1374
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
1375
+ if (!mission) fail(`unknown mission "${input.missionId}"`)
1376
+ if (!mission.worktree) fail(`mission ${mission.id} has no worktree to diff`)
1377
+ await this.assertWorktree(mission.worktree)
1378
+ const baseTip = await revParse(this.root, state.base)
1379
+ const branchTip = await revParse(mission.worktree.path, 'HEAD')
1380
+ const mergeBase = (await git(mission.worktree.path, ['merge-base', baseTip, branchTip])).trim()
1381
+ const files = (await git(mission.worktree.path, ['diff', '--name-status', `${mergeBase}..${branchTip}`]))
1382
+ .split('\n')
1383
+ .filter(Boolean)
1384
+ const commits = (await git(mission.worktree.path, ['log', '--oneline', `${mergeBase}..${branchTip}`]))
1385
+ .split('\n')
1386
+ .filter(Boolean)
1387
+ const fullPatch = await git(mission.worktree.path, ['diff', `${mergeBase}..${branchTip}`])
1388
+ const limit = Number.isInteger(input.maxBytes) && input.maxBytes > 0 ? input.maxBytes : 64 * 1024
1389
+ return {
1390
+ missionId: mission.id,
1391
+ branch: mission.worktree.branch,
1392
+ baseTip,
1393
+ branchTip,
1394
+ mergeBase,
1395
+ files,
1396
+ commits,
1397
+ truncated: fullPatch.length > limit,
1398
+ patch: fullPatch.slice(0, limit),
1399
+ }
1400
+ }
1401
+
1333
1402
  /**
1334
1403
  * Record a merge that already landed in Git without being recorded here:
1335
1404
  * a crashed/timed-out merge, a manual merge, or a rebase that fast-forwarded
@@ -1424,7 +1493,7 @@ export class Tower {
1424
1493
  await git(
1425
1494
  this.root,
1426
1495
  ['merge', '--no-ff', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`],
1427
- { timeoutMs, maxBuffer: MERGE_MAX_BUFFER },
1496
+ { timeoutMs, maxBuffer: runtimeConfig().merge.maxBufferBytes || MERGE_MAX_BUFFER },
1428
1497
  )
1429
1498
  } catch (error) {
1430
1499
  failure = error
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tower",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "DSH-native Tower: mission planning, worktree isolation, review-gated merges, and a human-owned Tower mode.",
6
6
  "license": "MIT",
@@ -28,12 +28,18 @@
28
28
  "exports": {
29
29
  ".": "./index.js",
30
30
  "./worker": "./worker.js",
31
+ "./reviewer": "./reviewer.js",
32
+ "./skills": "./skills.js",
31
33
  "./locale/*.json": "./locale/*.json",
32
- "./icon": "./icon.svg"
34
+ "./icon": "./icon.svg",
35
+ "./package.json": "./package.json"
33
36
  },
34
37
  "files": [
35
38
  "index.js",
36
39
  "worker.js",
40
+ "reviewer.js",
41
+ "skills.js",
42
+ "skills/",
37
43
  "lib/*.js",
38
44
  "cordis.patch.yml",
39
45
  "locale/*.json",
@@ -41,7 +47,10 @@
41
47
  "README.md",
42
48
  "README.en.md",
43
49
  "CHANGELOG.md",
44
- "LICENSE"
50
+ "LICENSE",
51
+ "CONTRIBUTING.md",
52
+ "SECURITY.md",
53
+ "docs/"
45
54
  ],
46
55
  "dsh": {
47
56
  "bundle": {
@@ -50,6 +59,11 @@
50
59
  },
51
60
  "scripts": {
52
61
  "test": "node --test tests/*.test.mjs",
53
- "test:watch": "node --test --watch tests/*.test.mjs"
62
+ "test:watch": "node --test --watch tests/*.test.mjs",
63
+ "docs:check": "node scripts/check-docs.mjs",
64
+ "check": "npm test && npm run docs:check"
65
+ },
66
+ "dependencies": {
67
+ "@deepseek-ai/schemastery": "~3.18.5"
54
68
  }
55
69
  }
package/reviewer.js ADDED
@@ -0,0 +1,39 @@
1
+ // tower-reviewer preset policy: read-only review surface. The reviewer must
2
+ // never modify the mission branch: tower_review binds the verdict to the tip
3
+ // recorded when the reviewer was spawned, so a modified tip is rejected.
4
+ export const name = 'dsh-tower/reviewer'
5
+ export const inject = ['tools', 'systemPrompt']
6
+
7
+ const ALLOW = [
8
+ 'read',
9
+ 'glob',
10
+ 'grep',
11
+ 'tower_diff',
12
+ 'tower_status',
13
+ 'tower_send',
14
+ 'tower_inbox',
15
+ 'tower_finding',
16
+ 'tower_review',
17
+ 'skill',
18
+ ]
19
+
20
+ const PROTOCOL = [
21
+ 'You are an independent Tower reviewer in a DeepSeek Harness Tower workspace.',
22
+ 'You are read-only: do not write, edit, commit, rebase, or merge, and never modify the mission branch.',
23
+ 'Inspect the mission diff with tower_diff, check scope, tasks, and the reviewed tip, then submit exactly one',
24
+ 'tower_review verdict (clean + merge, or p1-/p2-items + hold/fix-then-merge) with findings and decision.',
25
+ 'You must not be the mission worker. Use tower_finding for out-of-scope issues instead of editing files.',
26
+ ].join(' ')
27
+
28
+ export function apply(ctx) {
29
+ try {
30
+ ctx.tools.restrict({ allow: ALLOW })
31
+ } catch {
32
+ // a composition without the tool registry still gets the protocol text
33
+ }
34
+ try {
35
+ ctx.systemPrompt.section({ name: 'tower:reviewer-protocol', order: 12000, text: PROTOCOL })
36
+ } catch {
37
+ // prompt assembly remains valid without the section
38
+ }
39
+ }
@@ -0,0 +1,40 @@
1
+ # Tower recovery playbook
2
+
3
+ Read `tower_preflight` and the last failure before changing anything.
4
+
5
+ ## Merge hook failed or timed out (`merge-hook-failed`)
6
+
7
+ The merge commit was not created; the repository was restored. Re-run `tower_merge`. For slow
8
+ `pre-merge-commit` hooks pass a larger `timeoutMs` (default 15 minutes).
9
+
10
+ ## Merge commit landed but the call failed (`recoveredFromFailure`)
11
+
12
+ The mission is already recorded as merged. Do not merge again; run `tower_teardown`.
13
+
14
+ ## Merge already landed earlier (`reconciled` / `alreadyMerged`)
15
+
16
+ `tower_merge` detects the existing merge commit or a branch contained in the base and records it.
17
+ Only a clean/merge review qualifies; otherwise ask for a fresh review.
18
+
19
+ ## Merge blocked with `stale-base`
20
+
21
+ Run `tower_rebase` inside the mission worktree, then `tower_preflight` and `tower_merge`.
22
+ If the branch was already contained in the base, rebase returns `alreadyMerged` and leaves it
23
+ untouched — call `tower_merge` to record it.
24
+
25
+ ## `state.lock` reports another writer
26
+
27
+ Wait, then retry. Never delete the lock while a writer may be active; if the process is gone,
28
+ remove `<git-common-dir>/dsh-tower/state.lock` deliberately and retry.
29
+
30
+ ## Worktree missing or dirty
31
+
32
+ `tower_status` shows the expected path and branch. Re-add a missing worktree only from Git if the
33
+ branch still exists; do not delete a branch you have not reviewed. Dirty worktrees require an
34
+ explicit teardown `force` decision.
35
+
36
+ ## Reset a repository's Tower state
37
+
38
+ Only when no mission is in flight and you accept losing mission history: stop all agents, remove
39
+ `<git-common-dir>/dsh-tower/`, then `tower_init` again. Worktrees and branches must be cleaned
40
+ manually.
@@ -0,0 +1,14 @@
1
+ # Tower review
2
+
3
+ You are the independent reviewer of a completed Tower mission. You are read-only.
4
+
5
+ 1. Read the mission briefing and the review binding: the reviewed tip must equal the current branch tip.
6
+ 2. Inspect the diff with `tower_diff` (files, commits, patch). Do not run write, edit, pwsh, commit, rebase, or merge.
7
+ 3. Verify: every task is met; all changes are inside the mission scope; no unrelated or generated files; no secrets; tests or evidence are adequate.
8
+ 4. Check the reviewed commit is the tip you inspected. If it moved, stop and report a blocker instead of approving.
9
+ 5. Submit exactly one `tower_review`:
10
+ - `clean` + `merge` + `findings: "none"` when everything passes.
11
+ - `p1-Nitems` or `p2-Nitems` + `hold` or `fix-then-merge` with concrete findings when it does not.
12
+ 6. Put out-of-scope observations in `tower_finding`, never in the branch.
13
+
14
+ The verdict is bound to the inspected tip; do not modify the branch.
@@ -0,0 +1,13 @@
1
+ # Tower worker protocol
2
+
3
+ You are a Tower worker: one mission, one worktree, one commit series.
4
+
5
+ 1. Read the briefing: mission id, branch, task list, scope globs, dependencies, and context.
6
+ 2. Work only inside the assigned worktree. Never modify the main checkout or other missions.
7
+ 3. Keep changes inside the scope globs; report anything else as a `tower_finding` instead of editing it.
8
+ 4. As you finish each task call `tower_mission` with `task_done` (and `note` when useful).
9
+ 5. Run the verification available to you (tests, type check, build). Record it in your final message.
10
+ 6. Commit on the mission branch with a clear message. Do not push, spawn agents, review, or merge.
11
+ 7. If blocked, call `tower_mission` with `blocker`, then stop and report exactly what is blocking.
12
+
13
+ Final message must contain: mission id, branch, every changed file, how you verified it, and anything left undone.
package/skills.js ADDED
@@ -0,0 +1,63 @@
1
+ // Bundled Tower skills: protocol, review checklist, and recovery playbook.
2
+ // The provider is mounted by the bundle patch so every agent sees the catalog;
3
+ // the short system-prompt sections stay authoritative for safety rules.
4
+ import { readFile } from 'node:fs/promises'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ export const name = 'dsh-tower/skills'
8
+ export const inject = ['skills']
9
+
10
+ const ASSETS = fileURLToPath(new URL('./skills/', import.meta.url))
11
+
12
+ const SKILLS = [
13
+ {
14
+ name: 'tower-worker-protocol',
15
+ description:
16
+ 'Follow the Tower worker workflow: work only in the assigned mission worktree, update the mission, commit the branch, and report a complete handoff. Use when spawned as a Tower worker.',
17
+ file: 'tower-worker-protocol.md',
18
+ },
19
+ {
20
+ name: 'tower-review',
21
+ description:
22
+ 'Run an independent Tower review: inspect the mission diff read-only, verify scope/tasks/reviewed tip, and submit exactly one clean or p1/p2 review verdict. Use when spawned as a Tower reviewer.',
23
+ file: 'tower-review.md',
24
+ },
25
+ {
26
+ name: 'tower-recovery',
27
+ description:
28
+ 'Recover a Tower mission from a merge hook failure, an already-landed merge, a stale branch, a stuck state lock, or a missing worktree. Use when tower_merge, tower_rebase, or tower_preflight reports a blocker.',
29
+ file: 'tower-recovery.md',
30
+ },
31
+ ]
32
+
33
+ const provider = {
34
+ name: 'dsh-tower',
35
+ list: async () =>
36
+ SKILLS.map((skill) => ({
37
+ name: skill.name,
38
+ description: skill.description,
39
+ invocation: { modelInvocable: true, userInvocable: true },
40
+ provider: 'dsh-tower',
41
+ source: 'bundled',
42
+ resourceBase: { kind: 'directory', path: ASSETS },
43
+ rank: 600,
44
+ locator: new URL(skill.file, new URL('./skills/', import.meta.url)),
45
+ })),
46
+ async get(candidate) {
47
+ const skill = SKILLS.find((entry) => entry.name === candidate.name)
48
+ if (!skill) throw new Error(`unknown dsh-tower skill: ${candidate.name}`)
49
+ return {
50
+ name: skill.name,
51
+ description: skill.description,
52
+ invocation: { modelInvocable: true, userInvocable: true },
53
+ provider: 'dsh-tower',
54
+ source: 'bundled',
55
+ resourceBase: { kind: 'directory', path: ASSETS },
56
+ content: await readFile(new URL(skill.file, new URL('./skills/', import.meta.url)), 'utf8'),
57
+ }
58
+ },
59
+ }
60
+
61
+ export function apply(ctx) {
62
+ ctx.skills.registerProvider(() => provider)
63
+ }
package/worker.js CHANGED
@@ -11,11 +11,12 @@ const ALLOW = [
11
11
  'pwsh',
12
12
  'tower_mission',
13
13
  'tower_status',
14
+ 'tower_diff',
14
15
  'tower_send',
15
16
  'tower_inbox',
16
17
  'tower_complete',
17
18
  'tower_finding',
18
- 'tower_review',
19
+ 'skill',
19
20
  ]
20
21
 
21
22
  const PROTOCOL = [
@@ -24,7 +25,7 @@ const PROTOCOL = [
24
25
  'Commit your work on the mission branch. Update the mission as you go with tower_mission',
25
26
  '(task_done, blocker, status), and put the full handoff in your final message: what changed,',
26
27
  'every file path, how you verified it, and anything left undone.',
27
- 'Do not spawn other agents and do not merge branches; the tower owns integration.',
28
+ 'Do not spawn other agents, do not review, and do not merge branches; the tower owns integration.',
28
29
  ].join(' ')
29
30
 
30
31
  export function apply(ctx) {