dsh-tower 2.0.4 → 2.1.0-beta.1
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 +101 -0
- package/COMPATIBILITY.md +38 -0
- package/CONTRIBUTING.md +150 -2
- package/README.en.md +40 -3
- package/README.md +38 -3
- package/SECURITY.md +27 -0
- package/adapters/default.js +10 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/batch.js +79 -0
- package/app/board.js +25 -0
- package/app/checkCompletion.js +76 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +108 -0
- package/app/init.js +249 -0
- package/app/merge.js +567 -0
- package/app/messages.js +66 -0
- package/app/mission.js +35 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +42 -0
- package/app/provision.js +193 -0
- package/app/read.js +57 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +145 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +184 -0
- package/app/tower.js +524 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +3 -1
- package/docs/architecture/overview.md +111 -12
- package/docs/board.md +72 -0
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +8 -1
- package/docs/guide/release.md +125 -0
- package/docs/operations/security.md +47 -2
- package/docs/reference/config.generated.md +66 -0
- package/docs/reference/config.md +4 -1
- package/docs/reference/errors.generated.md +39 -0
- package/docs/reference/errors.md +4 -2
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +1 -1
- package/docs/zh/README.md +3 -2
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +6 -1
- package/docs/zh/operations/security.md +30 -1
- package/docs/zh/reference/config.md +4 -1
- package/docs/zh/reference/errors.md +3 -1
- package/docs/zh/reference/tools.md +1 -1
- package/domain/collab/batch.js +112 -0
- package/domain/collab/messages.js +127 -0
- package/domain/collab/roster.js +158 -0
- package/domain/collab/spawn.js +188 -0
- package/domain/config/schema.js +404 -0
- package/domain/contracts/outputs.js +279 -0
- package/domain/contracts/tool-inputs/collaboration.js +58 -0
- package/domain/contracts/tool-inputs/findings.js +29 -0
- package/domain/contracts/tool-inputs/index.js +94 -0
- package/domain/contracts/tool-inputs/integration.js +37 -0
- package/domain/contracts/tool-inputs/missions.js +94 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +96 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +176 -0
- package/domain/integration/init.js +225 -0
- package/domain/integration/liveness.js +31 -0
- package/domain/integration/merge.js +377 -0
- package/domain/integration/provision.js +124 -0
- package/domain/integration/rebase.js +236 -0
- package/domain/integration/review.js +52 -0
- package/domain/integration/teardown.js +118 -0
- package/domain/mission.js +68 -0
- package/domain/missions/completion.js +114 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +185 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +118 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +128 -0
- package/domain/report.js +59 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +105 -0
- package/domain/state/schema.js +233 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +33 -0
- package/domain/summary.js +114 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +63 -20
- package/lib/briefing.js +176 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +33 -375
- package/lib/doctor.js +97 -4
- package/lib/git.js +60 -16
- package/lib/hmr.js +4 -3
- package/lib/leases.js +3 -2
- package/lib/legacy.js +20 -9
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +11 -43
- package/lib/persistence.js +349 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/store.js +54 -137
- package/lib/tests.js +11 -10
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +2 -2
- package/lib/worktreeOwnership.js +5 -4
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +32 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +9 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +203 -0
- package/scripts/release-check.mjs +303 -0
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +44 -0
- package/tools/rebase.js +39 -0
- package/tools/recover.js +45 -0
- package/tools/reload.js +52 -0
- package/tools/resource.js +54 -0
- package/tools/review.js +45 -0
- package/tools/roster.js +60 -0
- package/tools/send.js +147 -0
- package/tools/spawn.js +196 -0
- package/tools/status.js +94 -0
- package/tools/teardown.js +42 -0
- package/tools/test.js +61 -0
- package/types/cordis.d.ts +6 -0
- package/types/index.d.ts +339 -0
- package/lib/errors.js +0 -123
- package/lib/tools.js +0 -2460
- package/lib/tower.js +0 -2912
- /package/{lib → domain}/batch.js +0 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Pure shared vocabulary (#56 P5): every status/type/action enum used by the
|
|
2
|
+
// tool contracts and handlers. Keeping one canonical value set here lets
|
|
3
|
+
// tools/** import constants/predicates instead of spelling literals.
|
|
4
|
+
// No fs, git, or config.
|
|
5
|
+
// @ts-check
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
FINDING_SEVERITIES,
|
|
9
|
+
FINDING_STATUSES,
|
|
10
|
+
FINDING_TYPES,
|
|
11
|
+
MERGE_VERDICTS,
|
|
12
|
+
MISSION_STATUSES,
|
|
13
|
+
PAUSE_CAUSES,
|
|
14
|
+
} from './mission.js'
|
|
15
|
+
import { ROSTER_INACTIVE_STATES } from './roster.js'
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
FINDING_SEVERITIES,
|
|
19
|
+
FINDING_STATUSES,
|
|
20
|
+
FINDING_TYPES,
|
|
21
|
+
MERGE_VERDICTS,
|
|
22
|
+
MISSION_STATUSES,
|
|
23
|
+
PAUSE_CAUSES,
|
|
24
|
+
ROSTER_INACTIVE_STATES,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Mission inputs accepted by tower_plan.
|
|
28
|
+
/** @type {readonly string[]} */
|
|
29
|
+
export const MISSION_KINDS = ['build', 'survey']
|
|
30
|
+
/** @type {readonly string[]} */
|
|
31
|
+
export const REVIEW_POLICIES = ['required-clean', 'none']
|
|
32
|
+
|
|
33
|
+
// Roster actors (the caller resolved by actorFor) and createable agent roles.
|
|
34
|
+
export const ROLE = Object.freeze({
|
|
35
|
+
tower: 'tower',
|
|
36
|
+
worker: 'worker',
|
|
37
|
+
reviewer: 'reviewer',
|
|
38
|
+
unknown: 'unknown',
|
|
39
|
+
})
|
|
40
|
+
/** @type {readonly string[]} */
|
|
41
|
+
export const ROLES = Object.freeze([ROLE.worker, ROLE.reviewer])
|
|
42
|
+
/** @type {readonly string[]} */
|
|
43
|
+
export const ACTOR_ROLES = Object.freeze([ROLE.tower, ROLE.worker, ROLE.reviewer, ROLE.unknown])
|
|
44
|
+
export const DEFAULT_ROLE = ROLE.worker
|
|
45
|
+
|
|
46
|
+
/** @param {string | null | undefined} role */
|
|
47
|
+
export function isTowerRole(role) {
|
|
48
|
+
return role === ROLE.tower
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {string | null | undefined} role */
|
|
52
|
+
export function isWorkerRole(role) {
|
|
53
|
+
return role === ROLE.worker
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @param {string | null | undefined} role */
|
|
57
|
+
export function isReviewerRole(role) {
|
|
58
|
+
return role === ROLE.reviewer
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {string | null | undefined} role */
|
|
62
|
+
export function isUnknownRole(role) {
|
|
63
|
+
return role === ROLE.unknown
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Inbox recipients: a roster name, the tower, or a broadcast.
|
|
67
|
+
export const RECIPIENT = Object.freeze({ tower: ROLE.tower, all: 'all' })
|
|
68
|
+
|
|
69
|
+
/** @param {string | null | undefined} to */
|
|
70
|
+
export function isTowerRecipient(to) {
|
|
71
|
+
return to === RECIPIENT.tower
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @param {string | null | undefined} to */
|
|
75
|
+
export function isBroadcast(to) {
|
|
76
|
+
return to === RECIPIENT.all
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Action discriminators for the repository tools.
|
|
80
|
+
export const BATCH_ACTION = Object.freeze({
|
|
81
|
+
create: 'create',
|
|
82
|
+
status: 'status',
|
|
83
|
+
close: 'close',
|
|
84
|
+
abandon: 'abandon',
|
|
85
|
+
})
|
|
86
|
+
/** @type {readonly string[]} */
|
|
87
|
+
export const BATCH_ACTIONS = Object.freeze(Object.values(BATCH_ACTION))
|
|
88
|
+
|
|
89
|
+
export const RECOVERY_ACTION = Object.freeze({ status: 'status', clearStaleLock: 'clear-stale-lock' })
|
|
90
|
+
/** @type {readonly string[]} */
|
|
91
|
+
export const RECOVERY_ACTIONS = Object.freeze(Object.values(RECOVERY_ACTION))
|
|
92
|
+
export const RECOVERY_CONFIRM = 'CLEAR_STALE_LOCK'
|
|
93
|
+
|
|
94
|
+
export const LEASE_ACTION = Object.freeze({ acquire: 'acquire', release: 'release', status: 'status' })
|
|
95
|
+
/** @type {readonly string[]} */
|
|
96
|
+
export const LEASE_ACTIONS = Object.freeze(Object.values(LEASE_ACTION))
|
|
97
|
+
|
|
98
|
+
export const LEGACY_MAP_ACTION = Object.freeze({ map: 'map', status: 'status' })
|
|
99
|
+
/** @type {readonly string[]} */
|
|
100
|
+
export const LEGACY_MAP_ACTIONS = Object.freeze(Object.values(LEGACY_MAP_ACTION))
|
|
101
|
+
|
|
102
|
+
export const STATUS_FORMAT = Object.freeze({ json: 'json', markdown: 'markdown' })
|
|
103
|
+
/** @type {readonly string[]} */
|
|
104
|
+
export const STATUS_FORMATS = Object.freeze(Object.values(STATUS_FORMAT))
|
|
105
|
+
|
|
106
|
+
/** @param {string | null | undefined} format */
|
|
107
|
+
export function isMarkdownFormat(format) {
|
|
108
|
+
return format === STATUS_FORMAT.markdown
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Finding filters extend the lifecycle statuses with the read-only "all".
|
|
112
|
+
export const FINDING_FILTER_ALL = 'all'
|
|
113
|
+
/** @type {readonly string[]} */
|
|
114
|
+
export const FINDING_FILTER_STATUSES = Object.freeze([...FINDING_STATUSES, FINDING_FILTER_ALL])
|
|
115
|
+
|
|
116
|
+
// Test admission modes read from config by tower_spawn/tower_complete.
|
|
117
|
+
export const TESTS_ADMISSION = Object.freeze({ off: 'off', spawn: 'spawn', complete: 'complete' })
|
|
118
|
+
/** @type {readonly string[]} */
|
|
119
|
+
export const TESTS_ADMISSIONS = Object.freeze(Object.values(TESTS_ADMISSION))
|
|
120
|
+
|
|
121
|
+
/** @param {string | null | undefined} admission */
|
|
122
|
+
export function isSpawnAdmission(admission) {
|
|
123
|
+
return admission === TESTS_ADMISSION.spawn
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @param {string | null | undefined} admission */
|
|
127
|
+
export function isCompleteAdmission(admission) {
|
|
128
|
+
return admission === TESTS_ADMISSION.complete
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Roster row states and observed catalog activity.
|
|
132
|
+
export const ROSTER_STATE = Object.freeze({
|
|
133
|
+
reserved: 'reserved',
|
|
134
|
+
active: 'active',
|
|
135
|
+
inactive: 'inactive',
|
|
136
|
+
retired: 'retired',
|
|
137
|
+
failed: 'failed',
|
|
138
|
+
interrupted: 'interrupted',
|
|
139
|
+
missing: 'missing',
|
|
140
|
+
stale: 'stale',
|
|
141
|
+
})
|
|
142
|
+
export const ROSTER_STATES = Object.freeze([...ROSTER_INACTIVE_STATES, ROSTER_STATE.reserved, ROSTER_STATE.active])
|
|
143
|
+
/** States treated as gone by roster reporting, excluding "inactive". */
|
|
144
|
+
export const ROSTER_GONE_STATES = Object.freeze([
|
|
145
|
+
ROSTER_STATE.retired,
|
|
146
|
+
ROSTER_STATE.failed,
|
|
147
|
+
ROSTER_STATE.interrupted,
|
|
148
|
+
ROSTER_STATE.missing,
|
|
149
|
+
ROSTER_STATE.stale,
|
|
150
|
+
])
|
|
151
|
+
export const ROSTER_ACTIVITY = Object.freeze({
|
|
152
|
+
running: 'running',
|
|
153
|
+
inactive: 'inactive',
|
|
154
|
+
missing: 'missing',
|
|
155
|
+
unknown: 'unknown',
|
|
156
|
+
})
|
|
157
|
+
export const PARENT_STATUS_OK = 'ok'
|
|
158
|
+
|
|
159
|
+
/** @param {string | null | undefined} state */
|
|
160
|
+
export function isReservedState(state) {
|
|
161
|
+
return state === ROSTER_STATE.reserved
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** @param {string | null | undefined} activity */
|
|
165
|
+
export function isRunningActivity(activity) {
|
|
166
|
+
return activity === ROSTER_ACTIVITY.running
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Spawn completion outcomes recorded in the roster.
|
|
170
|
+
export const SPAWN_OUTCOME = Object.freeze({ failed: 'failed', inactive: 'inactive' })
|
|
171
|
+
export const STOP_REASON = Object.freeze({ completed: 'completed' })
|
|
172
|
+
|
|
173
|
+
/** @param {string | null | undefined} reason */
|
|
174
|
+
export function isCompletedStopReason(reason) {
|
|
175
|
+
return reason === STOP_REASON.completed
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Doctor check severities.
|
|
179
|
+
export const DOCTOR_STATUS = Object.freeze({ pass: 'pass', warn: 'warn', fail: 'fail' })
|
package/index.js
CHANGED
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
// plugin from disk when the HMR service is present (dev/linked installs).
|
|
13
13
|
import fs from 'node:fs'
|
|
14
14
|
import { randomUUID } from 'node:crypto'
|
|
15
|
-
import { registerTowerTools } from './lib/
|
|
15
|
+
import { registerTowerTools } from './lib/tool-registry.js'
|
|
16
16
|
import { loadModes, saveModes, storePath } from './lib/modeStore.js'
|
|
17
|
+
import { ModeState } from './lib/modeState.js'
|
|
17
18
|
import { Config as ConfigSchema, DEFAULT_DENY_TOOLS, configureRuntime, runtimeConfig } from './lib/config.js'
|
|
18
|
-
import { TowerToolError } from './
|
|
19
|
+
import { TowerToolError } from './domain/errors.js'
|
|
19
20
|
import { resolveRepo } from './lib/git.js'
|
|
20
21
|
import {
|
|
21
22
|
acquireWorkspaceLock,
|
|
@@ -25,16 +26,17 @@ import {
|
|
|
25
26
|
workspaceLockToken,
|
|
26
27
|
} from './lib/workspaceLock.js'
|
|
27
28
|
import { createHmrBridge } from './lib/hmr.js'
|
|
29
|
+
import { assertPresetsAvailable } from './lib/presets.js'
|
|
28
30
|
|
|
29
31
|
export const name = 'dsh-tower'
|
|
30
32
|
export const inject = ['tools', 'systemPrompt']
|
|
31
33
|
export const Config = ConfigSchema
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
const modeState = new ModeState()
|
|
34
36
|
let denyTools = new Set(DEFAULT_DENY_TOOLS)
|
|
35
37
|
|
|
36
38
|
function modeOn(key) {
|
|
37
|
-
const entry = key ?
|
|
39
|
+
const entry = key ? modeState.get(key) : undefined
|
|
38
40
|
return Boolean(entry && entry.on === true)
|
|
39
41
|
}
|
|
40
42
|
const READ_ONLY_TOOL_NAMES = new Set([
|
|
@@ -86,7 +88,9 @@ function configSection(config, allowAgentControl) {
|
|
|
86
88
|
|
|
87
89
|
function persistModes() {
|
|
88
90
|
const config = runtimeConfig()
|
|
89
|
-
saveModes(
|
|
91
|
+
saveModes(modeState, config.mode.storePath || undefined, config.mode.maxEntries, {
|
|
92
|
+
timeoutMs: config.mode.lockTimeoutMs,
|
|
93
|
+
})
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
/** Parse `/tower` arguments: bare and free text both mean "ensure on". */
|
|
@@ -156,7 +160,7 @@ function clearPlanModeForTower(ctx, agent) {
|
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
function enterMode(ctx, key, agent, by, reason) {
|
|
159
|
-
const previous =
|
|
163
|
+
const previous = modeState.get(key)
|
|
160
164
|
const wasOn = Boolean(previous && previous.on)
|
|
161
165
|
const plan = clearPlanModeForTower(ctx, agent)
|
|
162
166
|
if (plan.refused) {
|
|
@@ -177,12 +181,14 @@ function enterMode(ctx, key, agent, by, reason) {
|
|
|
177
181
|
}
|
|
178
182
|
}
|
|
179
183
|
const now = Date.now()
|
|
180
|
-
|
|
184
|
+
modeState.set(key, {
|
|
181
185
|
on: true,
|
|
182
186
|
pausedGoal,
|
|
183
187
|
lock: previous?.lock ?? null,
|
|
184
188
|
baseBranch: previous?.baseBranch ?? null,
|
|
185
189
|
planModeCleared: plan.cleared,
|
|
190
|
+
humanAckAt: by === 'user' ? previous?.humanAckAt ?? now : previous?.humanAckAt ?? null,
|
|
191
|
+
lastDisclosure: previous?.lastDisclosure ?? 'full',
|
|
186
192
|
lastChange: { by, action: 'on', reason: reason ?? null, at: now },
|
|
187
193
|
at: now,
|
|
188
194
|
})
|
|
@@ -191,12 +197,12 @@ function enterMode(ctx, key, agent, by, reason) {
|
|
|
191
197
|
}
|
|
192
198
|
|
|
193
199
|
async function exitMode(key, by, reason) {
|
|
194
|
-
const previous =
|
|
200
|
+
const previous = modeState.get(key)
|
|
195
201
|
if (!previous) return { wasOn: false, pausedGoal: null, goalNote: null }
|
|
196
202
|
const wasOn = previous.on === true
|
|
197
203
|
// Keep a timestamped off tombstone so a concurrent save from another
|
|
198
204
|
// session cannot resurrect this mode entry.
|
|
199
|
-
|
|
205
|
+
modeState.set(key, { on: false, at: Date.now(), humanAckAt: previous.humanAckAt ?? null, lastDisclosure: previous.lastDisclosure ?? 'exit' })
|
|
200
206
|
persistModes()
|
|
201
207
|
if (previous.lock) {
|
|
202
208
|
await releaseWorkspaceLock(previous.lock.commonDir, { token: previous.lock.token }).catch(() => {})
|
|
@@ -246,7 +252,8 @@ export function apply(ctx, rawConfig) {
|
|
|
246
252
|
const config = configureRuntime(rawConfig)
|
|
247
253
|
const allowAgentControl = config.mode.allowAgentControl === true
|
|
248
254
|
const reminder = configSection(config, allowAgentControl)
|
|
249
|
-
|
|
255
|
+
modeState.replace(loadModes(config.mode.storePath || undefined))
|
|
256
|
+
if (typeof ctx.provide === 'function') ctx.provide('tower', modeState)
|
|
250
257
|
denyTools = new Set(config.mode.denyTools)
|
|
251
258
|
const hotReloadEnabled = config.dev?.hotReload !== false
|
|
252
259
|
const hmrBridge = hotReloadEnabled
|
|
@@ -264,7 +271,7 @@ export function apply(ctx, rawConfig) {
|
|
|
264
271
|
if (runtimeConfig().mode.workspaceLockScope === 'mutations' && READ_ONLY_TOOL_NAMES.has(toolName)) return
|
|
265
272
|
const key = agentKey(exec?.agent)
|
|
266
273
|
if (!key) return
|
|
267
|
-
const entry =
|
|
274
|
+
const entry = modeState.get(key)
|
|
268
275
|
if (!entry || entry.on !== true) return
|
|
269
276
|
const repo = await resolveRepo(repoRoot)
|
|
270
277
|
const existing = entry.lock
|
|
@@ -313,7 +320,26 @@ export function apply(ctx, rawConfig) {
|
|
|
313
320
|
},
|
|
314
321
|
basePreference(exec) {
|
|
315
322
|
const key = agentKey(exec?.agent)
|
|
316
|
-
return key && modeOn(key) ?
|
|
323
|
+
return key && modeOn(key) ? modeState.get(key)?.baseBranch ?? null : null
|
|
324
|
+
},
|
|
325
|
+
modeInfo(exec) {
|
|
326
|
+
const key = agentKey(exec?.agent)
|
|
327
|
+
if (!key) return null
|
|
328
|
+
const entry = modeState.get(key)
|
|
329
|
+
const history = [...modeState.values()]
|
|
330
|
+
.map((value) => value?.lastChange)
|
|
331
|
+
.filter((change) => change && typeof change.at === 'number')
|
|
332
|
+
.sort((a, b) => b.at - a.at)
|
|
333
|
+
.slice(0, 10)
|
|
334
|
+
return {
|
|
335
|
+
mode: modeOn(key) ? 'on' : entry ? 'off' : 'unknown',
|
|
336
|
+
allowAgentControl: runtimeConfig().mode.allowAgentControl === true,
|
|
337
|
+
lastChange: entry?.lastChange ?? null,
|
|
338
|
+
history,
|
|
339
|
+
workspaceLock: entry?.lock
|
|
340
|
+
? { enabled: runtimeConfig().mode.workspaceLock === true, commonDir: entry.lock.commonDir }
|
|
341
|
+
: null,
|
|
342
|
+
}
|
|
317
343
|
},
|
|
318
344
|
}
|
|
319
345
|
|
|
@@ -323,7 +349,7 @@ export function apply(ctx, rawConfig) {
|
|
|
323
349
|
// process still owns; a reloaded plugin restores the entries from modes.json.
|
|
324
350
|
ctx.effect(() => () => {
|
|
325
351
|
if (hmrBridge) hmrBridge.dispose?.()
|
|
326
|
-
for (const entry of
|
|
352
|
+
for (const entry of modeState.values()) {
|
|
327
353
|
if (entry?.lock) {
|
|
328
354
|
releaseWorkspaceLock(entry.lock.commonDir, { token: entry.lock.token }).catch(() => {})
|
|
329
355
|
}
|
|
@@ -374,7 +400,7 @@ export function apply(ctx, rawConfig) {
|
|
|
374
400
|
// Workspace-lock heartbeat; only sessions holding a lock do I/O.
|
|
375
401
|
ctx.effect(() => {
|
|
376
402
|
const timer = setInterval(() => {
|
|
377
|
-
for (const entry of
|
|
403
|
+
for (const entry of modeState.values()) {
|
|
378
404
|
if (!entry?.lock) continue
|
|
379
405
|
touchWorkspaceLock(entry.lock.commonDir, { token: entry.lock.token }).catch(() => {})
|
|
380
406
|
}
|
|
@@ -411,12 +437,13 @@ export function apply(ctx, rawConfig) {
|
|
|
411
437
|
},
|
|
412
438
|
async execute(_args, exec) {
|
|
413
439
|
const key = agentKey(exec.agent)
|
|
414
|
-
const entry = key ?
|
|
440
|
+
const entry = key ? modeState.get(key) : undefined
|
|
415
441
|
return {
|
|
416
442
|
mode: entry && entry.on === true ? 'on' : key ? 'off' : 'unknown',
|
|
417
443
|
agentId: key,
|
|
418
444
|
allowAgentControl,
|
|
419
445
|
lastChange: entry?.lastChange ?? null,
|
|
446
|
+
lastDisclosure: entry?.lastDisclosure ?? null,
|
|
420
447
|
pausedGoal: entry?.pausedGoal ?? null,
|
|
421
448
|
deniedWhileActive: [...denyTools],
|
|
422
449
|
persistedAt: storePath(config.mode.storePath || undefined),
|
|
@@ -473,12 +500,20 @@ export function apply(ctx, rawConfig) {
|
|
|
473
500
|
const key = agentKey(exec.agent)
|
|
474
501
|
if (!key) return { ok: false, error: 'tower_control requires a live calling agent' }
|
|
475
502
|
if (typeof args.reason !== 'string' || args.reason.trim().length === 0) {
|
|
476
|
-
throw new
|
|
503
|
+
throw new TowerToolError('invalid-argument', 'tower_control requires a non-empty reason')
|
|
477
504
|
}
|
|
478
505
|
const reason = args.reason.trim()
|
|
506
|
+
if (args.action === 'enter' && !modeOn(key)) {
|
|
507
|
+
await assertPresetsAvailable(ctx, config)
|
|
508
|
+
}
|
|
479
509
|
const outcome =
|
|
480
510
|
args.action === 'enter'
|
|
481
|
-
?
|
|
511
|
+
? (() => {
|
|
512
|
+
if (!modeOn(key) && runtimeConfig().mode.requireHumanAckOnFirstEntry === true && !modeState.get(key)?.humanAckAt) {
|
|
513
|
+
throw new TowerToolError('not-authorized', 'this profile requires a human acknowledgement; run /tower ack first')
|
|
514
|
+
}
|
|
515
|
+
return enterMode(ctx, key, exec.agent, 'agent', reason)
|
|
516
|
+
})()
|
|
482
517
|
: await exitMode(key, 'agent', reason)
|
|
483
518
|
return {
|
|
484
519
|
ok: true,
|
|
@@ -499,13 +534,20 @@ export function apply(ctx, rawConfig) {
|
|
|
499
534
|
commandCtx.commands.register({
|
|
500
535
|
name: 'tower',
|
|
501
536
|
description: 'Enter or leave Tower mode, optionally carrying a user instruction; /tower reload reloads via HMR; /tower base <branch> sets the next init base',
|
|
502
|
-
input: { hint: '[on|off|reload|base <branch>] [message]', attachments: true },
|
|
537
|
+
input: { hint: '[on|off|reload|ack|base <branch>] [message]', attachments: true },
|
|
503
538
|
handler: async ({ agent, rawInput, attachments }) => {
|
|
504
539
|
const key = agentKey(agent)
|
|
505
540
|
if (!key) return { kind: 'error', text: 'Tower mode requires a live agent.' }
|
|
506
|
-
const
|
|
541
|
+
const raw = String(rawInput ?? '').trim()
|
|
542
|
+
if (/^ack$/i.test(raw)) {
|
|
543
|
+
const previous = modeState.get(key) ?? { on: false }
|
|
544
|
+
modeState.set(key, { ...previous, on: previous.on === true, humanAckAt: Date.now(), at: Date.now() })
|
|
545
|
+
persistModes()
|
|
546
|
+
return { kind: 'success', text: 'Human acknowledgement recorded; the model may now make the first Tower-mode entry in this session.' }
|
|
547
|
+
}
|
|
548
|
+
const baseMatch = raw.match(/^base\s+(\S+)\s*$/i)
|
|
507
549
|
if (baseMatch) {
|
|
508
|
-
const entry =
|
|
550
|
+
const entry = modeState.get(key)
|
|
509
551
|
if (!entry || entry.on !== true) return { kind: 'error', text: 'Run /tower on first, then /tower base <branch>.' }
|
|
510
552
|
entry.baseBranch = baseMatch[1]
|
|
511
553
|
persistModes()
|
|
@@ -526,6 +568,7 @@ export function apply(ctx, rawConfig) {
|
|
|
526
568
|
}
|
|
527
569
|
}
|
|
528
570
|
const { active, message } = parsed
|
|
571
|
+
if (active) await assertPresetsAvailable(ctx, config)
|
|
529
572
|
const outcome = active
|
|
530
573
|
? enterMode(ctx, key, agent, 'user', message || null)
|
|
531
574
|
: await exitMode(key, 'user', message || null)
|
package/lib/briefing.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Mission briefings: pure text builders for worker/reviewer prompts.
|
|
2
|
+
|
|
3
|
+
function clip(text, max = 2000) {
|
|
4
|
+
const value = String(text ?? '')
|
|
5
|
+
return value.length > max ? `${value.slice(0, max)}\n… truncated` : value
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function workerBriefing({
|
|
9
|
+
repoRoot,
|
|
10
|
+
base,
|
|
11
|
+
mission,
|
|
12
|
+
deps,
|
|
13
|
+
worktree,
|
|
14
|
+
instructions,
|
|
15
|
+
reviews = [],
|
|
16
|
+
findings = [],
|
|
17
|
+
completionReport = null,
|
|
18
|
+
}) {
|
|
19
|
+
const completion = /** @type {import('../types/index.js').CompletionReport | null} */ (completionReport)
|
|
20
|
+
const lines = []
|
|
21
|
+
lines.push(`# Tower mission ${mission.id} — ${mission.title}`)
|
|
22
|
+
lines.push('')
|
|
23
|
+
lines.push(`You are the tower worker for mission ${mission.id} in repository ${repoRoot}.`)
|
|
24
|
+
lines.push('')
|
|
25
|
+
lines.push(`Work ONLY in this git worktree: ${worktree.path}`)
|
|
26
|
+
lines.push(`Branch: ${worktree.branch} (based on ${base} @ ${String(worktree.baseCommit).slice(0, 12)})`)
|
|
27
|
+
lines.push('')
|
|
28
|
+
lines.push(`Kind: ${mission.kind}`)
|
|
29
|
+
lines.push(
|
|
30
|
+
`Scope (only files you may touch): ${mission.scope.length > 0 ? mission.scope.join(', ') : '(none — survey is read-only)'}`,
|
|
31
|
+
)
|
|
32
|
+
if (mission.tasks.length > 0) {
|
|
33
|
+
lines.push('Tasks:')
|
|
34
|
+
for (const task of mission.tasks) lines.push(`- [${task.status}] ${task.text}`)
|
|
35
|
+
}
|
|
36
|
+
if (deps.length > 0) {
|
|
37
|
+
lines.push('Dependencies:')
|
|
38
|
+
for (const dep of deps) lines.push(`- ${dep.id} ${dep.title ?? ''} (${dep.status})`)
|
|
39
|
+
}
|
|
40
|
+
if (mission.context) {
|
|
41
|
+
lines.push('Context (verbatim from the user):')
|
|
42
|
+
lines.push(mission.context)
|
|
43
|
+
}
|
|
44
|
+
if ((mission.references ?? []).length > 0) {
|
|
45
|
+
lines.push('References (read-only pointers; read them before deciding):')
|
|
46
|
+
for (const reference of mission.references) lines.push(`- ${reference}`)
|
|
47
|
+
}
|
|
48
|
+
if (mission.reviewPolicy === 'none') {
|
|
49
|
+
lines.push(`Review policy: none (user exemption: ${mission.reviewExemptionReason ?? 'missing reason'})`)
|
|
50
|
+
}
|
|
51
|
+
if (completion) {
|
|
52
|
+
lines.push('')
|
|
53
|
+
lines.push(`Previous completion report (by ${completion.by ?? 'worker'}):`)
|
|
54
|
+
lines.push(clip(completion.report, 1500))
|
|
55
|
+
if ((completion.checks ?? []).length > 0) {
|
|
56
|
+
lines.push('Previous checks:')
|
|
57
|
+
for (const check of completion.checks ?? []) lines.push(`- ${check.name}${check.exitCode !== undefined ? ` (exit ${check.exitCode})` : ''}`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (reviews.length > 0) {
|
|
61
|
+
lines.push('')
|
|
62
|
+
lines.push('Previous review rounds for this branch (newest first) — address every finding:')
|
|
63
|
+
for (const review of reviews) {
|
|
64
|
+
lines.push(`- R${review.round} by ${review.reviewer}: ${review.status} / ${review.mergeVerdict}`)
|
|
65
|
+
if (review.findings) lines.push(` findings: ${clip(review.findings, 1200).replaceAll('\n', '\n ')}`)
|
|
66
|
+
if (review.decision) lines.push(` decision: ${clip(review.decision, 600)}`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (findings.length > 0) {
|
|
70
|
+
lines.push('')
|
|
71
|
+
lines.push('Findings related to this mission:')
|
|
72
|
+
for (const finding of findings) lines.push(`- ${finding.id} [${finding.status}] ${finding.severity ?? 'severity?'} ${finding.title}`)
|
|
73
|
+
}
|
|
74
|
+
lines.push('')
|
|
75
|
+
lines.push('Rules:')
|
|
76
|
+
lines.push(`- Pass workdir="${worktree.path}" on every shell command; never edit the main checkout.`)
|
|
77
|
+
lines.push('- Stay inside the declared scope; out-of-scope changes are rejected later.')
|
|
78
|
+
lines.push('- Commit your work on the mission branch. Do not merge, push, or spawn other agents.')
|
|
79
|
+
lines.push(
|
|
80
|
+
`- Update progress: tower_mission({repoRoot:"${repoRoot}",id:"${mission.id}",task_done:"..."}) or blocker:"...".`,
|
|
81
|
+
)
|
|
82
|
+
lines.push(
|
|
83
|
+
`- When the work is finished and every task is closed, call tower_complete({repoRoot:"${repoRoot}",missionId:"${mission.id}",report:"<full handoff>",checks:[{name,command,exitCode,evidence}]}). Unread inbox messages block completion, so read them with tower_inbox first.`,
|
|
84
|
+
)
|
|
85
|
+
lines.push('- Your final message is the full handoff: files changed, how you verified, what remains.')
|
|
86
|
+
if (instructions) {
|
|
87
|
+
lines.push('')
|
|
88
|
+
lines.push('Extra tower instructions:')
|
|
89
|
+
lines.push(instructions)
|
|
90
|
+
}
|
|
91
|
+
return lines.join('\n')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function reviewerBriefing({
|
|
95
|
+
repoRoot,
|
|
96
|
+
base,
|
|
97
|
+
mission,
|
|
98
|
+
deps,
|
|
99
|
+
worktree,
|
|
100
|
+
instructions,
|
|
101
|
+
reviews = [],
|
|
102
|
+
findings = [],
|
|
103
|
+
completionReport = null,
|
|
104
|
+
}) {
|
|
105
|
+
const completion = /** @type {import('../types/index.js').CompletionReport | null} */ (completionReport)
|
|
106
|
+
const lines = []
|
|
107
|
+
lines.push(`# Tower review — mission ${mission.id} — ${mission.title}`)
|
|
108
|
+
lines.push('')
|
|
109
|
+
lines.push(`You are the independent reviewer for mission ${mission.id} in repository ${repoRoot}.`)
|
|
110
|
+
lines.push('')
|
|
111
|
+
lines.push(`Review branch: ${worktree.branch}`)
|
|
112
|
+
lines.push(`Worktree: ${worktree.path}`)
|
|
113
|
+
lines.push(`Reviewed base: ${base} @ ${String(worktree.baseCommit).slice(0, 12)}`)
|
|
114
|
+
lines.push('')
|
|
115
|
+
lines.push(`Kind: ${mission.kind}`)
|
|
116
|
+
if (mission.tasks.length > 0) {
|
|
117
|
+
lines.push('The worker was asked to complete:')
|
|
118
|
+
for (const task of mission.tasks) lines.push(`- [${task.status}] ${task.text}`)
|
|
119
|
+
}
|
|
120
|
+
if (deps.length > 0) {
|
|
121
|
+
lines.push('Dependencies:')
|
|
122
|
+
for (const dep of deps) lines.push(`- ${dep.id} ${dep.title ?? ''} (${dep.status})`)
|
|
123
|
+
}
|
|
124
|
+
if (mission.context) {
|
|
125
|
+
lines.push('Context (verbatim from the user):')
|
|
126
|
+
lines.push(mission.context)
|
|
127
|
+
}
|
|
128
|
+
if ((mission.references ?? []).length > 0) {
|
|
129
|
+
lines.push('References (read-only pointers):')
|
|
130
|
+
for (const reference of mission.references) lines.push(`- ${reference}`)
|
|
131
|
+
}
|
|
132
|
+
if (completion) {
|
|
133
|
+
lines.push('')
|
|
134
|
+
lines.push(`Completion report (by ${completion.by ?? 'worker'}):`)
|
|
135
|
+
lines.push(clip(completion.report, 2000))
|
|
136
|
+
if ((completion.checks ?? []).length > 0) {
|
|
137
|
+
lines.push('Completion checks the worker ran:')
|
|
138
|
+
for (const check of completion.checks ?? []) {
|
|
139
|
+
lines.push(
|
|
140
|
+
`- ${check.name}${check.exitCode !== undefined ? ` (exit ${check.exitCode})` : ''}${check.evidence ? ` — ${clip(check.evidence, 400)}` : ''}`,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (reviews.length > 0) {
|
|
146
|
+
lines.push('')
|
|
147
|
+
lines.push('Prior review rounds for this branch (newest first):')
|
|
148
|
+
for (const review of reviews) {
|
|
149
|
+
lines.push(`- R${review.round} by ${review.reviewer}: ${review.status} / ${review.mergeVerdict}`)
|
|
150
|
+
if (review.findings) lines.push(` findings: ${clip(review.findings, 1200).replaceAll('\n', '\n ')}`)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (findings.length > 0) {
|
|
154
|
+
lines.push('')
|
|
155
|
+
lines.push('Related findings:')
|
|
156
|
+
for (const finding of findings) lines.push(`- ${finding.id} [${finding.status}] ${finding.severity ?? 'severity?'} ${finding.title}`)
|
|
157
|
+
}
|
|
158
|
+
lines.push('')
|
|
159
|
+
lines.push('Rules:')
|
|
160
|
+
lines.push('- Read-only: do not modify files, commit, or merge.')
|
|
161
|
+
lines.push('- Inspect the diff from the reviewed base to the branch tip and map every task to the diff.')
|
|
162
|
+
lines.push('- Check scope, correctness, tests, and security. Report severity and concrete evidence.')
|
|
163
|
+
lines.push(
|
|
164
|
+
`- Submit the formal verdict with tower_review({repoRoot:"${repoRoot}",missionId:"${mission.id}",status:"clean",merge:"merge",findings:"none",checks:["..."],decision:"..."}). Use status "p1-Nitems"/"p2-Nitems" and merge "fix-then-merge"/"hold" for problems; a non-clean verdict returns the mission to active.`,
|
|
165
|
+
)
|
|
166
|
+
lines.push(
|
|
167
|
+
`- File out-of-scope issues with tower_finding({repoRoot:"${repoRoot}",type:"bug",title:"...",summary:"...",details:"...",suggested_fix:"..."}) instead of fixing them.`,
|
|
168
|
+
)
|
|
169
|
+
lines.push('- Your final message is the review report; the tool call above is the formal verdict.')
|
|
170
|
+
if (instructions) {
|
|
171
|
+
lines.push('')
|
|
172
|
+
lines.push('Extra tower instructions:')
|
|
173
|
+
lines.push(instructions)
|
|
174
|
+
}
|
|
175
|
+
return lines.join('\n')
|
|
176
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Release compatibility provenance. COMPATIBILITY.md is shipped in the package;
|
|
2
|
+
// no network lookup is involved.
|
|
3
|
+
import { fs } from '../adapters/store-fs.js'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
8
|
+
|
|
9
|
+
export function parseVersion(value) {
|
|
10
|
+
const text = String(value ?? '').trim()
|
|
11
|
+
const match = text.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/)
|
|
12
|
+
if (!match) return null
|
|
13
|
+
return {
|
|
14
|
+
numbers: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
15
|
+
prerelease: match[4] ? match[4].split('.') : [],
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function comparePrerelease(a, b) {
|
|
20
|
+
if (a.length === 0 && b.length === 0) return 0
|
|
21
|
+
if (a.length === 0) return 1
|
|
22
|
+
if (b.length === 0) return -1
|
|
23
|
+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
|
24
|
+
if (a[index] === undefined) return -1
|
|
25
|
+
if (b[index] === undefined) return 1
|
|
26
|
+
const aNumber = /^\d+$/.test(a[index])
|
|
27
|
+
const bNumber = /^\d+$/.test(b[index])
|
|
28
|
+
if (aNumber && bNumber) {
|
|
29
|
+
const delta = Number(a[index]) - Number(b[index])
|
|
30
|
+
if (delta !== 0) return delta
|
|
31
|
+
} else if (aNumber !== bNumber) {
|
|
32
|
+
return aNumber ? -1 : 1
|
|
33
|
+
} else if (a[index] !== b[index]) {
|
|
34
|
+
return a[index] < b[index] ? -1 : 1
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function compareVersions(a, b) {
|
|
41
|
+
const left = typeof a === 'string' ? parseVersion(a) : a
|
|
42
|
+
const right = typeof b === 'string' ? parseVersion(b) : b
|
|
43
|
+
if (!left || !right) return null
|
|
44
|
+
for (let index = 0; index < 3; index += 1) {
|
|
45
|
+
if (left.numbers[index] !== right.numbers[index]) return left.numbers[index] < right.numbers[index] ? -1 : 1
|
|
46
|
+
}
|
|
47
|
+
return comparePrerelease(left.prerelease, right.prerelease)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseCompatibility(text) {
|
|
51
|
+
const rows = []
|
|
52
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
53
|
+
if (!/^\|/.test(line)) continue
|
|
54
|
+
const cells = line.split('|').slice(1, -1).map((cell) => cell.trim())
|
|
55
|
+
if (cells.length < 4) continue
|
|
56
|
+
if (!parseVersion(cells[0])) continue
|
|
57
|
+
rows.push({ hostVersion: cells[0], commit: cells[1], verifiedOn: cells[2], ran: cells[3] })
|
|
58
|
+
}
|
|
59
|
+
return rows
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function readCompatibility(file = path.join(PACKAGE_ROOT, 'COMPATIBILITY.md')) {
|
|
63
|
+
try {
|
|
64
|
+
return parseCompatibility(fs.readFileSync(file, 'utf8'))
|
|
65
|
+
} catch {
|
|
66
|
+
return []
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function assessHostVersion(hostVersion, rows) {
|
|
71
|
+
const parsed = parseVersion(hostVersion)
|
|
72
|
+
if (!parsed) return { verdict: 'unknown', row: null, detail: 'cannot parse the running host version' }
|
|
73
|
+
const exact = rows.find((row) => compareVersions(row.hostVersion, parsed) === 0)
|
|
74
|
+
if (exact) return { verdict: 'verified', row: exact, detail: `${exact.hostVersion} verified on ${exact.verifiedOn}` }
|
|
75
|
+
const known = rows
|
|
76
|
+
.map((row) => ({ row, cmp: compareVersions(parsed, row.hostVersion) }))
|
|
77
|
+
.filter((entry) => entry.cmp !== null)
|
|
78
|
+
if (known.length === 0) return { verdict: 'unknown', row: null, detail: 'COMPATIBILITY.md has no version rows' }
|
|
79
|
+
const newest = known.reduce((best, entry) => (entry.cmp > best.cmp ? entry : best), known[0])
|
|
80
|
+
if (newest.cmp > 0) {
|
|
81
|
+
return {
|
|
82
|
+
verdict: 'untested-newer',
|
|
83
|
+
row: newest.row,
|
|
84
|
+
detail: `${hostVersion} is newer than every verified build (latest row ${newest.row.hostVersion})`,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
verdict: 'untested-older',
|
|
89
|
+
row: newest.row,
|
|
90
|
+
detail: `${hostVersion} is older than the verified builds (latest row ${newest.row.hostVersion})`,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function detectHostVersion(ctx, env = process.env) {
|
|
95
|
+
for (const value of [env.DSH_VERSION, env.DSH_HOST_VERSION, env.npm_config_dsh_version]) {
|
|
96
|
+
if (typeof value === 'string' && parseVersion(value)) return value
|
|
97
|
+
}
|
|
98
|
+
for (const serviceName of ['app', 'runtime', 'host']) {
|
|
99
|
+
try {
|
|
100
|
+
const service = ctx?.get?.(serviceName)
|
|
101
|
+
const value = service && (service.version ?? service.build?.version)
|
|
102
|
+
if (typeof value === 'string' && parseVersion(value)) return value
|
|
103
|
+
} catch {
|
|
104
|
+
// defensive: a missing service is not a doctor failure
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null
|
|
108
|
+
}
|