@ran-sh/dsh-crew 2.1.8 → 2.2.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/.claude-plugin/plugin.json +1 -1
- package/docs/job-contracts.md +14 -1
- package/docs/readiness-matrix.md +20 -1
- package/lib/client.js +9 -8
- package/package.json +1 -1
- package/scripts/setup.mjs +6 -27
- package/scripts/verify-npm-install.mjs +2 -2
- package/skills/dsh-crew/SKILL.md +24 -15
- package/src/adaptive-routing.mjs +2 -2
- package/src/ci-evidence.mjs +1 -1
- package/src/client/history-panel.tsx +6 -5
- package/src/client/quick-panel.tsx +0 -1
- package/src/credential-lifecycle.mjs +0 -3
- package/src/credential-purge-state.mjs +1 -5
- package/src/credential-reference.mjs +0 -8
- package/src/crew-workspaces.mjs +136 -0
- package/src/delivery.mjs +2 -2
- package/src/dsh-cli-runtime.mjs +18 -22
- package/src/extension-contract.mjs +1 -1
- package/src/failure-classification.mjs +0 -10
- package/src/history/cleanup-plan.mjs +15 -5
- package/src/hub/entry.mjs +1 -1
- package/src/hub/index.mjs +45 -19
- package/src/i18n.mjs +0 -2
- package/src/install/crew-skill.mjs +1 -1
- package/src/install/install-legacy.mjs +3 -3
- package/src/install/npx-lifecycle.mjs +60 -112
- package/src/install/official-web.mjs +1 -1
- package/src/install/windows-startup.mjs +5 -5
- package/src/install/windows-supervisor-adapter.mjs +3 -2
- package/src/install/windows-supervisor-lifecycle.mjs +2 -2
- package/src/install/zcode.mjs +0 -2
- package/src/job-contracts.mjs +1 -1
- package/src/job-identity.mjs +13 -6
- package/src/jobs.mjs +3 -4
- package/src/mcp-runtime.mjs +17 -12
- package/src/model-routing.mjs +3 -4
- package/src/model-schedule.mjs +0 -1
- package/src/policy-legacy.mjs +10 -12
- package/src/provider-inventory.mjs +1 -1
- package/src/provider-layer-migration.mjs +0 -2
- package/src/readiness-matrix.mjs +1 -1
- package/src/release-in-use.mjs +1 -1
- package/src/role-profiles.mjs +2 -2
- package/src/runtime-controls.mjs +1 -1
- package/src/runtime-identity.mjs +1 -1
- package/src/status-shard.mjs +1 -1
- package/src/supervisor/restart-request.mjs +4 -4
- package/src/workflow-runtime.mjs +1 -2
- package/src/workspace-context.mjs +2 -2
- package/src/workspace-isolation.mjs +71 -4
- package/src/workspace-lock.mjs +134 -0
- package/scripts/policy-probe.mjs +0 -101
- package/scripts/verify-crew-ui-polish.mjs +0 -145
- package/scripts/verify-history-ui.mjs +0 -98
- package/scripts/verify-installer-fix.mjs +0 -26
package/docs/job-contracts.md
CHANGED
|
@@ -8,7 +8,7 @@ back to the caller.
|
|
|
8
8
|
|
|
9
9
|
```text
|
|
10
10
|
caller objective
|
|
11
|
-
-> Worker in
|
|
11
|
+
-> Worker in the project's stable worker workspace (or a caller-named one)
|
|
12
12
|
-> structured outcome + candidate reference
|
|
13
13
|
-> optional Reviewer inspects the workspace directly
|
|
14
14
|
-> compact Result Contract + canonical job events
|
|
@@ -22,6 +22,10 @@ changes/tests/risks, changed-file names, base revision, and candidate
|
|
|
22
22
|
fingerprint. It opens the relevant files and runs `git diff` in the isolated
|
|
23
23
|
workspace when deeper inspection is needed.
|
|
24
24
|
|
|
25
|
+
The workspace a job ran in is grouped by directory, not created per job: a
|
|
26
|
+
reviewer reviewing in place runs in the worker's workspace, and only an explicit
|
|
27
|
+
reviewer gets `dsh-crew-review`. See the isolation note above.
|
|
28
|
+
|
|
25
29
|
The capsule also carries a pointer to the reviewed attempt's persisted execution
|
|
26
30
|
record — the Hub session id plus the Crew harness session store — because a
|
|
27
31
|
transient change leaves nothing in the workspace to inspect once it has been
|
|
@@ -95,6 +99,15 @@ Per-job precedence is request `constraints` > Profile > session defaults.
|
|
|
95
99
|
`auto`, `existing`, or `none`. Workspace preflight reports `READY`, `CONFLICT`,
|
|
96
100
|
`READ_ONLY`, or `UNAVAILABLE` before dispatch.
|
|
97
101
|
|
|
102
|
+
With the default isolation a job runs in its project's stable workspace —
|
|
103
|
+
`dsh-crew-worker`, or `dsh-crew-review` for an explicit reviewer — rather than a
|
|
104
|
+
tree of its own, which is what keeps the Harness session panel at two entries per
|
|
105
|
+
project. The workspace is reset to the job's base revision before it runs, so the
|
|
106
|
+
candidate is still that job's work alone, and it is held for the job's duration: a
|
|
107
|
+
second job finds it held and is refused with `WORKSPACE_BUSY` instead of sharing a
|
|
108
|
+
tree with the first. `none` (shared) keeps the caller's directory and takes no
|
|
109
|
+
lock.
|
|
110
|
+
|
|
98
111
|
`constraints.allow_no_changes: true` is for tasks explicitly expected to leave
|
|
99
112
|
no net workspace changes, including read-only search/inspection and bounded
|
|
100
113
|
temporary smoke work. It does not blindly convert a partial Delivery Report
|
package/docs/readiness-matrix.md
CHANGED
|
@@ -39,13 +39,32 @@ rows remain `NOT_RUN` until a trusted higher layer supplies explicit evidence.
|
|
|
39
39
|
- `provider_health`
|
|
40
40
|
- `reviewer_health`
|
|
41
41
|
- `model_execution`
|
|
42
|
+
- `worker_primary_callable`
|
|
43
|
+
- `worker_escalation_callable`
|
|
44
|
+
- `reviewer_primary_callable`
|
|
45
|
+
- `provider_lifecycle_consistent`
|
|
42
46
|
- `deepseek_flash`
|
|
43
47
|
- `deepseek_pro`
|
|
44
|
-
- `opencode_go_mimo_qwen`
|
|
45
48
|
- `reviewer_pipeline`
|
|
46
49
|
- `cancellation_timeout_escalation`
|
|
47
50
|
- `standalone_official`
|
|
48
51
|
|
|
52
|
+
This list is the one the code builds; it had drifted, listing a row that was
|
|
53
|
+
removed and omitting four that exist.
|
|
54
|
+
|
|
55
|
+
Coverage of a provider is not a row per provider. `deepseek_flash` and
|
|
56
|
+
`deepseek_pro` are the exception: they name DeepSeek Official because that
|
|
57
|
+
provider has its own slots, and a machine running its workers elsewhere has no
|
|
58
|
+
such execution to show — the row says so rather than borrowing another
|
|
59
|
+
provider's. Everything else that answers "can this machine's configured route
|
|
60
|
+
actually run" — `provider_health`, `reviewer_health`, `model_execution`,
|
|
61
|
+
`worker_primary_callable`, `reviewer_primary_callable`, `reviewer_pipeline` — is
|
|
62
|
+
filtered to whatever route is currently selected, so an operator on other
|
|
63
|
+
providers is covered by those and loses nothing by the two DeepSeek rows
|
|
64
|
+
reading `NOT_RUN`. A static row per provider was tried and replaced by these
|
|
65
|
+
dynamic signals; `opencode_go_mimo_qwen` was that attempt's last trace in this
|
|
66
|
+
document.
|
|
67
|
+
|
|
49
68
|
`provider_health` and `reviewer_health` describe only the currently resolved
|
|
50
69
|
Worker and Reviewer routes. A fresh negative observation overrides historical
|
|
51
70
|
success; unrelated providers and stale observations do not. Historical jobs
|
package/lib/client.js
CHANGED
|
@@ -570,8 +570,8 @@ const copy = {
|
|
|
570
570
|
delete: "删除(不可恢复)",
|
|
571
571
|
scope: "范围",
|
|
572
572
|
all: "全部(含你自己的会话)",
|
|
573
|
-
crew: "仅 Crew
|
|
574
|
-
worktree: "仅 Crew
|
|
573
|
+
crew: "仅 Crew 创建",
|
|
574
|
+
worktree: "仅 Crew 工作区(推荐)",
|
|
575
575
|
before: "指定时间之前",
|
|
576
576
|
time: "创建时间早于",
|
|
577
577
|
timeHint: "按本机时区选择,严格按创建时间筛选;包含新会话的工作区会保留。",
|
|
@@ -612,8 +612,8 @@ const copy = {
|
|
|
612
612
|
delete: "Delete (permanent)",
|
|
613
613
|
scope: "Scope",
|
|
614
614
|
all: "Everything (includes your own sessions)",
|
|
615
|
-
crew: "Crew-created only
|
|
616
|
-
worktree: "Crew
|
|
615
|
+
crew: "Crew-created only",
|
|
616
|
+
worktree: "Crew workspaces only (recommended)",
|
|
617
617
|
before: "Before a date",
|
|
618
618
|
time: "Created before",
|
|
619
619
|
timeHint: "Local timezone; strict creation-time cutoff. Workspaces with newer sessions are kept.",
|
|
@@ -651,7 +651,8 @@ function HistoryPanel({ locale }) {
|
|
|
651
651
|
const t = copy[locale === "zh" ? "zh" : "en"];
|
|
652
652
|
const [open, setOpen] = (0, react.useState)(false);
|
|
653
653
|
const [operation, setOperation] = (0, react.useState)("archive");
|
|
654
|
-
const [scope, setScope] = (0, react.useState)("
|
|
654
|
+
const [scope, setScope] = (0, react.useState)("worktree");
|
|
655
|
+
const timeScoped = scope === "before" || scope === "worktree";
|
|
655
656
|
const [before, setBefore] = (0, react.useState)("");
|
|
656
657
|
const [preview, setPreview] = (0, react.useState)(null);
|
|
657
658
|
const [status, setStatus] = (0, react.useState)({ phase: "IDLE" });
|
|
@@ -816,7 +817,7 @@ function HistoryPanel({ locale }) {
|
|
|
816
817
|
})
|
|
817
818
|
] })]
|
|
818
819
|
}),
|
|
819
|
-
|
|
820
|
+
timeScoped && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [
|
|
820
821
|
t.time,
|
|
821
822
|
" ",
|
|
822
823
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
@@ -840,11 +841,11 @@ function HistoryPanel({ locale }) {
|
|
|
840
841
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
841
842
|
type: "button",
|
|
842
843
|
style,
|
|
843
|
-
disabled: locked || !connected ||
|
|
844
|
+
disabled: locked || !connected || timeScoped && before !== "" && !Number.isFinite(new Date(before).getTime()) || scope === "before" && before === "",
|
|
844
845
|
onClick: () => void act("preview", {
|
|
845
846
|
operation,
|
|
846
847
|
scope,
|
|
847
|
-
...
|
|
848
|
+
...timeScoped && before !== "" ? { before: new Date(before).toISOString() } : {}
|
|
848
849
|
}),
|
|
849
850
|
children: t.preview
|
|
850
851
|
}),
|
package/package.json
CHANGED
package/scripts/setup.mjs
CHANGED
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
ensureCrewDshRuntime,
|
|
17
17
|
ensureCrewPluginRegistration,
|
|
18
18
|
resolveDshCli,
|
|
19
|
-
runResolvedDsh,
|
|
20
19
|
describeDshCli,
|
|
21
20
|
removeCrewPluginRegistration,
|
|
22
21
|
TARGET_DSH_VERSION,
|
|
@@ -81,11 +80,12 @@ function run(cmd, args, opts = {}) {
|
|
|
81
80
|
* may use `npx -y` to fetch DSH, while status/uninstall probes never trigger a
|
|
82
81
|
* network download merely by checking availability.
|
|
83
82
|
*
|
|
84
|
-
* The returned descriptor is the CLI base only
|
|
85
|
-
* `
|
|
86
|
-
*
|
|
83
|
+
* The returned descriptor is the CLI base only, reported to the operator as
|
|
84
|
+
* `description`; the dedicated dsh-crew profile and the Crew-owned DSH_HOME
|
|
85
|
+
* are supplied by the install flow. The official ``web`` profile is never a
|
|
86
|
+
* target.
|
|
87
87
|
*/
|
|
88
|
-
|
|
88
|
+
function detectDsh({ allowDownload = false, home = homedir(), includeCompatibility = true } = {}) {
|
|
89
89
|
const resolved = resolveDshCli({
|
|
90
90
|
home,
|
|
91
91
|
allowDownload,
|
|
@@ -95,18 +95,6 @@ export function detectDsh({ allowDownload = false, home = homedir(), includeComp
|
|
|
95
95
|
return { ...resolved, cli: resolved.command, description: describeDshCli(resolved) };
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
/**
|
|
99
|
-
* Run a DSH plugin command against the dedicated Crew profile under the
|
|
100
|
-
* Crew-owned DSH_HOME. `home` is the "user home" base used to derive that
|
|
101
|
-
* isolated home, so tests can point it at a disposable root.
|
|
102
|
-
*/
|
|
103
|
-
export function runDsh(dsh, args, { home = homedir() } = {}) {
|
|
104
|
-
const resolved = dsh?.command
|
|
105
|
-
? dsh
|
|
106
|
-
: { kind: dsh?.kind ?? 'legacy', command: dsh?.cli ?? dsh, args: [] };
|
|
107
|
-
return runResolvedDsh(resolved, ['plugin', '--profile', CREW_PROFILE_NAME, ...args], { home });
|
|
108
|
-
}
|
|
109
|
-
|
|
110
98
|
export function readPackageName(root = ROOT) {
|
|
111
99
|
try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).name; } catch { return null; }
|
|
112
100
|
}
|
|
@@ -117,20 +105,11 @@ export function checkRoot(root = ROOT) {
|
|
|
117
105
|
&& existsSync(join(root, 'cordis.patch.yml'));
|
|
118
106
|
}
|
|
119
107
|
|
|
120
|
-
|
|
108
|
+
function depsPresent(root = ROOT) {
|
|
121
109
|
return existsSync(join(root, 'node_modules', '@modelcontextprotocol', 'sdk'))
|
|
122
110
|
&& existsSync(join(root, 'node_modules', '.bin', 'tsdown'));
|
|
123
111
|
}
|
|
124
112
|
|
|
125
|
-
export function profileHasPackage(home, name, profileRoot = crewProfileDir({ home })) {
|
|
126
|
-
const pkgFile = join(profileRoot, 'package.json');
|
|
127
|
-
if (!existsSync(pkgFile)) return false;
|
|
128
|
-
try {
|
|
129
|
-
const pkg = JSON.parse(readFileSync(pkgFile, 'utf8'));
|
|
130
|
-
return Boolean(pkg.dependencies?.[name]) || Boolean((pkg.dsh?.profile?.bundles ?? []).includes(name));
|
|
131
|
-
} catch { return false; }
|
|
132
|
-
}
|
|
133
|
-
|
|
134
113
|
function mark(log, ok, text) {
|
|
135
114
|
log(`${ok ? '✓' : '✗'} ${text}`);
|
|
136
115
|
return ok;
|
|
@@ -24,7 +24,7 @@ const DSH_PACKAGE = '@deepseek-ai/dsh';
|
|
|
24
24
|
const DSH_PREFIX = '@deepseek-ai/dsh-';
|
|
25
25
|
const MAX_DIRECT_PEERS = 64;
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
function resolveNpmInvocation({
|
|
28
28
|
platform = process.platform,
|
|
29
29
|
nodePath = process.execPath,
|
|
30
30
|
fileExists = existsSync,
|
|
@@ -144,7 +144,7 @@ function manifestUrl(registryUrl, name, version) {
|
|
|
144
144
|
return `${registryUrl.replace(/\/+$/, '')}/${encodedName}/${encodeURIComponent(version)}`;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
|
|
147
|
+
async function fetchPublicManifest(name, version, {
|
|
148
148
|
registryUrl = registry,
|
|
149
149
|
fetchImpl = globalThis.fetch,
|
|
150
150
|
} = {}) {
|
package/skills/dsh-crew/SKILL.md
CHANGED
|
@@ -90,20 +90,23 @@ review asks for changes, that is a task result to act on — not approval.
|
|
|
90
90
|
auditable evidence, and a reply with neither a change nor a verified check is
|
|
91
91
|
incomplete. That is the gate working, not the worker failing. A task that
|
|
92
92
|
changes nothing *on purpose* can pass — see the next entry.
|
|
93
|
-
- **A job is named `Crew_<date>_<time>_<purpose>`.**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
directory
|
|
93
|
+
- **A project has two workspaces, and a job is named `Crew_<date>_<time>_<purpose>`.**
|
|
94
|
+
Worker jobs and the review that follows one run in `dsh-crew-worker`; an
|
|
95
|
+
explicit reviewer runs in `dsh-crew-review`. The Harness groups sessions by the
|
|
96
|
+
directory a job ran in, so those two are what an operator sees in the panel,
|
|
97
|
+
however many jobs have run. The job's own name still distinguishes it inside
|
|
98
|
+
the group.
|
|
97
99
|
- **A verified zero-change task needs `constraints.allow_no_changes: true`.**
|
|
98
100
|
For a deliberately temporary job — create, verify, clean up, end with an empty
|
|
99
101
|
diff — that flag plus the reported checks is what certifies it. It requires a
|
|
100
102
|
clean, readable baseline, at least one `PASS` and no `FAIL`, and it relaxes
|
|
101
103
|
nothing else: no evidence, a failed check, a dirty baseline or an actual change
|
|
102
104
|
each still refuse.
|
|
103
|
-
- **Isolated workspaces need git.** The default
|
|
105
|
+
- **Isolated workspaces need git.** The default isolation fails with
|
|
104
106
|
`NOT_GIT_REPOSITORY` for a non-git workspace rather than silently sharing the
|
|
105
|
-
tree
|
|
106
|
-
|
|
107
|
+
tree; a directory already sitting at the workspace path that Crew did not
|
|
108
|
+
create fails with `WORKSPACE_CONFLICT` instead of being deleted. Use `shared`
|
|
109
|
+
deliberately if that is what you want; `allow_no_changes` works in either.
|
|
107
110
|
- **A repository with no commits cannot be isolated.** `git init` with nothing
|
|
108
111
|
committed reports `REPOSITORY_HAS_NO_COMMITS` — there is no revision to start
|
|
109
112
|
from. Commit once, or run that job with `shared`.
|
|
@@ -140,11 +143,13 @@ unrestricted. Defaults mirror DeepSeek's published peak hours.
|
|
|
140
143
|
|
|
141
144
|
## Work
|
|
142
145
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
`
|
|
146
|
+
The default isolation gives a coding worker a git worktree of its own project,
|
|
147
|
+
and the primary tree is untouched. That worktree is the project's stable worker
|
|
148
|
+
or reviewer workspace, reset to the job's base revision before it runs, so the
|
|
149
|
+
candidate diff is that job's work alone. One job at a time runs in each — a
|
|
150
|
+
second finds the workspace held and is refused with `WORKSPACE_BUSY` rather than
|
|
151
|
+
sharing it, because two jobs in one tree would each see the other's edits. So
|
|
152
|
+
concurrency is bounded by workspaces even where `max_parallel` is higher.
|
|
148
153
|
|
|
149
154
|
The reviewer gate is `required` by default: an unreviewed change fails. Review
|
|
150
155
|
findings and failing tests are results to address, never a reason to bypass the
|
|
@@ -153,9 +158,13 @@ gate.
|
|
|
153
158
|
## History
|
|
154
159
|
|
|
155
160
|
Session storage is shared between 3080 and 3210 — clearing one clears both.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
The default scope is **Crew's own workspaces**: sessions Crew recorded creating
|
|
162
|
+
that ran in `dsh-crew-worker`, `dsh-crew-review`, or a per-job worktree an
|
|
163
|
+
earlier release left behind. The operator's own conversations survive, and so
|
|
164
|
+
does anything Crew ran in a directory that is not one of its workspaces.
|
|
165
|
+
`all` removes theirs too; use it deliberately. The panel's date field narrows
|
|
166
|
+
any scope to sessions created before it, and workspaces with newer sessions are
|
|
167
|
+
kept. Archived batches are restorable from the panel.
|
|
159
168
|
|
|
160
169
|
## When Crew is unavailable
|
|
161
170
|
|
package/src/adaptive-routing.mjs
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// caller may record only outcomes Crew already observed, and routing decisions
|
|
6
6
|
// expose bounded evidence suitable for model-selection trace metadata.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
const ADAPTIVE_ROUTING_VERSION = 1;
|
|
9
|
+
const ADAPTIVE_ROUTING_DEFAULTS = Object.freeze({
|
|
10
10
|
enabled: false,
|
|
11
11
|
window_size: 8,
|
|
12
12
|
min_samples: 2,
|
package/src/ci-evidence.mjs
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
// one; nothing here reads credentials from disk, and the value is never logged,
|
|
30
30
|
// returned, or included in an evidence record.
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
const CI_EVIDENCE_REPO = 'Ran-sh/dsh-crew';
|
|
33
33
|
|
|
34
34
|
// The row each CI job validates. A platform with no job in the workflow cannot
|
|
35
35
|
// be evidenced by any run, so `macos_smoke` is deliberately absent rather than
|
|
@@ -5,7 +5,7 @@ const pending = (phase?: string) => !!phase && !['IDLE', 'DONE', 'FAILED', 'ROLL
|
|
|
5
5
|
const copy = {
|
|
6
6
|
zh: {
|
|
7
7
|
title: '工作区与会话清理', intro: '仅影响 3210 的工作区记录与会话日志;不删除项目文件、附件或 3080 数据。',
|
|
8
|
-
operation: '操作', archive: '归档(可恢复)', delete: '删除(不可恢复)', scope: '范围', all: '全部(含你自己的会话)', crew: '仅 Crew
|
|
8
|
+
operation: '操作', archive: '归档(可恢复)', delete: '删除(不可恢复)', scope: '范围', all: '全部(含你自己的会话)', crew: '仅 Crew 创建', worktree: '仅 Crew 工作区(推荐)', before: '指定时间之前',
|
|
9
9
|
time: '创建时间早于', timeHint: '按本机时区选择,严格按创建时间筛选;包含新会话的工作区会保留。',
|
|
10
10
|
preview: '预览清理范围', confirm: '确认执行', acknowledgement: '删除确认:请输入 DELETE', idle: '会短暂停止并重启 3210;请先结束或关闭所有正在使用的 3210 会话。',
|
|
11
11
|
consent: '我已确认范围,并同意短暂重启 3210', restore: '恢复', archives: '已归档批次', empty: '暂无可恢复的归档',
|
|
@@ -17,7 +17,7 @@ const copy = {
|
|
|
17
17
|
},
|
|
18
18
|
en: {
|
|
19
19
|
title: 'Workspace & session cleanup', intro: 'Only 3210 workspace records and session logs. Project files, attachments and 3080 data are untouched.',
|
|
20
|
-
operation: 'Action', archive: 'Archive (restorable)', delete: 'Delete (permanent)', scope: 'Scope', all: 'Everything (includes your own sessions)', crew: 'Crew-created only
|
|
20
|
+
operation: 'Action', archive: 'Archive (restorable)', delete: 'Delete (permanent)', scope: 'Scope', all: 'Everything (includes your own sessions)', crew: 'Crew-created only', worktree: 'Crew workspaces only (recommended)', before: 'Before a date',
|
|
21
21
|
time: 'Created before', timeHint: 'Local timezone; strict creation-time cutoff. Workspaces with newer sessions are kept.',
|
|
22
22
|
preview: 'Preview cleanup', confirm: 'Confirm operation', acknowledgement: 'Type DELETE to confirm deletion', idle: '3210 will briefly stop and restart. End or close all active 3210 conversations first.',
|
|
23
23
|
consent: 'I reviewed the scope and agree to restart 3210', restore: 'Restore', archives: 'Archived batches', empty: 'No restorable archives',
|
|
@@ -33,7 +33,8 @@ export function HistoryPanel({ locale }: { locale: string }) {
|
|
|
33
33
|
const t = copy[locale === 'zh' ? 'zh' : 'en'];
|
|
34
34
|
const [open, setOpen] = useState(false);
|
|
35
35
|
const [operation, setOperation] = useState('archive');
|
|
36
|
-
const [scope, setScope] = useState('
|
|
36
|
+
const [scope, setScope] = useState('worktree');
|
|
37
|
+
const timeScoped = scope === 'before' || scope === 'worktree';
|
|
37
38
|
const [before, setBefore] = useState('');
|
|
38
39
|
const [preview, setPreview] = useState<any>(null);
|
|
39
40
|
const [status, setStatus] = useState<any>({ phase: 'IDLE' });
|
|
@@ -83,9 +84,9 @@ export function HistoryPanel({ locale }: { locale: string }) {
|
|
|
83
84
|
<label>{t.operation} <select style={style} value={operation} disabled={locked} onChange={e => { setOperation(e.target.value); invalidate(); }}><option value="archive">{t.archive}</option><option value="delete">{t.delete}</option></select></label>
|
|
84
85
|
<label>{t.scope} <select style={style} value={scope} disabled={locked} onChange={e => { setScope(e.target.value); invalidate(); }}><option value="crew">{t.crew}</option><option value="worktree">{t.worktree}</option><option value="all">{t.all}</option><option value="before">{t.before}</option></select></label>
|
|
85
86
|
</div>
|
|
86
|
-
{
|
|
87
|
+
{timeScoped && <label>{t.time} <input type="datetime-local" style={style} value={before} disabled={locked} onChange={e => { setBefore(e.target.value); invalidate(); }} /></label>}
|
|
87
88
|
<div style={{ fontSize: 11.5, opacity: .7 }}>{t.timeHint}</div>
|
|
88
|
-
<button type="button" style={style} disabled={locked || !connected || (
|
|
89
|
+
<button type="button" style={style} disabled={locked || !connected || (timeScoped && before !== '' && !Number.isFinite(new Date(before).getTime())) || (scope === 'before' && before === '')} onClick={() => void act('preview', { operation, scope, ...(timeScoped && before !== '' ? { before: new Date(before).toISOString() } : {}) })}>{t.preview}</button>
|
|
89
90
|
{preview && <div style={{ ...style, display: 'flex', flexDirection: 'column', gap: 9 }}>
|
|
90
91
|
<strong>{t.count(preview.counts.workspaces, preview.counts.sessions)}</strong>
|
|
91
92
|
{preview.before && <span>{t.time}: {new Date(preview.before).toLocaleString()}</span>}
|
|
@@ -9,7 +9,6 @@ import { PanelHeader, PanelStyles } from './panel-chrome';
|
|
|
9
9
|
// (/_dsh/dsh-crew/quick-config, /quick-status). Runtime maintenance stays on 3210.
|
|
10
10
|
|
|
11
11
|
const API = '/_dsh/dsh-crew';
|
|
12
|
-
const CREW_CONTROL_PLANE_URL = 'http://127.0.0.1:3210/';
|
|
13
12
|
const FULL = 'http://127.0.0.1:3210/';
|
|
14
13
|
|
|
15
14
|
type ModelEntry = { provider: string; model: string };
|
|
@@ -4,9 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
import { createHash, randomUUID } from 'node:crypto';
|
|
6
6
|
|
|
7
|
-
export const CREDENTIAL_PURGE_STATES = Object.freeze([
|
|
8
|
-
'PLANNED', 'REFERENCE_RECHECKED', 'PURGE_PENDING_CONFIRMATION', 'PURGED', 'VERIFIED', 'FAILED',
|
|
9
|
-
]);
|
|
10
7
|
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$/;
|
|
11
8
|
const REVISION_PATTERN = /^[a-f0-9]{64}$/;
|
|
12
9
|
|
|
@@ -36,7 +36,7 @@ export function markCredentialPurged(input, plan) {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
function markCredentialPurgeUnverified(input, plan) {
|
|
40
40
|
const state = normalizeCredentialPurgeState(input);
|
|
41
41
|
if (!validId(plan?.reference_id) || !validId(plan?.plan_id)) return state;
|
|
42
42
|
const purged = { ...state.purged };
|
|
@@ -53,7 +53,3 @@ export function recordCredentialPurgeOutcome(input, plan, result) {
|
|
|
53
53
|
if (result?.state === 'PURGED') return markCredentialPurgeUnverified(input, plan);
|
|
54
54
|
return normalizeCredentialPurgeState(input);
|
|
55
55
|
}
|
|
56
|
-
|
|
57
|
-
export function isCredentialPurged(input, referenceId) {
|
|
58
|
-
return Object.prototype.hasOwnProperty.call(normalizeCredentialPurgeState(input).purged, referenceId);
|
|
59
|
-
}
|
|
@@ -28,11 +28,3 @@ export function classifyCredentialReference(value, { kind = 'env' } = {}) {
|
|
|
28
28
|
: HANDLE_REFERENCE.test(raw) && !SECRET_LIKE.test(raw) && !EMBEDDED_SECRET.test(raw);
|
|
29
29
|
return { present: true, value: valid ? raw : null, redacted: !valid };
|
|
30
30
|
}
|
|
31
|
-
|
|
32
|
-
export function sanitizeCredentialReference(value, options = {}) {
|
|
33
|
-
return classifyCredentialReference(value, options).value;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function hasCredentialReference(value, options = {}) {
|
|
37
|
-
return classifyCredentialReference(value, options).present;
|
|
38
|
-
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The two stable workspaces a Crew job runs in, one pair per project.
|
|
2
|
+
//
|
|
3
|
+
// Every job used to get its own git worktree named `Crew_<date>_<time>_<role>`,
|
|
4
|
+
// so the Harness — which groups sessions by the job's cwd, and matches the
|
|
5
|
+
// workspace exactly (`resolveByPath`, never a parent directory) — grew a new
|
|
6
|
+
// workspace entry for every job an operator ran. A pair that is created once and
|
|
7
|
+
// reused means the session list stops growing: a worker job and its automatic
|
|
8
|
+
// review both run in `dsh-crew-worker` (the reviewer has to see the tree the
|
|
9
|
+
// worker produced), and an explicit reviewer dispatch runs in `dsh-crew-review`.
|
|
10
|
+
//
|
|
11
|
+
// Two cwds per project, never shared between projects: a worktree belongs to one
|
|
12
|
+
// repository, so the pair is namespaced under a key derived from the repository
|
|
13
|
+
// path.
|
|
14
|
+
//
|
|
15
|
+
// Because the workspace outlives the job, two jobs sharing it would corrupt each
|
|
16
|
+
// other's evidence — see `workspace-lock.mjs`, which this takes for the whole
|
|
17
|
+
// job, and the reset below, which is what keeps a job from inheriting the last
|
|
18
|
+
// one's work.
|
|
19
|
+
|
|
20
|
+
import { existsSync } from 'node:fs';
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { basename, join, resolve, sep } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
createWorktreeAt,
|
|
26
|
+
crewOwnsWorktree,
|
|
27
|
+
defaultWorktreeRoot,
|
|
28
|
+
inspectRepository,
|
|
29
|
+
resetWorktreeTo,
|
|
30
|
+
} from './workspace-isolation.mjs';
|
|
31
|
+
import { acquireWorkspaceLock, DEFAULT_LOCK_WAIT_MS } from './workspace-lock.mjs';
|
|
32
|
+
|
|
33
|
+
export const CREW_WORKSPACE_WORKER = 'dsh-crew-worker';
|
|
34
|
+
export const CREW_WORKSPACE_REVIEW = 'dsh-crew-review';
|
|
35
|
+
const CREW_WORKSPACE_NAMES = Object.freeze([CREW_WORKSPACE_WORKER, CREW_WORKSPACE_REVIEW]);
|
|
36
|
+
export const WORKSPACE_CONFLICT = 'WORKSPACE_CONFLICT';
|
|
37
|
+
|
|
38
|
+
/** The workspace a role runs in. Only the reviewer is separated out. */
|
|
39
|
+
export function crewWorkspaceName(role) {
|
|
40
|
+
return role === 'reviewer' || role === 'review' ? CREW_WORKSPACE_REVIEW : CREW_WORKSPACE_WORKER;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Is this directory name one of the two stable workspaces? */
|
|
44
|
+
export function isCrewWorkspaceName(name) {
|
|
45
|
+
return CREW_WORKSPACE_NAMES.includes(String(name ?? ''));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A stable, filesystem-safe key for a repository.
|
|
50
|
+
*
|
|
51
|
+
* Two projects' `dsh-crew-worker` directories must not collide, and the human
|
|
52
|
+
* part is kept because the key is visible in the session path: the directory
|
|
53
|
+
* name plus eight hex characters of the resolved path, the same shape the legacy
|
|
54
|
+
* worktree names used.
|
|
55
|
+
*/
|
|
56
|
+
export function crewProjectKey(repoRoot) {
|
|
57
|
+
const identity = resolve(String(repoRoot ?? '')).split(sep).join('/');
|
|
58
|
+
const normalized = process.platform === 'win32' ? identity.toLowerCase() : identity;
|
|
59
|
+
const safe = basename(identity).replace(/[^A-Za-z0-9]+/g, '-').slice(0, 32).replace(/^-+|-+$/g, '') || 'project';
|
|
60
|
+
const digest = createHash('sha256').update(normalized).digest('hex').slice(0, 8);
|
|
61
|
+
return `dsh-crew-${safe}-${digest}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `<worktreeRoot>/<projectKey>/<name>` — where this project's pair lives. */
|
|
65
|
+
export function crewWorkspacePath({ repoRoot, role, root = defaultWorktreeRoot() } = {}) {
|
|
66
|
+
return join(resolve(root), crewProjectKey(repoRoot), crewWorkspaceName(role));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The lock for a workspace lives beside the pair, never inside the tree. */
|
|
70
|
+
export function crewWorkspaceLockPath({ repoRoot, role, root = defaultWorktreeRoot() } = {}) {
|
|
71
|
+
return join(resolve(root), crewProjectKey(repoRoot), '.locks', `${crewWorkspaceName(role)}.lock`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The workspace this job should run in, held for the job's duration.
|
|
76
|
+
*
|
|
77
|
+
* Creates the pair member on first use and adopts it afterwards; the adopted
|
|
78
|
+
* tree is reset to the job's base revision so the candidate diff is this job's
|
|
79
|
+
* work alone, exactly as a fresh worktree gave it. Returns a `release` that
|
|
80
|
+
* drops the lock but leaves the directory — the next job reuses it.
|
|
81
|
+
*/
|
|
82
|
+
export async function ensureCrewWorkspace({
|
|
83
|
+
cwd,
|
|
84
|
+
role,
|
|
85
|
+
baseRevision,
|
|
86
|
+
at,
|
|
87
|
+
root = defaultWorktreeRoot(),
|
|
88
|
+
git,
|
|
89
|
+
lockWaitMs = DEFAULT_LOCK_WAIT_MS,
|
|
90
|
+
now,
|
|
91
|
+
sleep,
|
|
92
|
+
isAlive,
|
|
93
|
+
pid,
|
|
94
|
+
} = {}) {
|
|
95
|
+
const repo = await inspectRepository({ cwd, git });
|
|
96
|
+
if (!repo.ok) return { ok: false, reason: repo.reason, error: repo.error };
|
|
97
|
+
const repoRoot = repo.repoRoot;
|
|
98
|
+
const revision = baseRevision ?? repo.baseRevision;
|
|
99
|
+
const name = crewWorkspaceName(role);
|
|
100
|
+
const worktreePath = crewWorkspacePath({ repoRoot, role, root });
|
|
101
|
+
const lockPath = crewWorkspaceLockPath({ repoRoot, role, root });
|
|
102
|
+
|
|
103
|
+
const lock = await acquireWorkspaceLock({ lockPath, purpose: name, waitMs: lockWaitMs, now, sleep, isAlive, pid });
|
|
104
|
+
if (!lock.ok) return { ok: false, reason: lock.reason, error: lock.error, holder: lock.holder ?? null };
|
|
105
|
+
const release = lock.release;
|
|
106
|
+
|
|
107
|
+
const fail = (reason, error) => {
|
|
108
|
+
release();
|
|
109
|
+
return { ok: false, reason, error };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const reused = await crewOwnsWorktree({ worktreePath, repoRoot, git });
|
|
113
|
+
if (!reused) {
|
|
114
|
+
if (existsSync(worktreePath)) {
|
|
115
|
+
// Something is there that Crew did not create. Deleting it would be a
|
|
116
|
+
// destructive guess about a directory Crew cannot claim, so refuse and
|
|
117
|
+
// say which path is in the way.
|
|
118
|
+
return fail(WORKSPACE_CONFLICT, `workspace path exists but is not a Crew worktree of this repository: ${worktreePath}`);
|
|
119
|
+
}
|
|
120
|
+
const created = await createWorktreeAt({ dir: worktreePath, repoRoot, revision, purpose: name, at, git });
|
|
121
|
+
if (!created.ok) return fail(created.reason, created.error);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const reset = await resetWorktreeTo({ worktreePath, revision, git });
|
|
125
|
+
if (!reset.ok) return fail(reset.reason, reset.error);
|
|
126
|
+
|
|
127
|
+
return { ok: true, worktreePath, repoRoot, baseRevision: revision, name, reused, release };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Release the lock a job held. The directory stays: it is the workspace the
|
|
132
|
+
* next job — and every session it produced — belongs to.
|
|
133
|
+
*/
|
|
134
|
+
export function releaseCrewWorkspace(handle) {
|
|
135
|
+
try { return handle?.release?.() === true; } catch { return false; }
|
|
136
|
+
}
|
package/src/delivery.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
export const DELIVERY_SECTIONS = ['Diff', 'Tests', 'Risks'];
|
|
13
13
|
export const OPTIONAL_DELIVERY_SECTIONS = ['Unverified'];
|
|
14
|
-
|
|
14
|
+
const ALL_DELIVERY_SECTIONS = [...DELIVERY_SECTIONS, ...OPTIONAL_DELIVERY_SECTIONS];
|
|
15
15
|
|
|
16
16
|
export const REVIEW_SECTIONS = ['Review Findings', 'Evidence', 'Risks', 'Verdict'];
|
|
17
17
|
|
|
@@ -129,7 +129,7 @@ export function appendDeliveryInstructions(task, { tier, isReview } = {}) {
|
|
|
129
129
|
* visible evidence. Two parsers gave two answers about one section; there is now
|
|
130
130
|
* one, and it lives here.
|
|
131
131
|
*/
|
|
132
|
-
|
|
132
|
+
function parseTestRow(line) {
|
|
133
133
|
const normalized = String(line ?? '')
|
|
134
134
|
.replace(/^\s*[-*+]\s+/, '')
|
|
135
135
|
.replace(/^\|/, '')
|