dsh-tower 1.1.2 → 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/CHANGELOG.md +37 -0
- package/CONTRIBUTING.md +36 -0
- package/README.en.md +48 -23
- package/README.md +44 -21
- package/SECURITY.md +20 -0
- package/cordis.patch.yml +22 -0
- package/docs/README.md +13 -0
- package/docs/architecture/overview.md +66 -0
- package/docs/examples/first-mission.md +58 -0
- package/docs/faq.md +43 -0
- package/docs/guide/install-upgrade-migration.md +48 -0
- package/docs/guide/quickstart.md +65 -0
- package/docs/operations/runbook.md +57 -0
- package/docs/operations/security.md +39 -0
- package/docs/reference/blockers.md +50 -0
- package/docs/reference/config.md +51 -0
- package/docs/reference/tools.md +33 -0
- package/index.js +33 -37
- package/lib/config.js +119 -0
- package/lib/git.js +7 -1
- package/lib/modeStore.js +19 -15
- package/lib/tools.js +80 -13
- package/lib/tower.js +315 -19
- package/package.json +18 -4
- package/reviewer.js +39 -0
- package/skills/tower-recovery.md +40 -0
- package/skills/tower-review.md +14 -0
- package/skills/tower-worker-protocol.md +13 -0
- package/skills.js +63 -0
- package/worker.js +3 -2
package/lib/modeStore.js
CHANGED
|
@@ -1,29 +1,32 @@
|
|
|
1
1
|
// Tower mode persistence.
|
|
2
2
|
//
|
|
3
3
|
// The session-projection route needs zod schemas, which this bundle cannot
|
|
4
|
-
// resolve from the DSH installation; a small atomic JSON
|
|
5
|
-
// DSH_HOME/tower-mode keeps the mode across restarts
|
|
6
|
-
// dependency or touching profile state.
|
|
4
|
+
// resolve from the DSH installation on every deployment; a small atomic JSON
|
|
5
|
+
// store under DSH_HOME/tower-mode keeps the mode across restarts.
|
|
7
6
|
import fs from 'node:fs'
|
|
8
7
|
import os from 'node:os'
|
|
9
8
|
import path from 'node:path'
|
|
10
9
|
|
|
11
|
-
const
|
|
10
|
+
const DEFAULT_MAX_ENTRIES = 200
|
|
12
11
|
|
|
13
|
-
function
|
|
12
|
+
function defaultStoreDir() {
|
|
14
13
|
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
15
14
|
return path.join(home, 'tower-mode')
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
function
|
|
19
|
-
return path.
|
|
17
|
+
function storeDir(baseDir) {
|
|
18
|
+
return baseDir && String(baseDir).trim() ? path.resolve(String(baseDir)) : defaultStoreDir()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function storeFile(baseDir) {
|
|
22
|
+
return path.join(storeDir(baseDir), 'modes.json')
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
/** Read persisted modes into a Map keyed by agent/session id. */
|
|
23
|
-
export function loadModes() {
|
|
26
|
+
export function loadModes(baseDir) {
|
|
24
27
|
const modes = new Map()
|
|
25
28
|
try {
|
|
26
|
-
const raw = fs.readFileSync(storeFile(), 'utf8')
|
|
29
|
+
const raw = fs.readFileSync(storeFile(baseDir), 'utf8')
|
|
27
30
|
const parsed = JSON.parse(raw)
|
|
28
31
|
const entries = parsed && typeof parsed === 'object' && parsed.modes && typeof parsed.modes === 'object'
|
|
29
32
|
? parsed.modes
|
|
@@ -45,15 +48,16 @@ export function loadModes() {
|
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
/** Persist the current modes atomically; failures never break the command. */
|
|
48
|
-
export function saveModes(modes) {
|
|
51
|
+
export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
49
52
|
try {
|
|
50
|
-
const dir = storeDir()
|
|
53
|
+
const dir = storeDir(baseDir)
|
|
51
54
|
fs.mkdirSync(dir, { recursive: true })
|
|
55
|
+
const limit = Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : DEFAULT_MAX_ENTRIES
|
|
52
56
|
const entries = [...modes.entries()]
|
|
53
57
|
.sort((a, b) => (Number(b[1]?.at) || 0) - (Number(a[1]?.at) || 0))
|
|
54
|
-
.slice(0,
|
|
58
|
+
.slice(0, limit)
|
|
55
59
|
const payload = { version: 1, modes: Object.fromEntries(entries) }
|
|
56
|
-
const file = storeFile()
|
|
60
|
+
const file = storeFile(baseDir)
|
|
57
61
|
const tmp = `${file}.tmp-${process.pid}`
|
|
58
62
|
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
|
|
59
63
|
fs.renameSync(tmp, file)
|
|
@@ -63,6 +67,6 @@ export function saveModes(modes) {
|
|
|
63
67
|
}
|
|
64
68
|
}
|
|
65
69
|
|
|
66
|
-
export function storePath() {
|
|
67
|
-
return storeFile()
|
|
70
|
+
export function storePath(baseDir) {
|
|
71
|
+
return storeFile(baseDir)
|
|
68
72
|
}
|
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
|
|
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'
|
|
277
|
-
|
|
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:
|
|
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:
|
|
@@ -732,12 +794,17 @@ export function registerTowerTools(ctx) {
|
|
|
732
794
|
define(ctx, {
|
|
733
795
|
name: 'tower_merge',
|
|
734
796
|
description:
|
|
735
|
-
'Merge a mission branch into the recorded base branch with --no-ff, after the hard gate passes. Verifies the two merge parents and records the mission as merged. A failed gate returns stable blocker codes
|
|
797
|
+
'Merge a mission branch into the recorded base branch with --no-ff, after the hard gate passes. Verifies the two merge parents and records the mission as merged. If the merge commit already landed (manual merge, crashed or slow pre-merge hook), it detects the merge commit and records it as reconciled instead of merging again. A failed gate returns stable blocker codes without merging; a hook failure aborts cleanly and returns "merge-hook-failed" while a real content conflict returns "merge-conflict".',
|
|
736
798
|
parameters: {
|
|
737
799
|
type: 'object',
|
|
738
800
|
properties: {
|
|
739
801
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
740
802
|
missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
|
|
803
|
+
timeoutMs: {
|
|
804
|
+
type: 'integer',
|
|
805
|
+
minimum: 1000,
|
|
806
|
+
description: 'Optional timeout for the merge command in milliseconds, including pre-merge hooks (default 15 minutes).',
|
|
807
|
+
},
|
|
741
808
|
},
|
|
742
809
|
required: ['repoRoot', 'missionId'],
|
|
743
810
|
additionalProperties: false,
|
|
@@ -746,14 +813,14 @@ export function registerTowerTools(ctx) {
|
|
|
746
813
|
async execute(args) {
|
|
747
814
|
const tower = await Tower.open(args.repoRoot)
|
|
748
815
|
const { activity } = await observeActivity(ctx, tower)
|
|
749
|
-
return tower.merge({ missionId: args.missionId, activity })
|
|
816
|
+
return tower.merge({ missionId: args.missionId, activity, timeoutMs: args.timeoutMs })
|
|
750
817
|
},
|
|
751
818
|
})
|
|
752
819
|
|
|
753
820
|
define(ctx, {
|
|
754
821
|
name: 'tower_rebase',
|
|
755
822
|
description:
|
|
756
|
-
'Rebase a mission branch onto the current base tip inside its own worktree. Refuses while its worker is running or the worktree is dirty. A clean conflict-free rebase waives re-review for the old review; a conflict is aborted and the mission is marked blocked.',
|
|
823
|
+
'Rebase a mission branch onto the current base tip inside its own worktree. Refuses while its worker is running or the worktree is dirty. A clean conflict-free rebase waives re-review for the old review; a conflict is aborted and the mission is marked blocked. If the branch is already contained in the base, it returns alreadyMerged and leaves the branch untouched so tower_merge can record the landed merge.',
|
|
757
824
|
parameters: {
|
|
758
825
|
type: 'object',
|
|
759
826
|
properties: {
|