@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.
Files changed (56) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/docs/job-contracts.md +14 -1
  3. package/docs/readiness-matrix.md +20 -1
  4. package/lib/client.js +9 -8
  5. package/package.json +1 -1
  6. package/scripts/setup.mjs +6 -27
  7. package/scripts/verify-npm-install.mjs +2 -2
  8. package/skills/dsh-crew/SKILL.md +24 -15
  9. package/src/adaptive-routing.mjs +2 -2
  10. package/src/ci-evidence.mjs +1 -1
  11. package/src/client/history-panel.tsx +6 -5
  12. package/src/client/quick-panel.tsx +0 -1
  13. package/src/credential-lifecycle.mjs +0 -3
  14. package/src/credential-purge-state.mjs +1 -5
  15. package/src/credential-reference.mjs +0 -8
  16. package/src/crew-workspaces.mjs +136 -0
  17. package/src/delivery.mjs +2 -2
  18. package/src/dsh-cli-runtime.mjs +18 -22
  19. package/src/extension-contract.mjs +1 -1
  20. package/src/failure-classification.mjs +0 -10
  21. package/src/history/cleanup-plan.mjs +15 -5
  22. package/src/hub/entry.mjs +1 -1
  23. package/src/hub/index.mjs +45 -19
  24. package/src/i18n.mjs +0 -2
  25. package/src/install/crew-skill.mjs +1 -1
  26. package/src/install/install-legacy.mjs +3 -3
  27. package/src/install/npx-lifecycle.mjs +60 -112
  28. package/src/install/official-web.mjs +1 -1
  29. package/src/install/windows-startup.mjs +5 -5
  30. package/src/install/windows-supervisor-adapter.mjs +3 -2
  31. package/src/install/windows-supervisor-lifecycle.mjs +2 -2
  32. package/src/install/zcode.mjs +0 -2
  33. package/src/job-contracts.mjs +1 -1
  34. package/src/job-identity.mjs +13 -6
  35. package/src/jobs.mjs +3 -4
  36. package/src/mcp-runtime.mjs +17 -12
  37. package/src/model-routing.mjs +3 -4
  38. package/src/model-schedule.mjs +0 -1
  39. package/src/policy-legacy.mjs +10 -12
  40. package/src/provider-inventory.mjs +1 -1
  41. package/src/provider-layer-migration.mjs +0 -2
  42. package/src/readiness-matrix.mjs +1 -1
  43. package/src/release-in-use.mjs +1 -1
  44. package/src/role-profiles.mjs +2 -2
  45. package/src/runtime-controls.mjs +1 -1
  46. package/src/runtime-identity.mjs +1 -1
  47. package/src/status-shard.mjs +1 -1
  48. package/src/supervisor/restart-request.mjs +4 -4
  49. package/src/workflow-runtime.mjs +1 -2
  50. package/src/workspace-context.mjs +2 -2
  51. package/src/workspace-isolation.mjs +71 -4
  52. package/src/workspace-lock.mjs +134 -0
  53. package/scripts/policy-probe.mjs +0 -101
  54. package/scripts/verify-crew-ui-polish.mjs +0 -145
  55. package/scripts/verify-history-ui.mjs +0 -98
  56. package/scripts/verify-installer-fix.mjs +0 -26
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crew",
3
- "version": "2.1.8",
3
+ "version": "2.2.1",
4
4
  "description": "Dispatch subtasks to DeepSeek Harness (DSH) agents as native subagents with live progress",
5
5
  "author": {
6
6
  "name": "ZSeven-W"
@@ -8,7 +8,7 @@ back to the caller.
8
8
 
9
9
  ```text
10
10
  caller objective
11
- -> Worker in isolated workspace
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
@@ -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 (recommended)",
616
- worktree: "Crew worktrees only",
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)("crew");
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
- scope === "before" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [
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 || scope === "before" && !Number.isFinite(new Date(before).getTime()),
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
- ...scope === "before" ? { before: new Date(before).toISOString() } : {}
848
+ ...timeScoped && before !== "" ? { before: new Date(before).toISOString() } : {}
848
849
  }),
849
850
  children: t.preview
850
851
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "2.1.8",
3
+ "version": "2.2.1",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
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; every invocation is built by
85
- * `runDsh` with the dedicated dsh-crew profile and the Crew DSH_HOME in the
86
- * child process environment. The official ``web`` profile is never a target.
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
- export function detectDsh({ allowDownload = false, home = homedir(), includeCompatibility = true } = {}) {
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
- export function depsPresent(root = ROOT) {
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
- export function resolveNpmInvocation({
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
- export async function fetchPublicManifest(name, version, {
147
+ async function fetchPublicManifest(name, version, {
148
148
  registryUrl = registry,
149
149
  fetchImpl = globalThis.fetch,
150
150
  } = {}) {
@@ -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>`.** The worktree directory, the
94
- session the Harness lists in its workspace panel, and the name in a status
95
- payload all use that one string, so a conversation can be matched to a
96
- directory by eye. The purpose is the role: `worker` or `reviewer`.
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 `worktree` isolation fails with
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. Use `shared` deliberately if that is what you want; `allow_no_changes`
106
- works in either.
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
- `isolation: worktree` is the default: a coding worker runs in a per-job git
144
- worktree and the primary tree is untouched. Non-git workspaces fail with
145
- `NOT_GIT_REPOSITORY` rather than silently sharing the tree; use `shared`
146
- explicitly if that is what you want. Concurrent jobs are capped by
147
- `max_parallel`.
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
- Cleanup is scoped by provenance, and the default scope is **Crew-created only**,
157
- so the operator's own conversations survive. `all` removes theirs too; use it
158
- deliberately. Archived batches are restorable from the panel.
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
 
@@ -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
- export const ADAPTIVE_ROUTING_VERSION = 1;
9
- export const ADAPTIVE_ROUTING_DEFAULTS = Object.freeze({
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,
@@ -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
- export const CI_EVIDENCE_REPO = 'Ran-sh/dsh-crew';
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 创建(推荐)', worktree: '仅 Crew 工作树', before: '指定时间之前',
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 (recommended)', worktree: 'Crew worktrees only', before: 'Before a date',
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('crew');
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
- {scope === 'before' && <label>{t.time} <input type="datetime-local" style={style} value={before} disabled={locked} onChange={e => { setBefore(e.target.value); invalidate(); }} /></label>}
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 || (scope === 'before' && !Number.isFinite(new Date(before).getTime()))} onClick={() => void act('preview', { operation, scope, ...(scope === 'before' ? { before: new Date(before).toISOString() } : {}) })}>{t.preview}</button>
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
- export function markCredentialPurgeUnverified(input, plan) {
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
- export const ALL_DELIVERY_SECTIONS = [...DELIVERY_SECTIONS, ...OPTIONAL_DELIVERY_SECTIONS];
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
- export function parseTestRow(line) {
132
+ function parseTestRow(line) {
133
133
  const normalized = String(line ?? '')
134
134
  .replace(/^\s*[-*+]\s+/, '')
135
135
  .replace(/^\|/, '')