@ran-sh/dsh-crew 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +8 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.de.md +359 -0
- package/README.es.md +359 -0
- package/README.fr.md +359 -0
- package/README.hi.md +359 -0
- package/README.id.md +359 -0
- package/README.ja.md +359 -0
- package/README.ko.md +359 -0
- package/README.md +360 -0
- package/README.pt.md +359 -0
- package/README.ru.md +359 -0
- package/README.th.md +359 -0
- package/README.tr.md +359 -0
- package/README.vi.md +359 -0
- package/README.zh-TW.md +359 -0
- package/README.zh.md +305 -0
- package/agents/ds-flash.md +26 -0
- package/agents/ds-pro.md +32 -0
- package/agents/ds-reviewer.md +23 -0
- package/agents/ds-worker.md +22 -0
- package/codex/agents/ds-flash.toml +30 -0
- package/codex/agents/ds-pro.toml +31 -0
- package/codex/agents/ds-reviewer.toml +28 -0
- package/codex/agents/ds-worker.toml +28 -0
- package/codex/prompts/dsh-config.md +3 -0
- package/codex/prompts/dsh-status.md +1 -0
- package/commands/config.md +11 -0
- package/commands/off.md +5 -0
- package/commands/on.md +5 -0
- package/commands/status.md +5 -0
- package/cordis.patch.yml +4 -0
- package/docs/images/dsh-crew-host.png +0 -0
- package/docs/images/dsh-crew-jobs.png +0 -0
- package/docs/images/dsh-crew-logo.png +0 -0
- package/docs/images/dsh-crew-overview.png +0 -0
- package/lib/client.js +2765 -0
- package/package.json +125 -0
- package/scripts/build-client.mjs +28 -0
- package/scripts/live-crew-smoke.mjs +39 -0
- package/scripts/live-policy-matrix.mjs +177 -0
- package/scripts/policy-probe.mjs +101 -0
- package/scripts/setup.mjs +294 -0
- package/scripts/smoke-real.mjs +110 -0
- package/scripts/smoke.mjs +78 -0
- package/scripts/verify-installer-fix.mjs +26 -0
- package/src/adaptive-routing.mjs +260 -0
- package/src/client/activation-summary.tsx +64 -0
- package/src/client/entry.tsx +236 -0
- package/src/client/index.tsx +1120 -0
- package/src/config-readiness.mjs +59 -0
- package/src/delivery.mjs +205 -0
- package/src/dsh-cli-runtime.mjs +251 -0
- package/src/failure-classification.mjs +172 -0
- package/src/hub/entry.mjs +98 -0
- package/src/hub/index.mjs +757 -0
- package/src/hub-client.mjs +132 -0
- package/src/hub-compatibility.mjs +49 -0
- package/src/i18n.mjs +19 -0
- package/src/install/cli.mjs +28 -0
- package/src/install/install-legacy.mjs +460 -0
- package/src/install/install.mjs +451 -0
- package/src/jobs.mjs +275 -0
- package/src/mcp-runtime.mjs +257 -0
- package/src/model-catalog.mjs +173 -0
- package/src/model-routing.mjs +391 -0
- package/src/multimodal.mjs +0 -0
- package/src/policy-legacy.mjs +830 -0
- package/src/policy.mjs +197 -0
- package/src/readiness-matrix.mjs +169 -0
- package/src/runtime-controls.mjs +90 -0
- package/src/runtime-identity.mjs +108 -0
- package/src/server.mjs +477 -0
- package/src/status-shard.mjs +52 -0
- package/src/structured-error-code.mjs +39 -0
- package/src/vision-route.mjs +138 -0
- package/src/workflow-runtime.mjs +567 -0
- package/src/workflow.mjs +160 -0
- package/src/workspace-audit.mjs +231 -0
- package/src/workspace-isolation.mjs +306 -0
- package/statusline/statusline.sh +14 -0
- package/statusline/worker-segment.sh +35 -0
- package/worker.cordis.yml +77 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { buildReadinessMatrix, READINESS_REASON_CODES } from './readiness-matrix.mjs';
|
|
2
|
+
|
|
3
|
+
function warningCodes(catalogBody) {
|
|
4
|
+
const hints = Array.isArray(catalogBody?.health?.hints) ? catalogBody.health.hints : [];
|
|
5
|
+
return [...new Set(hints
|
|
6
|
+
.filter((hint) => hint?.level === 'warning' && typeof hint?.code === 'string' && hint.code.trim())
|
|
7
|
+
.map((hint) => hint.code.trim()))];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Enrich the conservative runtime matrix with evidence the config report has
|
|
12
|
+
* already collected. This function performs no I/O and never reads provider
|
|
13
|
+
* configuration, credentials, quotas, pricing, or hidden catalog expectations.
|
|
14
|
+
*/
|
|
15
|
+
export function buildConfigReadinessMatrix({
|
|
16
|
+
platform = process.platform,
|
|
17
|
+
hubCompatibility = null,
|
|
18
|
+
workerProviderMode = null,
|
|
19
|
+
providerCatalogChecked = false,
|
|
20
|
+
providerCatalogBody = null,
|
|
21
|
+
} = {}) {
|
|
22
|
+
const warnings = warningCodes(providerCatalogBody);
|
|
23
|
+
const catalogResponseOk = !!providerCatalogBody
|
|
24
|
+
&& typeof providerCatalogBody === 'object'
|
|
25
|
+
&& providerCatalogBody.ok !== false;
|
|
26
|
+
const catalogOk = providerCatalogChecked && catalogResponseOk && warnings.length === 0;
|
|
27
|
+
|
|
28
|
+
const evidence = {};
|
|
29
|
+
if (
|
|
30
|
+
workerProviderMode !== 'deepseek-official'
|
|
31
|
+
&& hubCompatibility?.compatible === true
|
|
32
|
+
&& providerCatalogChecked
|
|
33
|
+
&& catalogResponseOk
|
|
34
|
+
&& warnings.length > 0
|
|
35
|
+
) {
|
|
36
|
+
evidence.provider_catalog = {
|
|
37
|
+
status: 'FAIL',
|
|
38
|
+
reason_code: READINESS_REASON_CODES.PROVIDER_CATALOG_HEALTH_WARNING,
|
|
39
|
+
evidence_source: 'harness-catalog',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const matrix = buildReadinessMatrix({
|
|
44
|
+
platform,
|
|
45
|
+
hubCompatibility,
|
|
46
|
+
workerProviderMode,
|
|
47
|
+
providerCatalogChecked,
|
|
48
|
+
providerCatalogOk: catalogOk,
|
|
49
|
+
evidence,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (warnings.length === 0) return matrix;
|
|
53
|
+
return {
|
|
54
|
+
...matrix,
|
|
55
|
+
rows: matrix.rows.map((row) => row.id === 'provider_catalog'
|
|
56
|
+
? { ...row, detail_codes: warnings }
|
|
57
|
+
: row),
|
|
58
|
+
};
|
|
59
|
+
}
|
package/src/delivery.mjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Auditable worker delivery: one shared Delivery Contract that every coding
|
|
2
|
+
// worker (and the automatic Pro review) must fill out before its result is
|
|
3
|
+
// accepted, plus pure helpers to prompt for, parse, validate and format it.
|
|
4
|
+
//
|
|
5
|
+
// Everything here is a pure function (no I/O, no worker runtime), so tests can
|
|
6
|
+
// exercise the contract without starting DSH. Keeping the contract in one
|
|
7
|
+
// shared builder guarantees the prompt-construction points (jobs.mjs, the hub,
|
|
8
|
+
// the MCP shim) emit byte-identical instructions, and parse / validate let the
|
|
9
|
+
// orchestrator separate *execution* status (running/done/failed) from
|
|
10
|
+
// *delivery* completeness (did the worker actually report Diff/Tests/Risks?).
|
|
11
|
+
|
|
12
|
+
export const DELIVERY_SECTIONS = ['Diff', 'Tests', 'Risks'];
|
|
13
|
+
export const OPTIONAL_DELIVERY_SECTIONS = ['Unverified'];
|
|
14
|
+
export const ALL_DELIVERY_SECTIONS = [...DELIVERY_SECTIONS, ...OPTIONAL_DELIVERY_SECTIONS];
|
|
15
|
+
|
|
16
|
+
export const REVIEW_SECTIONS = ['Review Findings', 'Evidence', 'Risks', 'Verdict'];
|
|
17
|
+
|
|
18
|
+
/** Any worker prompt that already carries a delivery report (added once). */
|
|
19
|
+
export const DELIVERY_MARKER = '# Delivery report';
|
|
20
|
+
|
|
21
|
+
const METADATA_KEYS = {
|
|
22
|
+
Diff: 'diff',
|
|
23
|
+
Tests: 'tests',
|
|
24
|
+
Risks: 'risks',
|
|
25
|
+
Unverified: 'unverified',
|
|
26
|
+
'Review Findings': 'findings',
|
|
27
|
+
Evidence: 'evidence',
|
|
28
|
+
Verdict: 'verdict',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Prompts a coding worker (or, with isReview, an automatic Pro review) to end
|
|
33
|
+
* its final message with the auditable delivery contract. This is the single
|
|
34
|
+
* source of the contract text for every worker prompt.
|
|
35
|
+
*/
|
|
36
|
+
export function buildDeliveryInstructions({ tier = 'pro', isReview = false } = {}) {
|
|
37
|
+
if (isReview) {
|
|
38
|
+
return `# Delivery report — automatic review (worker tier: pro)
|
|
39
|
+
You are reviewing an implementation, and your review must be auditable. End your final message with these four sections — each on its own line as a '##' heading — followed by concise content:
|
|
40
|
+
|
|
41
|
+
## Review Findings
|
|
42
|
+
One-line overall assessment of whether the implementation satisfies the task.
|
|
43
|
+
|
|
44
|
+
## Evidence
|
|
45
|
+
What you inspected: file paths, diffs, commands you ran, and their results.
|
|
46
|
+
|
|
47
|
+
## Risks
|
|
48
|
+
Concrete issues found: bugs, style problems, missing edge cases, security or secret-handling concerns.
|
|
49
|
+
|
|
50
|
+
## Verdict
|
|
51
|
+
One line: approved / needs changes / rejected, plus the single most important reason.
|
|
52
|
+
|
|
53
|
+
Do not edit files unless the user explicitly asks for fixes.`;
|
|
54
|
+
}
|
|
55
|
+
return `# Delivery report requirements (worker tier: ${String(tier).toUpperCase()})
|
|
56
|
+
You are a coding worker, and your result must be auditable. End your final message with these three mandatory sections — each on its own line as a '##' heading — followed by concise, factual content:
|
|
57
|
+
|
|
58
|
+
## Diff
|
|
59
|
+
Every file you changed or created (paths), with a one-line summary per file. If you changed nothing, write "no files changed".
|
|
60
|
+
|
|
61
|
+
## Tests
|
|
62
|
+
Every entry must use exactly one of these auditable states:
|
|
63
|
+
PASS — <command/check> — <result>
|
|
64
|
+
FAIL — <command/check> — <reason>
|
|
65
|
+
NOT RUN — <check> — <reason>
|
|
66
|
+
"none" is not a valid Tests result.
|
|
67
|
+
|
|
68
|
+
## Risks
|
|
69
|
+
Known risks and side effects: files touched outside the requested scope, assumptions you made, anything that could break, and any credentials or sensitive data you opened.
|
|
70
|
+
|
|
71
|
+
## Unverified
|
|
72
|
+
(Optional — omit entirely if you verified everything.) Anything you could not verify: skipped builds, untested platforms, known gaps.
|
|
73
|
+
|
|
74
|
+
The Diff, Tests and Risks sections are mandatory — do not skip them. Keep each section tight (a few lines is enough).
|
|
75
|
+
|
|
76
|
+
${tier === 'flash' ? `Implement only the delegated coding scope.
|
|
77
|
+
Run direct validation needed for your change.
|
|
78
|
+
Return the Delivery Report.
|
|
79
|
+
Then stop and return control to the Main Agent.
|
|
80
|
+
|
|
81
|
+
Do not autonomously start a new task or delegate further work.` : ''}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Append the delivery instructions to a worker task prompt. Idempotent: a task
|
|
86
|
+
* that already carries the delivery report (e.g. a review prompt, or a
|
|
87
|
+
* re-dispatch) is returned untouched so instructions are never doubled.
|
|
88
|
+
*/
|
|
89
|
+
export function appendDeliveryInstructions(task, { tier, isReview } = {}) {
|
|
90
|
+
if (typeof task !== 'string') return task;
|
|
91
|
+
if (task.includes(DELIVERY_MARKER)) return task;
|
|
92
|
+
return `${task}\n\n${buildDeliveryInstructions({ tier, isReview })}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseTestsSection(value) {
|
|
96
|
+
if (typeof value !== 'string' || value.trim() === '') return { valid: false, status: undefined };
|
|
97
|
+
const statuses = [];
|
|
98
|
+
for (const line of value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean)) {
|
|
99
|
+
const match = line.match(/^(?:[-*+]\s+)?(PASS|FAIL|NOT RUN)\s+—\s+\S.*?\s+—\s+\S.*$/);
|
|
100
|
+
if (!match) return { valid: false, status: undefined };
|
|
101
|
+
statuses.push(match[1]);
|
|
102
|
+
}
|
|
103
|
+
const status = statuses.includes('FAIL') ? 'FAIL' : statuses.includes('NOT RUN') ? 'NOT RUN' : statuses.includes('PASS') ? 'PASS' : undefined;
|
|
104
|
+
return { valid: status !== undefined, status };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parse a worker's final message into the delivery report. Detects the report
|
|
109
|
+
* format from the headings present: coding workers use Diff/Tests/Risks
|
|
110
|
+
* (+ optional Unverified), automatic reviews use Review Findings/Evidence/
|
|
111
|
+
* Risks/Verdict. `complete` means every *mandatory* section of the detected
|
|
112
|
+
* format is present and non-empty.
|
|
113
|
+
*
|
|
114
|
+
* Returns { format, present, complete, missing, sections }.
|
|
115
|
+
*/
|
|
116
|
+
export function parseDeliveryReport(text = '') {
|
|
117
|
+
const normalized = typeof text === 'string' ? text : String(text ?? '');
|
|
118
|
+
if (normalized.trim() === '') {
|
|
119
|
+
return { format: null, present: [], complete: false, missing: [...DELIVERY_SECTIONS], sections: {} };
|
|
120
|
+
}
|
|
121
|
+
const canonical = new Map(ALL_DELIVERY_SECTIONS.concat(REVIEW_SECTIONS).map((name) => [name.toLowerCase(), name]));
|
|
122
|
+
const heading = /^##\s+(Diff|Tests|Risks|Unverified|Review Findings|Evidence|Verdict)\s*$/gim;
|
|
123
|
+
const marks = [];
|
|
124
|
+
let m;
|
|
125
|
+
while ((m = heading.exec(normalized)) !== null) marks.push({ index: m.index, name: canonical.get(m[1].toLowerCase()), len: m[0].length });
|
|
126
|
+
if (marks.length === 0) {
|
|
127
|
+
return { format: null, present: [], complete: false, missing: [...DELIVERY_SECTIONS], sections: {} };
|
|
128
|
+
}
|
|
129
|
+
const sections = {};
|
|
130
|
+
for (let i = 0; i < marks.length; i++) {
|
|
131
|
+
const start = marks[i].index + marks[i].len;
|
|
132
|
+
const end = i + 1 < marks.length ? marks[i + 1].index : normalized.length;
|
|
133
|
+
const body = normalized.slice(start, end).trim();
|
|
134
|
+
const name = marks[i].name;
|
|
135
|
+
sections[name] = sections[name] === undefined ? body : `${sections[name]}\n\n${body}`;
|
|
136
|
+
}
|
|
137
|
+
const isReview = REVIEW_SECTIONS.some((s) => s !== 'Risks' && sections[s] !== undefined);
|
|
138
|
+
const mandatory = isReview ? REVIEW_SECTIONS : DELIVERY_SECTIONS;
|
|
139
|
+
const parsedTests = isReview ? null : parseTestsSection(sections.Tests);
|
|
140
|
+
const missing = mandatory.filter((s) => {
|
|
141
|
+
const v = sections[s];
|
|
142
|
+
if (v === undefined || v === '') return true;
|
|
143
|
+
return !isReview && s === 'Tests' && !parsedTests.valid;
|
|
144
|
+
});
|
|
145
|
+
const testsStatus = parsedTests?.status;
|
|
146
|
+
return {
|
|
147
|
+
format: isReview ? 'review' : 'coding',
|
|
148
|
+
present: [...new Set(marks.map((x) => x.name))],
|
|
149
|
+
complete: missing.length === 0,
|
|
150
|
+
missing,
|
|
151
|
+
sections,
|
|
152
|
+
...(testsStatus ? { tests_status: testsStatus } : {}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Re-check a parsed report (or raw text) against the contract. Pure and
|
|
158
|
+
* forgiving: returns { ok, complete, missing, present } and never throws.
|
|
159
|
+
*/
|
|
160
|
+
export function validateDeliveryReport(parsedOrText) {
|
|
161
|
+
const parsed = typeof parsedOrText === 'string'
|
|
162
|
+
? parseDeliveryReport(parsedOrText)
|
|
163
|
+
: (parsedOrText ?? parseDeliveryReport(''));
|
|
164
|
+
const sections = parsed.sections ?? {};
|
|
165
|
+
const isReview = parsed.format === 'review';
|
|
166
|
+
const mandatory = isReview ? REVIEW_SECTIONS : DELIVERY_SECTIONS;
|
|
167
|
+
const parsedTests = isReview ? null : parseTestsSection(sections.Tests);
|
|
168
|
+
const missing = mandatory.filter((s) => {
|
|
169
|
+
const v = sections[s];
|
|
170
|
+
if (v === undefined || v === '') return true;
|
|
171
|
+
return !isReview && s === 'Tests' && !parsedTests.valid;
|
|
172
|
+
});
|
|
173
|
+
return { ok: missing.length === 0, complete: missing.length === 0, missing: [...missing], present: [...(parsed.present ?? [])] };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function clip(text, limit) {
|
|
177
|
+
if (typeof text !== 'string') return undefined;
|
|
178
|
+
const t = text.trim();
|
|
179
|
+
if (t === '') return undefined;
|
|
180
|
+
return t.length > limit ? `${t.slice(0, limit)}…` : t;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Compact metadata form of a parsed report for MCP responses / job views:
|
|
185
|
+
* complete + missing plus a bounded snippet per present section. Keeps the
|
|
186
|
+
* worker's full message out of status payloads.
|
|
187
|
+
*/
|
|
188
|
+
export function formatDeliveryMetadata(parsed, { limit = 400 } = {}) {
|
|
189
|
+
const sections = parsed?.sections ?? {};
|
|
190
|
+
const out = {
|
|
191
|
+
complete: parsed?.complete === true,
|
|
192
|
+
missing: Array.isArray(parsed?.missing) ? [...parsed.missing] : [],
|
|
193
|
+
};
|
|
194
|
+
for (const s of ALL_DELIVERY_SECTIONS) {
|
|
195
|
+
const clipped = clip(sections[s], limit);
|
|
196
|
+
if (clipped !== undefined) out[METADATA_KEYS[s]] = clipped;
|
|
197
|
+
}
|
|
198
|
+
for (const s of REVIEW_SECTIONS) {
|
|
199
|
+
const clipped = clip(sections[s], limit);
|
|
200
|
+
if (clipped !== undefined) out[METADATA_KEYS[s]] = clipped;
|
|
201
|
+
}
|
|
202
|
+
if (parsed?.tests_status) out.tests_status = parsed.tests_status;
|
|
203
|
+
if (out.missing.length === 0) delete out.missing;
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// DSH CLI/runtime resolution for Crew-owned installs.
|
|
2
|
+
//
|
|
3
|
+
// The resolver is deliberately independent from the official ~/.dsh state.
|
|
4
|
+
// A reusable CLI may live under the Crew DSH_HOME, while global dsh/npx remain
|
|
5
|
+
// compatibility fallbacks. Status and uninstall callers can resolve without
|
|
6
|
+
// allowing a download.
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { join, extname } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { crewDshHome, crewProfileDir } from './install/install.mjs';
|
|
13
|
+
|
|
14
|
+
export const DSH_CLI_PACKAGE = '@deepseek-ai/dsh';
|
|
15
|
+
export const CREW_DSH_RUNTIME_DIRNAME = 'runtime';
|
|
16
|
+
|
|
17
|
+
function defaultFindCommand(name) {
|
|
18
|
+
const probe = process.platform === 'win32' ? 'where.exe' : 'which';
|
|
19
|
+
const result = spawnSync(probe, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
20
|
+
if (result.status !== 0) return null;
|
|
21
|
+
return String(result.stdout ?? '').split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function defaultExists(path) {
|
|
25
|
+
try { return existsSync(path); } catch { return false; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function packageVersion(entry, read = readFileSync) {
|
|
29
|
+
try {
|
|
30
|
+
const packageFile = join(entry, '..', '..', 'package.json');
|
|
31
|
+
const parsed = JSON.parse(read(packageFile, 'utf8'));
|
|
32
|
+
return typeof parsed.version === 'string' ? parsed.version : null;
|
|
33
|
+
} catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function crewDshRuntimeRoot({ home = homedir() } = {}) {
|
|
37
|
+
return join(crewDshHome({ home }), CREW_DSH_RUNTIME_DIRNAME);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function crewDshRuntimeEntry({ home = homedir(), platform = process.platform } = {}) {
|
|
41
|
+
const suffix = platform === 'win32' ? '.cmd' : '';
|
|
42
|
+
return join(crewDshRuntimeRoot({ home }), 'node_modules', '.bin', `dsh${suffix}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function crewDshRuntimeModule({ home = homedir() } = {}) {
|
|
46
|
+
return join(crewDshRuntimeRoot({ home }), 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isPathLike(value) {
|
|
50
|
+
return typeof value === 'string' && (value.includes('/') || value.includes('\\') || extname(value) === '.cmd' || extname(value) === '.exe');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function descriptor({ kind, command, args = [], source, version = null, reusable = false }) {
|
|
54
|
+
return Object.freeze({ kind, command, args: [...args], source, version, reusable });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function explicitDescriptor(value, { exists = defaultExists, platform = process.platform } = {}) {
|
|
58
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
59
|
+
const trimmed = value.trim();
|
|
60
|
+
if (isPathLike(trimmed) && !exists(trimmed)) return null;
|
|
61
|
+
if (extname(trimmed).toLowerCase() === '.js') {
|
|
62
|
+
return descriptor({ kind: 'explicit-node', command: process.execPath, args: [trimmed], source: 'explicit', reusable: true });
|
|
63
|
+
}
|
|
64
|
+
return descriptor({ kind: platform === 'win32' && extname(trimmed).toLowerCase() === '.cmd' ? 'explicit-cmd' : 'explicit', command: trimmed, source: 'explicit', reusable: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a CLI without reading DSH credentials or profile state.
|
|
69
|
+
* `allowDownload` only affects the final npx descriptor; resolution itself
|
|
70
|
+
* never starts a network operation.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveDshCli({
|
|
73
|
+
home = homedir(),
|
|
74
|
+
env = process.env,
|
|
75
|
+
platform = process.platform,
|
|
76
|
+
exists = defaultExists,
|
|
77
|
+
findCommand = defaultFindCommand,
|
|
78
|
+
allowDownload = false,
|
|
79
|
+
includeCompatibility = true,
|
|
80
|
+
read = readFileSync,
|
|
81
|
+
} = {}) {
|
|
82
|
+
const explicit = explicitDescriptor(env.DSH_CREW_DSH_CLI ?? env.DSH_CLI, { exists, platform });
|
|
83
|
+
if (explicit) return explicit;
|
|
84
|
+
|
|
85
|
+
const moduleEntry = crewDshRuntimeModule({ home });
|
|
86
|
+
if (exists(moduleEntry)) {
|
|
87
|
+
return descriptor({
|
|
88
|
+
kind: 'crew-runtime',
|
|
89
|
+
command: process.execPath,
|
|
90
|
+
args: [moduleEntry],
|
|
91
|
+
source: 'crew-runtime',
|
|
92
|
+
version: packageVersion(moduleEntry, read),
|
|
93
|
+
reusable: true,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!includeCompatibility) return null;
|
|
98
|
+
|
|
99
|
+
const global = findCommand('dsh');
|
|
100
|
+
if (global) return descriptor({ kind: 'global', command: global, source: 'global' });
|
|
101
|
+
|
|
102
|
+
const npx = findCommand('npx');
|
|
103
|
+
if (!npx) return null;
|
|
104
|
+
return descriptor({
|
|
105
|
+
kind: allowDownload ? 'npx-download' : 'npx-local',
|
|
106
|
+
command: npx,
|
|
107
|
+
args: allowDownload ? ['--yes', DSH_CLI_PACKAGE] : ['--no-install', DSH_CLI_PACKAGE],
|
|
108
|
+
source: allowDownload ? 'npx-download' : 'npx-local',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function quoteWindowsArg(value) {
|
|
113
|
+
const input = String(value);
|
|
114
|
+
if (input.length > 0 && !/[\s"]/u.test(input)) return input;
|
|
115
|
+
let output = '"';
|
|
116
|
+
let slashes = 0;
|
|
117
|
+
for (const char of input) {
|
|
118
|
+
if (char === '\\') { slashes += 1; continue; }
|
|
119
|
+
if (char === '"') {
|
|
120
|
+
output += '\\'.repeat(slashes * 2 + 1) + '"';
|
|
121
|
+
} else {
|
|
122
|
+
output += '\\'.repeat(slashes) + char;
|
|
123
|
+
}
|
|
124
|
+
slashes = 0;
|
|
125
|
+
}
|
|
126
|
+
output += '\\'.repeat(slashes * 2) + '"';
|
|
127
|
+
return output;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export { quoteWindowsArg };
|
|
131
|
+
|
|
132
|
+
/** Build a shell-free invocation, including deterministic Windows .cmd handling. */
|
|
133
|
+
export function buildDshInvocation(cli, args = [], { platform = process.platform, comspec = process.env.ComSpec ?? 'cmd.exe' } = {}) {
|
|
134
|
+
const allArgs = [...(cli?.args ?? []), ...args].map(String);
|
|
135
|
+
const command = String(cli?.command ?? '');
|
|
136
|
+
if (platform === 'win32' && /\.(?:cmd|bat)$/iu.test(command)) {
|
|
137
|
+
return {
|
|
138
|
+
command: comspec,
|
|
139
|
+
args: ['/d', '/s', '/c', [quoteWindowsArg(command), ...allArgs.map(quoteWindowsArg)].join(' ')],
|
|
140
|
+
shell: false,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return { command, args: allArgs, shell: false };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function runResolvedDsh(cli, args = [], {
|
|
147
|
+
home = homedir(),
|
|
148
|
+
env = process.env,
|
|
149
|
+
runner = spawnSync,
|
|
150
|
+
platform = process.platform,
|
|
151
|
+
comspec = process.env.ComSpec ?? 'cmd.exe',
|
|
152
|
+
} = {}) {
|
|
153
|
+
if (!cli) return { ok: false, status: -1, stdout: '', stderr: 'DSH CLI unavailable' };
|
|
154
|
+
const invocation = buildDshInvocation(cli, args, { platform, comspec });
|
|
155
|
+
const result = runner(invocation.command, invocation.args, {
|
|
156
|
+
encoding: 'utf8',
|
|
157
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
158
|
+
shell: invocation.shell,
|
|
159
|
+
env: { ...env, DSH_HOME: crewDshHome({ home }) },
|
|
160
|
+
});
|
|
161
|
+
return {
|
|
162
|
+
ok: result.status === 0,
|
|
163
|
+
status: result.status ?? -1,
|
|
164
|
+
stdout: result.stdout ?? '',
|
|
165
|
+
stderr: result.stderr ?? '',
|
|
166
|
+
invocation,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Install a reusable DSH CLI into Crew-owned state. This is the only helper
|
|
172
|
+
* that may invoke a package manager, and callers must explicitly opt into it.
|
|
173
|
+
*/
|
|
174
|
+
export function ensureCrewDshRuntime({
|
|
175
|
+
home = homedir(),
|
|
176
|
+
packageSpec = DSH_CLI_PACKAGE,
|
|
177
|
+
npmCommand = null,
|
|
178
|
+
pnpmCommand = null,
|
|
179
|
+
findCommand = defaultFindCommand,
|
|
180
|
+
exists = defaultExists,
|
|
181
|
+
runner = spawnSync,
|
|
182
|
+
platform = process.platform,
|
|
183
|
+
comspec = process.env.ComSpec ?? 'cmd.exe',
|
|
184
|
+
env = process.env,
|
|
185
|
+
} = {}) {
|
|
186
|
+
const existing = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false });
|
|
187
|
+
if (existing?.kind === 'crew-runtime') return { ok: true, cli: existing, reused: true };
|
|
188
|
+
|
|
189
|
+
const pnpm = pnpmCommand ?? findCommand('pnpm');
|
|
190
|
+
const npm = npmCommand ?? findCommand('npm');
|
|
191
|
+
if (!pnpm && !npm) return { ok: false, code: 'DSH_RUNTIME_INSTALLER_NOT_FOUND', error: 'pnpm/npm unavailable' };
|
|
192
|
+
const runtimeRoot = crewDshRuntimeRoot({ home });
|
|
193
|
+
mkdirSync(runtimeRoot, { recursive: true });
|
|
194
|
+
const packageManager = pnpm
|
|
195
|
+
? descriptor({ kind: 'pnpm', command: pnpm, source: 'pnpm' })
|
|
196
|
+
: descriptor({ kind: 'npm', command: npm, source: 'npm' });
|
|
197
|
+
const packageArgs = pnpm
|
|
198
|
+
? ['add', '--dir', runtimeRoot, '--ignore-scripts', packageSpec]
|
|
199
|
+
: ['install', '--prefix', runtimeRoot, '--no-package-lock', '--ignore-scripts', '--omit=dev', packageSpec];
|
|
200
|
+
const invocation = buildDshInvocation(packageManager, packageArgs, { platform, comspec });
|
|
201
|
+
const result = runner(invocation.command, invocation.args, {
|
|
202
|
+
encoding: 'utf8',
|
|
203
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
204
|
+
shell: invocation.shell,
|
|
205
|
+
env: { ...env },
|
|
206
|
+
});
|
|
207
|
+
const cli = resolveDshCli({ home, env, platform, exists, findCommand, includeCompatibility: false });
|
|
208
|
+
if (result.status !== 0 || !cli || cli.kind !== 'crew-runtime') {
|
|
209
|
+
return { ok: false, code: 'DSH_RUNTIME_INSTALL_FAILED', error: 'Crew-owned DSH runtime install failed', status: result.status ?? -1 };
|
|
210
|
+
}
|
|
211
|
+
return { ok: true, cli, reused: false, version: cli.version, runtimeRoot };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function describeDshCli(cli) {
|
|
215
|
+
if (!cli) return 'unavailable';
|
|
216
|
+
const version = cli.version ? `@${cli.version}` : '';
|
|
217
|
+
return `${cli.kind}${version}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Remove one Crew plugin registration without invoking a package manager.
|
|
222
|
+
* This is intentionally limited to the derived Crew profile directory so a
|
|
223
|
+
* status/uninstall probe cannot touch the official web profile or download
|
|
224
|
+
* anything merely to remove a stale registration.
|
|
225
|
+
*/
|
|
226
|
+
export function removeCrewPluginRegistration({ home = homedir(), name, profileRoot = crewProfileDir({ home }) } = {}) {
|
|
227
|
+
if (typeof name !== 'string' || !name || name.split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
228
|
+
return { ok: false, code: 'INVALID_CREW_PLUGIN_NAME' };
|
|
229
|
+
}
|
|
230
|
+
const packageFile = join(profileRoot, 'package.json');
|
|
231
|
+
if (!existsSync(packageFile)) return { ok: true, removed: false };
|
|
232
|
+
let pkg;
|
|
233
|
+
try { pkg = JSON.parse(readFileSync(packageFile, 'utf8')); } catch { return { ok: false, code: 'CREW_PROFILE_METADATA_INVALID' }; }
|
|
234
|
+
const hadDependency = Boolean(pkg.dependencies?.[name]);
|
|
235
|
+
const bundles = Array.isArray(pkg.dsh?.profile?.bundles) ? pkg.dsh.profile.bundles : [];
|
|
236
|
+
const hadBundle = bundles.includes(name);
|
|
237
|
+
if (!hadDependency && !hadBundle) return { ok: true, removed: false };
|
|
238
|
+
const next = { ...pkg };
|
|
239
|
+
if (hadDependency) {
|
|
240
|
+
next.dependencies = { ...pkg.dependencies };
|
|
241
|
+
delete next.dependencies[name];
|
|
242
|
+
if (Object.keys(next.dependencies).length === 0) delete next.dependencies;
|
|
243
|
+
}
|
|
244
|
+
if (hadBundle) {
|
|
245
|
+
next.dsh = { ...pkg.dsh, profile: { ...pkg.dsh.profile, bundles: bundles.filter((item) => item !== name) } };
|
|
246
|
+
}
|
|
247
|
+
writeFileSync(packageFile, JSON.stringify(next, null, 2) + '\n');
|
|
248
|
+
const packageParts = name.split('/');
|
|
249
|
+
rmSync(join(profileRoot, 'node_modules', ...packageParts), { recursive: true, force: true });
|
|
250
|
+
return { ok: true, removed: true };
|
|
251
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Pure, secret-free failure classification for workflow and MCP results.
|
|
2
|
+
//
|
|
3
|
+
// Classification consumes structured status/codes/outcomes only. It never
|
|
4
|
+
// parses raw exception text, provider responses, credentials, quotas or logs.
|
|
5
|
+
|
|
6
|
+
export const FAILURE_CATEGORIES = Object.freeze([
|
|
7
|
+
'none',
|
|
8
|
+
'policy',
|
|
9
|
+
'compatibility',
|
|
10
|
+
'provider',
|
|
11
|
+
'runtime',
|
|
12
|
+
'verification',
|
|
13
|
+
'cancelled',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export const FAILURE_REASON_CODES = Object.freeze({
|
|
17
|
+
NONE: 'NONE',
|
|
18
|
+
CANCELLED: 'CANCELLED',
|
|
19
|
+
ATTEMPT_TIMEOUT: 'ATTEMPT_TIMEOUT',
|
|
20
|
+
RUNTIME_FAILURE: 'RUNTIME_FAILURE',
|
|
21
|
+
EXECUTION_FAILED: 'EXECUTION_FAILED',
|
|
22
|
+
TESTS_FAILED: 'TESTS_FAILED',
|
|
23
|
+
TESTS_NOT_RUN: 'TESTS_NOT_RUN',
|
|
24
|
+
DELIVERY_INCOMPLETE: 'DELIVERY_INCOMPLETE',
|
|
25
|
+
WORKSPACE_MISMATCH: 'WORKSPACE_MISMATCH',
|
|
26
|
+
TASK_BLOCKED: 'TASK_BLOCKED',
|
|
27
|
+
TASK_PARTIAL: 'TASK_PARTIAL',
|
|
28
|
+
REVIEW_CHANGES_REQUESTED: 'REVIEW_CHANGES_REQUESTED',
|
|
29
|
+
REVIEW_INCONCLUSIVE: 'REVIEW_INCONCLUSIVE',
|
|
30
|
+
POLICY_REJECTED: 'POLICY_REJECTED',
|
|
31
|
+
HUB_INCOMPATIBLE: 'HUB_INCOMPATIBLE',
|
|
32
|
+
PROVIDER_UNAVAILABLE: 'PROVIDER_UNAVAILABLE',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const PROVIDER_CODES = new Set([
|
|
36
|
+
'NO_DSH_PROVIDER_SELECTED',
|
|
37
|
+
'NO_WORKER_MODEL_AVAILABLE',
|
|
38
|
+
'MODEL_CATALOG_UNAVAILABLE',
|
|
39
|
+
'PROVIDER_CATALOG_UNAVAILABLE',
|
|
40
|
+
'PROVIDER_CATALOG_HEALTH_WARNING',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const POLICY_CODES = new Set([
|
|
44
|
+
'SUBAGENTS_DISABLED',
|
|
45
|
+
'TIER_DISABLED',
|
|
46
|
+
'NO_AUTO_TIER',
|
|
47
|
+
'NO_WORKER_TIER',
|
|
48
|
+
'PRO_NOT_AUTO',
|
|
49
|
+
'VISION_DISABLED',
|
|
50
|
+
'ROLE_DISABLED',
|
|
51
|
+
'ROLE_NOT_AUTO',
|
|
52
|
+
'ROLE_TIER_CONFLICT',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const COMPATIBILITY_CODES = new Set([
|
|
56
|
+
'HUB_UNREACHABLE',
|
|
57
|
+
'HUB_HTTP_ERROR',
|
|
58
|
+
'HUB_SERVICE_MISMATCH',
|
|
59
|
+
'HUB_PROTOCOL_MISSING',
|
|
60
|
+
'HUB_PROTOCOL_MISMATCH',
|
|
61
|
+
'HUB_CAPABILITY_MISSING',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const RUNTIME_CODES = new Set([
|
|
65
|
+
'ISOLATION_UNAVAILABLE',
|
|
66
|
+
'NOT_GIT_REPOSITORY',
|
|
67
|
+
'GIT_NOT_FOUND',
|
|
68
|
+
'GIT_TIMEOUT',
|
|
69
|
+
'GIT_ERROR',
|
|
70
|
+
'WORKTREE_LOCKED',
|
|
71
|
+
'WORKTREE_CREATE_FAILED',
|
|
72
|
+
'CANDIDATE_CAPTURE_FAILED',
|
|
73
|
+
'ATTEMPT_INFRA_FAILURE',
|
|
74
|
+
'HUB_REQUEST_FAILED',
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
function normalizedCode(value) {
|
|
78
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function result(category, reasonCode, { sourceCode = null, terminalReason = null } = {}) {
|
|
82
|
+
return {
|
|
83
|
+
schema_version: 1,
|
|
84
|
+
category,
|
|
85
|
+
reason_code: reasonCode,
|
|
86
|
+
...(sourceCode ? { source_code: sourceCode } : {}),
|
|
87
|
+
...(terminalReason ? { terminal_reason: terminalReason } : {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function verificationRoot({ outcome, review } = {}) {
|
|
92
|
+
if (review?.verdict === 'request_changes') return FAILURE_REASON_CODES.REVIEW_CHANGES_REQUESTED;
|
|
93
|
+
if (review?.verdict === 'inconclusive' && review?.status === 'failed') return FAILURE_REASON_CODES.REVIEW_INCONCLUSIVE;
|
|
94
|
+
if (outcome?.tests_status === 'FAIL') return FAILURE_REASON_CODES.TESTS_FAILED;
|
|
95
|
+
if (outcome?.delivery?.complete === false) return FAILURE_REASON_CODES.DELIVERY_INCOMPLETE;
|
|
96
|
+
if (outcome?.workspace_evidence_ok === false) return FAILURE_REASON_CODES.WORKSPACE_MISMATCH;
|
|
97
|
+
if (outcome?.task_status === 'blocked') return FAILURE_REASON_CODES.TASK_BLOCKED;
|
|
98
|
+
if (outcome?.tests_status === 'NOT RUN') return FAILURE_REASON_CODES.TESTS_NOT_RUN;
|
|
99
|
+
if (outcome?.task_status === 'partial') return FAILURE_REASON_CODES.TASK_PARTIAL;
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Classify one workflow/result snapshot.
|
|
105
|
+
*
|
|
106
|
+
* Precedence is intentional:
|
|
107
|
+
* 1) cancellation is explicit user/runtime intent;
|
|
108
|
+
* 2) stable compatibility/provider/policy codes identify the failing boundary;
|
|
109
|
+
* 3) timeout/runtime execution failures outrank verification;
|
|
110
|
+
* 4) verification uses the business evidence that caused escalation/failure;
|
|
111
|
+
* 5) otherwise the result is `none`.
|
|
112
|
+
*
|
|
113
|
+
* `decision.reason` is retained only as a bounded terminal reason (for example
|
|
114
|
+
* max_attempts_reached); it never replaces the underlying verification cause.
|
|
115
|
+
*/
|
|
116
|
+
export function classifyFailure({
|
|
117
|
+
phase = null,
|
|
118
|
+
status = null,
|
|
119
|
+
errorCode = null,
|
|
120
|
+
outcome = null,
|
|
121
|
+
decision = null,
|
|
122
|
+
review = null,
|
|
123
|
+
childAttempts = [],
|
|
124
|
+
} = {}) {
|
|
125
|
+
const code = normalizedCode(errorCode);
|
|
126
|
+
const terminalReason = normalizedCode(decision?.reason);
|
|
127
|
+
|
|
128
|
+
if (phase === 'cancelled' || status === 'cancelled' || code === 'WORKFLOW_CANCELLED') {
|
|
129
|
+
return result('cancelled', FAILURE_REASON_CODES.CANCELLED, {
|
|
130
|
+
sourceCode: code === 'WORKFLOW_CANCELLED' ? code : null,
|
|
131
|
+
terminalReason,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (code && COMPATIBILITY_CODES.has(code)) {
|
|
136
|
+
return result('compatibility', code, { sourceCode: code, terminalReason });
|
|
137
|
+
}
|
|
138
|
+
if (code && PROVIDER_CODES.has(code)) {
|
|
139
|
+
return result('provider', FAILURE_REASON_CODES.PROVIDER_UNAVAILABLE, { sourceCode: code, terminalReason });
|
|
140
|
+
}
|
|
141
|
+
if (code && POLICY_CODES.has(code)) {
|
|
142
|
+
return result('policy', FAILURE_REASON_CODES.POLICY_REJECTED, { sourceCode: code, terminalReason });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const attempts = Array.isArray(childAttempts) ? childAttempts : [];
|
|
146
|
+
if (attempts.some((attempt) => attempt?.timed_out === true || attempt?.stopReason === 'timeout')) {
|
|
147
|
+
return result('runtime', FAILURE_REASON_CODES.ATTEMPT_TIMEOUT, { terminalReason });
|
|
148
|
+
}
|
|
149
|
+
if (code && RUNTIME_CODES.has(code)) {
|
|
150
|
+
return result('runtime', code, { sourceCode: code, terminalReason });
|
|
151
|
+
}
|
|
152
|
+
if (outcome?.execution_status === 'failed') {
|
|
153
|
+
return result('runtime', FAILURE_REASON_CODES.EXECUTION_FAILED, { terminalReason });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const verification = verificationRoot({ outcome, review });
|
|
157
|
+
if (verification) return result('verification', verification, { terminalReason });
|
|
158
|
+
|
|
159
|
+
if (phase === 'failed' || status === 'failed') {
|
|
160
|
+
return result('runtime', FAILURE_REASON_CODES.RUNTIME_FAILURE, {
|
|
161
|
+
sourceCode: code,
|
|
162
|
+
terminalReason,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return result('none', FAILURE_REASON_CODES.NONE);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Classify an immediate MCP rejection before a workflow exists. */
|
|
170
|
+
export function classifyFailureCode(code) {
|
|
171
|
+
return classifyFailure({ status: 'failed', errorCode: code });
|
|
172
|
+
}
|