@ran-sh/dsh-crew 1.10.5 → 1.10.6
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/package.json +1 -1
- package/src/dsh-cli-runtime.mjs +2 -1
- package/src/hub/index.mjs +13 -0
- package/src/install/crew-paths.mjs +52 -0
- package/src/install/install-legacy.mjs +42 -8
- package/src/install/install.mjs +1 -8
- package/src/install/npx-lifecycle.mjs +6 -5
- package/src/install/tree-move.mjs +37 -0
- package/src/runtime-identity.mjs +1 -1
- package/src/workflow-runtime.mjs +7 -1
- package/src/workflow.mjs +41 -18
package/package.json
CHANGED
package/src/dsh-cli-runtime.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { spawnSync } from 'node:child_process';
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
10
|
+
import { renameTree } from './install/tree-move.mjs';
|
|
10
11
|
import {
|
|
11
12
|
existsSync,
|
|
12
13
|
lstatSync,
|
|
@@ -460,7 +461,7 @@ export async function migrateCrewDshRuntime({
|
|
|
460
461
|
let liveMoved = false;
|
|
461
462
|
try {
|
|
462
463
|
if (existsSync(liveRoot)) {
|
|
463
|
-
|
|
464
|
+
renameTree(liveRoot, prevRoot, { rename });
|
|
464
465
|
liveMoved = true;
|
|
465
466
|
}
|
|
466
467
|
mkdirSync(liveRoot, { recursive: true });
|
package/src/hub/index.mjs
CHANGED
|
@@ -1330,6 +1330,19 @@ export function resolveHubSpawnPayload(payload, getConfig = () => ({}), dependen
|
|
|
1330
1330
|
}
|
|
1331
1331
|
// v0.2 role-based dispatch: reviewer / worker are gated by their role state,
|
|
1332
1332
|
// and the tier slot is derived from the role (reviewer always → pro).
|
|
1333
|
+
// A plain payload names no workspace or constraints, but it does name a role,
|
|
1334
|
+
// and the role's profile states how that role runs. Only the advanced shape
|
|
1335
|
+
// resolved it, so the same task submitted through the simple shape ran shared
|
|
1336
|
+
// while the profile said worktree: the profile was silently ignored for the
|
|
1337
|
+
// caller who wrote the least. A payload that names no role keeps its
|
|
1338
|
+
// pass-through shape.
|
|
1339
|
+
if (normalized.requested_isolation === undefined && (normalized.role === 'worker' || normalized.role === 'reviewer')) {
|
|
1340
|
+
const registry = dependencies.profileRegistry ?? loadRoleProfiles();
|
|
1341
|
+
const roleProfile = registry.ok ? resolveRoleProfile(registry, normalized.profile, normalized.role) : { ok: false };
|
|
1342
|
+
if (roleProfile.ok && typeof roleProfile.profile?.isolation === 'string') {
|
|
1343
|
+
normalized = { ...normalized, requested_isolation: roleProfile.profile.isolation };
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1333
1346
|
if (normalized.role === 'worker' || normalized.role === 'reviewer') {
|
|
1334
1347
|
const hint = resolveRoleTierHint(normalized.role, normalized.tier);
|
|
1335
1348
|
if (!hint.ok) return { ok: false, code: hint.code, error: hint.error };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Where the Crew-owned Harness profile lives, and where the registration link
|
|
2
|
+
// that the host integrations point at is rooted.
|
|
3
|
+
//
|
|
4
|
+
// This is its own module because two layers must agree about it and neither may
|
|
5
|
+
// import the other: the installers write host integrations against the loader
|
|
6
|
+
// link, and the readiness checks decide whether those integrations are correct.
|
|
7
|
+
// When readiness derived its expected paths from the release directory instead,
|
|
8
|
+
// every correctly installed machine read as needing repair — the installer and
|
|
9
|
+
// the check disagreed about what "the installed server" is, and only the
|
|
10
|
+
// installer was right.
|
|
11
|
+
|
|
12
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
|
|
16
|
+
export const CREW_PROFILE_NAME = 'dsh-crew';
|
|
17
|
+
export const CREW_HOME_REL = join('.config', 'dsh-crew', 'harness');
|
|
18
|
+
|
|
19
|
+
export function crewDshHome({ home = homedir() } = {}) {
|
|
20
|
+
return join(home, CREW_HOME_REL);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function crewProfileDir({ home = homedir() } = {}) {
|
|
24
|
+
return join(crewDshHome({ home }), 'profiles', CREW_PROFILE_NAME);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The profile's `node_modules/<name>` entry for the installed package. The
|
|
28
|
+
// registration points this at the live release, so it is stable across
|
|
29
|
+
// upgrades while the release path underneath it is not.
|
|
30
|
+
export function loaderLinkPath({ home = homedir(), name } = {}) {
|
|
31
|
+
if (typeof name !== 'string' || name.trim() === '') return null;
|
|
32
|
+
return join(crewProfileDir({ home }), 'node_modules', ...name.split('/'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sameDirectory(left, right) {
|
|
36
|
+
try { return realpathSync(left) === realpathSync(right); } catch { return false; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The root the host integrations were installed from.
|
|
41
|
+
*
|
|
42
|
+
* Readiness must judge the integrations against the path they were actually
|
|
43
|
+
* written with. That path is the loader link when it resolves to this release,
|
|
44
|
+
* and the release directory itself otherwise — a machine whose link is missing
|
|
45
|
+
* or points somewhere else is reported against the release, which is exactly
|
|
46
|
+
* the mismatch the operator needs to see.
|
|
47
|
+
*/
|
|
48
|
+
export function integrationRoot({ home = homedir(), root, name } = {}) {
|
|
49
|
+
const link = loaderLinkPath({ home, name });
|
|
50
|
+
if (!link || !root || !existsSync(link)) return root;
|
|
51
|
+
return sameDirectory(link, root) ? link : root;
|
|
52
|
+
}
|
|
@@ -10,6 +10,7 @@ import { homedir } from 'node:os';
|
|
|
10
10
|
import { normalizeModelPriority } from '../model-routing.mjs';
|
|
11
11
|
import { crewSkillFiles, installCrewSkill, readCrewSkill, removeCrewSkill } from './crew-skill.mjs';
|
|
12
12
|
import { zcodeStatus } from './zcode.mjs';
|
|
13
|
+
import { integrationRoot } from './crew-paths.mjs';
|
|
13
14
|
|
|
14
15
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
15
16
|
const MARKETPLACE_NAME = 'dsh-crew';
|
|
@@ -372,6 +373,13 @@ export function writeGlobalConfig(patch) {
|
|
|
372
373
|
|
|
373
374
|
/** What is currently installed where — drives the settings-page buttons. */
|
|
374
375
|
export function installStatus({ home = homedir(), root = ROOT, env = process.env } = {}) {
|
|
376
|
+
// Host integrations are installed from the profile's loader link, not from the
|
|
377
|
+
// release directory, so an upgrade re-points one link instead of rewriting four
|
|
378
|
+
// host configurations. Readiness has to judge them against the path they were
|
|
379
|
+
// actually written with: deriving it from the release directory made every
|
|
380
|
+
// correctly installed machine read as needing repair.
|
|
381
|
+
const manifestName = readJson(join(root, 'package.json'), {})?.name ?? null;
|
|
382
|
+
const effectiveRoot = integrationRoot({ home, root, name: manifestName });
|
|
375
383
|
const settings = readJson(join(home, '.claude', 'settings.json'), {});
|
|
376
384
|
const enabled = settings.enabledPlugins;
|
|
377
385
|
const claudeInstalled = !!(enabled && !Array.isArray(enabled) && enabled[PLUGIN_KEY]);
|
|
@@ -382,8 +390,8 @@ export function installStatus({ home = homedir(), root = ROOT, env = process.env
|
|
|
382
390
|
const claudeFootprint = claudeInstalled || typeof marketplaceRoot === 'string' || installedPluginRecord !== undefined;
|
|
383
391
|
const claudeComponents = {
|
|
384
392
|
enabled: claudeInstalled,
|
|
385
|
-
marketplace: normalizedPath(marketplaceRoot) === normalizedPath(
|
|
386
|
-
snapshot: claudeSnapshotReady(home,
|
|
393
|
+
marketplace: normalizedPath(marketplaceRoot) === normalizedPath(effectiveRoot) && claudePluginRootReady(effectiveRoot),
|
|
394
|
+
snapshot: claudeSnapshotReady(home, effectiveRoot),
|
|
387
395
|
permissions: claudePermissionsReady(settings),
|
|
388
396
|
};
|
|
389
397
|
const claudeMissing = Object.entries(claudeComponents).filter(([, present]) => !present).map(([key]) => key);
|
|
@@ -395,11 +403,11 @@ export function installStatus({ home = homedir(), root = ROOT, env = process.env
|
|
|
395
403
|
const workerTarget = codexRoleTarget(workerFile, 'ds-worker');
|
|
396
404
|
const reviewerTarget = codexRoleTarget(reviewerFile, 'ds-reviewer');
|
|
397
405
|
const mcpTarget = codexMcpTarget(configText);
|
|
398
|
-
const expectedTarget = normalizedPath(join(
|
|
399
|
-
const expectedWorkerRole = renderedCodexRole(
|
|
400
|
-
const expectedReviewerRole = renderedCodexRole(
|
|
401
|
-
const expectedConfigPrompt = readText(join(
|
|
402
|
-
const expectedStatusPrompt = readText(join(
|
|
406
|
+
const expectedTarget = normalizedPath(join(effectiveRoot, 'src', 'server.mjs'));
|
|
407
|
+
const expectedWorkerRole = renderedCodexRole(effectiveRoot, 'ds-worker.toml');
|
|
408
|
+
const expectedReviewerRole = renderedCodexRole(effectiveRoot, 'ds-reviewer.toml');
|
|
409
|
+
const expectedConfigPrompt = readText(join(effectiveRoot, 'codex', 'prompts', 'dsh-config.md'));
|
|
410
|
+
const expectedStatusPrompt = readText(join(effectiveRoot, 'codex', 'prompts', 'dsh-status.md'));
|
|
403
411
|
const components = {
|
|
404
412
|
worker_role: expectedWorkerRole !== null && readText(workerFile) === expectedWorkerRole,
|
|
405
413
|
reviewer_role: expectedReviewerRole !== null && readText(reviewerFile) === expectedReviewerRole,
|
|
@@ -422,10 +430,34 @@ export function installStatus({ home = homedir(), root = ROOT, env = process.env
|
|
|
422
430
|
missing: claudeMissing,
|
|
423
431
|
},
|
|
424
432
|
codex: { installed: codexInstalled, ready: missing.length === 0, components, missing },
|
|
425
|
-
zcode: zcodeStatus({ home, root }),
|
|
433
|
+
zcode: zcodeStatus({ home, root: effectiveRoot }),
|
|
426
434
|
};
|
|
427
435
|
}
|
|
428
436
|
|
|
437
|
+
function removeLegacyCodexRoles({ agentsDir }) {
|
|
438
|
+
const actions = [];
|
|
439
|
+
for (const f of ['worker.toml', 'reviewer.toml']) {
|
|
440
|
+
const p = join(agentsDir, f);
|
|
441
|
+
if (!existsSync(p)) continue;
|
|
442
|
+
// Only Crew's own abandoned stub is removed. An earlier release wrote these
|
|
443
|
+
// before the roles were renamed to ds-worker/ds-reviewer, and every Codex
|
|
444
|
+
// start since has logged "Ignoring malformed agent role definition" about a
|
|
445
|
+
// file of ours that Codex cannot use: a role without `developer_instructions`
|
|
446
|
+
// is not a role, so anything that *is* a real user role cannot match here.
|
|
447
|
+
const text = readText(p);
|
|
448
|
+
if (typeof text !== 'string') continue;
|
|
449
|
+
const name = f.replace(/\.toml$/, '');
|
|
450
|
+
const stripped = text.replace(/#[^\n]*/g, '').trim();
|
|
451
|
+
if (/developer_instructions/.test(stripped)) continue;
|
|
452
|
+
if (!new RegExp(`^name\\s*=\\s*["']${name}["']$`).test(stripped)) continue;
|
|
453
|
+
const bak = backup(p);
|
|
454
|
+
if (bak) actions.push(`backup: ${bak}`);
|
|
455
|
+
rmSync(p);
|
|
456
|
+
actions.push(`removed obsolete role: ${p}`);
|
|
457
|
+
}
|
|
458
|
+
return actions;
|
|
459
|
+
}
|
|
460
|
+
|
|
429
461
|
export function uninstallCodex({ home = homedir(), env = process.env } = {}) {
|
|
430
462
|
const actions = [];
|
|
431
463
|
// Both the v0.2 roles (ds-worker / ds-reviewer) and the deprecated v0.1
|
|
@@ -435,6 +467,7 @@ export function uninstallCodex({ home = homedir(), env = process.env } = {}) {
|
|
|
435
467
|
const p = join(codexHomeDir(home, env), 'agents', f);
|
|
436
468
|
if (existsSync(p)) { backup(p); rmSync(p); actions.push(`removed: ${p} (backup kept)`); }
|
|
437
469
|
}
|
|
470
|
+
actions.push(...removeLegacyCodexRoles({ agentsDir: join(codexHomeDir(home, env), 'agents') }));
|
|
438
471
|
for (const f of ['dsh-config.md', 'dsh-status.md']) {
|
|
439
472
|
const p = join(codexHomeDir(home, env), 'prompts', f);
|
|
440
473
|
if (existsSync(p)) { rmSync(p); actions.push(`removed: ${p}`); }
|
|
@@ -596,6 +629,7 @@ export function installCodex({ home = homedir(), scope, root = ROOT, env = proce
|
|
|
596
629
|
writeFileSync(join(promptsDir, f), readFileSync(join(promptsSrc, f), 'utf8'));
|
|
597
630
|
actions.push(`prompt: ${join(promptsDir, f)}`);
|
|
598
631
|
}
|
|
632
|
+
actions.push(...removeLegacyCodexRoles({ agentsDir }));
|
|
599
633
|
if (scope !== 'project') {
|
|
600
634
|
const act = writeGlobalCodexMcpServer(home, renderedPath, env);
|
|
601
635
|
actions.push(...act);
|
package/src/install/install.mjs
CHANGED
|
@@ -32,16 +32,9 @@ const TIER_STATES = ['disabled', 'manual', 'auto'];
|
|
|
32
32
|
// paths must never default to the user's official ~/.dsh or its ``web`` profile.
|
|
33
33
|
// A fresh Crew Hub config points at the Crew-owned port only; the former
|
|
34
34
|
// shared-profile default 3080 is treated as a legacy value to migrate away from.
|
|
35
|
-
export const CREW_PROFILE_NAME = 'dsh-crew';
|
|
36
|
-
export const CREW_HOME_REL = join('.config', 'dsh-crew', 'harness');
|
|
37
35
|
export const CREW_DEFAULT_HUB_URL = 'http://127.0.0.1:3210';
|
|
38
36
|
export const CREW_LEGACY_HUB_URL = 'http://127.0.0.1:3080';
|
|
39
|
-
export
|
|
40
|
-
return join(home, CREW_HOME_REL);
|
|
41
|
-
}
|
|
42
|
-
export function crewProfileDir({ home = homedir() } = {}) {
|
|
43
|
-
return join(crewDshHome({ home }), 'profiles', CREW_PROFILE_NAME);
|
|
44
|
-
}
|
|
37
|
+
export { CREW_HOME_REL, CREW_PROFILE_NAME, crewDshHome, crewProfileDir } from './crew-paths.mjs';
|
|
45
38
|
|
|
46
39
|
// The isolated default set: identical to the legacy defaults except the Hub URL,
|
|
47
40
|
// so fresh Crew installs never point at the official web profile.
|
|
@@ -44,6 +44,7 @@ import { samePayloadContent, capturePayloadContent } from './payload-content.mjs
|
|
|
44
44
|
import { crewDshHome, crewProfileDir } from './install.mjs';
|
|
45
45
|
import { releaseClaimsState } from '../release-in-use.mjs';
|
|
46
46
|
import { compareProcessToken, processStartToken } from '../process-identity.mjs';
|
|
47
|
+
import { renameTree } from './tree-move.mjs';
|
|
47
48
|
import { checkRuntimeAdvance, normalizeRuntimeState, runtimeStateMayHaveStarted } from './runtime-lifecycle.mjs';
|
|
48
49
|
import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
|
|
49
50
|
import {
|
|
@@ -857,7 +858,7 @@ export async function reconcileUpdateJournal({ home = homedir(), log = () => {},
|
|
|
857
858
|
if (parked) {
|
|
858
859
|
try {
|
|
859
860
|
rmSync(rt.liveRoot, { recursive: true, force: true });
|
|
860
|
-
|
|
861
|
+
renameTree(parked, rt.liveRoot);
|
|
861
862
|
} catch (error) {
|
|
862
863
|
return { ok: false, code: 'JOURNAL_COORDINATED_RUNTIME_RESTORE_FAILED', stage: journal.stage, error: `prior runtime restore failed: ${error?.message ?? error}` };
|
|
863
864
|
}
|
|
@@ -868,7 +869,7 @@ export async function reconcileUpdateJournal({ home = homedir(), log = () => {},
|
|
|
868
869
|
if (retainedDir && existsSync(retainedDir)) {
|
|
869
870
|
try {
|
|
870
871
|
rmSync(rt.liveRoot, { recursive: true, force: true });
|
|
871
|
-
|
|
872
|
+
renameTree(retainedDir, rt.liveRoot);
|
|
872
873
|
restored = true;
|
|
873
874
|
} catch { /* fall through to fail closed */ }
|
|
874
875
|
}
|
|
@@ -2434,14 +2435,14 @@ export async function performCoordinatedCohortUpdate({
|
|
|
2434
2435
|
if (existsSync(liveRoot)) {
|
|
2435
2436
|
// prevPath is unique per attempt and already recorded in the journal.
|
|
2436
2437
|
try { rmSync(prevPath, { recursive: true, force: true }); } catch {}
|
|
2437
|
-
|
|
2438
|
+
renameTree(liveRoot, prevPath);
|
|
2438
2439
|
liveMoved = true;
|
|
2439
2440
|
}
|
|
2440
2441
|
mkdirSync(liveRoot, { recursive: true });
|
|
2441
2442
|
} catch (error) {
|
|
2442
2443
|
// Park failed: restore live tree before anything else.
|
|
2443
2444
|
try {
|
|
2444
|
-
if (liveMoved && !existsSync(liveRoot) && prevPath && existsSync(prevPath))
|
|
2445
|
+
if (liveMoved && !existsSync(liveRoot) && prevPath && existsSync(prevPath)) renameTree(prevPath, liveRoot);
|
|
2445
2446
|
} catch { /* best effort */ }
|
|
2446
2447
|
const comp = await compensate();
|
|
2447
2448
|
return finalizeCompensationFailure({ home, code: 'COORDINATED_RUNTIME_PARK_FAILED', error: String(error?.message ?? error), comp });
|
|
@@ -2453,7 +2454,7 @@ export async function performCoordinatedCohortUpdate({
|
|
|
2453
2454
|
let comp = { ok: false };
|
|
2454
2455
|
try {
|
|
2455
2456
|
rmSync(liveRoot, { recursive: true, force: true });
|
|
2456
|
-
if (liveMoved && existsSync(prevPath))
|
|
2457
|
+
if (liveMoved && existsSync(prevPath)) renameTree(prevPath, liveRoot);
|
|
2457
2458
|
comp = await compensate();
|
|
2458
2459
|
} catch { /* compensate below already reports */ }
|
|
2459
2460
|
return finalizeCompensationFailure({ home, code: installed.code ?? 'COORDINATED_RUNTIME_INSTALL_FAILED', error: installed.error ?? 'runtime install at live root failed', comp });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { renameSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rename a directory, absorbing the transient refusal Windows gives for a tree
|
|
5
|
+
* that was just in use.
|
|
6
|
+
*
|
|
7
|
+
* Every runtime-tree move in this codebase happens immediately after the process
|
|
8
|
+
* using that tree was stopped: parking the live cohort, restoring a parked one
|
|
9
|
+
* during recovery, rotating a displaced cohort. On Windows the rename can be
|
|
10
|
+
* refused with EPERM/EACCES for a moment afterwards — a handle another process
|
|
11
|
+
* still holds, an indexer, or the previous child's own teardown — and the same
|
|
12
|
+
* call succeeds a few milliseconds later. Treating the first refusal as fatal
|
|
13
|
+
* turns that timing artifact into a failed upgrade, a failed rollback, or worst
|
|
14
|
+
* of all a recovery that cannot finish.
|
|
15
|
+
*
|
|
16
|
+
* Only the transient codes are retried. A missing source or a cross-device move
|
|
17
|
+
* is not going to become possible on the next attempt, so it is raised at once.
|
|
18
|
+
*/
|
|
19
|
+
export const TRANSIENT_RENAME_CODES = Object.freeze(['EPERM', 'EACCES', 'EBUSY', 'ENOTEMPTY']);
|
|
20
|
+
|
|
21
|
+
export function renameTree(from, to, { rename = renameSync, delays = [0, 40, 120, 300, 700] } = {}) {
|
|
22
|
+
let lastError = null;
|
|
23
|
+
for (const delay of delays) {
|
|
24
|
+
if (delay > 0) {
|
|
25
|
+
const until = Date.now() + delay;
|
|
26
|
+
while (Date.now() < until) { /* bounded wait: this path is not hot */ }
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
rename(from, to);
|
|
30
|
+
return { ok: true };
|
|
31
|
+
} catch (error) {
|
|
32
|
+
lastError = error;
|
|
33
|
+
if (!TRANSIENT_RENAME_CODES.includes(error?.code)) throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
throw lastError;
|
|
37
|
+
}
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export {
|
|
|
36
36
|
// included in the identity contract.
|
|
37
37
|
const RUNTIME_ID = randomUUID();
|
|
38
38
|
|
|
39
|
-
export const RUNTIME_VERSION = '1.10.
|
|
39
|
+
export const RUNTIME_VERSION = '1.10.6';
|
|
40
40
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
41
41
|
|
|
42
42
|
export const HUB_CAPABILITIES = Object.freeze([
|
package/src/workflow-runtime.mjs
CHANGED
|
@@ -181,8 +181,14 @@ export function createWorkflowRuntime(adapters, {
|
|
|
181
181
|
phase: JOB_PHASES.CREATED,
|
|
182
182
|
status: 'running',
|
|
183
183
|
cancelling: false,
|
|
184
|
+
// Where it will run is not known until the workspace is allocated, and the
|
|
185
|
+
// first snapshot a caller sees is taken before that happens. Claiming
|
|
186
|
+
// `shared` there was a wrong answer for every job that ends up isolated:
|
|
187
|
+
// the caller was told the work happens in their own tree while it was
|
|
188
|
+
// about to happen in a worktree. `null` means not allocated yet;
|
|
189
|
+
// `requested_isolation` carries what was asked for.
|
|
184
190
|
execution_cwd: spec.cwd,
|
|
185
|
-
isolation:
|
|
191
|
+
isolation: null,
|
|
186
192
|
base_revision: null,
|
|
187
193
|
primary_workspace_dirty: false,
|
|
188
194
|
attempts: [],
|
package/src/workflow.mjs
CHANGED
|
@@ -63,17 +63,41 @@ function splitSection(value) {
|
|
|
63
63
|
return value.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
'
|
|
72
|
-
'
|
|
73
|
-
'
|
|
74
|
-
|
|
66
|
+
// A line that asserts the workspace ends unchanged. The report sections are
|
|
67
|
+
// prose, so this has to recognize the assertion inside a sentence rather than
|
|
68
|
+
// only as a whole line: "No net file changes — git status is empty" is the same
|
|
69
|
+
// claim as "no changes", and it is the *better* report.
|
|
70
|
+
const NO_CHANGE_ASSERTION_RE = new RegExp(
|
|
71
|
+
'^(?:'
|
|
72
|
+
+ 'no\\s+(?:net\\s+)?(?:files?|changes?)\\b'
|
|
73
|
+
+ '|nothing\\s+(?:was\\s+)?(?:changed|modified|added|removed|deleted)\\b'
|
|
74
|
+
+ '|none\\s+(?:of\\s+the\\s+)?(?:files?|changes?)\\b'
|
|
75
|
+
+ '|workspace\\s+(?:is\\s+|remains\\s+)?(?:unchanged|clean)\\b'
|
|
76
|
+
+ '|(?:the\\s+)?(?:final\\s+|net\\s+)?(?:file\\s+)?changes?\\s*[:—-]\\s*(?:none|no\\b|empty)'
|
|
77
|
+
+ '|无(?:文件)?变更|没有(?:文件)?变更|未(?:更改|修改)任何文件|工作区(?:保持)?不变'
|
|
78
|
+
+ ')',
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
function isNoChangeAssertion(line) {
|
|
82
|
+
const normalized = line
|
|
83
|
+
.replace(/^(?:[-*+]\s+)+/, '')
|
|
84
|
+
.replace(/[`"'“”‘’]/g, '')
|
|
85
|
+
.replace(/[.!。!]+$/, '')
|
|
86
|
+
.trim()
|
|
87
|
+
.toLowerCase();
|
|
88
|
+
if (normalized === '') return false;
|
|
89
|
+
return NO_CHANGE_ASSERTION_RE.test(normalized);
|
|
90
|
+
}
|
|
75
91
|
|
|
92
|
+
// Whether the report claims the workspace holds changes. A report that asserts
|
|
93
|
+
// the workspace ends unchanged is not claiming any, whatever else it describes:
|
|
94
|
+
// an authorized task that creates something, verifies it and removes it must
|
|
95
|
+
// describe that transient work, and describing it is not a claim that it is
|
|
96
|
+
// still there. This can only ever turn a mismatch into a match when the
|
|
97
|
+
// workspace really is unchanged — if it did change, `claimsChanges === false`
|
|
98
|
+
// against real changes is still a mismatch, so no actual change is hidden.
|
|
76
99
|
function deliveryClaimsChanges(outcome) {
|
|
100
|
+
if (outcome?.no_change_declared === true) return false;
|
|
77
101
|
return Array.isArray(outcome?.changes) && outcome.changes.length > 0;
|
|
78
102
|
}
|
|
79
103
|
|
|
@@ -110,15 +134,9 @@ export function applyWorkspaceEvidence(outcome, {
|
|
|
110
134
|
}
|
|
111
135
|
|
|
112
136
|
function parseChanges(section) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
.replace(/[`"'“”‘’]/g, '')
|
|
117
|
-
.replace(/[.!。!]+$/g, '')
|
|
118
|
-
.trim()
|
|
119
|
-
.toLowerCase();
|
|
120
|
-
return !NO_CHANGE_SENTINELS.has(normalized);
|
|
121
|
-
});
|
|
137
|
+
// The declaration lines are dropped so the remaining list is what the report
|
|
138
|
+
// says about files; the no-change assertion itself is kept out of it.
|
|
139
|
+
return splitSection(section).filter((line) => !isNoChangeAssertion(line));
|
|
122
140
|
}
|
|
123
141
|
|
|
124
142
|
// The Tests section is parsed by the delivery contract's own parser. This module
|
|
@@ -163,6 +181,11 @@ export function buildOutcome({ result = '', deliveryMeta, executionStatus, stopR
|
|
|
163
181
|
confidence: null,
|
|
164
182
|
needs_escalation: false,
|
|
165
183
|
changes: parseChanges(parsed.sections.Diff),
|
|
184
|
+
// The report's net claim about the workspace, read from the section as
|
|
185
|
+
// written. It cannot be inferred from `changes`: that list is filtered, so
|
|
186
|
+
// the declaration line is gone from it by the time anyone looks, and the
|
|
187
|
+
// lines that remain describe work the report already said it undid.
|
|
188
|
+
no_change_declared: splitSection(parsed.sections.Diff).some(isNoChangeAssertion),
|
|
166
189
|
tests,
|
|
167
190
|
tests_status: testsStatus ?? null,
|
|
168
191
|
risks: splitSection(parsed.sections.Risks),
|