dsh-gitbash-shell 0.31.0 → 0.32.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/src/index.js CHANGED
@@ -55,6 +55,25 @@ import { dirname, join } from 'node:path'
55
55
  import { fileURLToPath } from 'node:url'
56
56
 
57
57
  import { PRESET_META, minimalPluginsFor, pluginsFor } from './compositions.js'
58
+ import { loadSchemastery } from './schemastery.js'
59
+
60
+ /**
61
+ * The executor base class's own Config schema, when this deployment has it.
62
+ *
63
+ * Dynamic and optional on purpose: `@deepseek-ai/dsh-bash-sandbox` belongs to
64
+ * another row of THIS plugin, and a deployment that cannot resolve it must
65
+ * still mount every other face (the static-peer-import lesson, v0.24.3). What
66
+ * we need it for is the host's OWN behavioural answer to "do you read your
67
+ * shell limits through `.get()`?" — see src/schemastery.js.
68
+ */
69
+ async function executorConfigSchema() {
70
+ try {
71
+ const mod = await import('@deepseek-ai/dsh-bash-sandbox')
72
+ return (mod.SandboxBashExecutor ?? mod.default)?.Config
73
+ } catch {
74
+ return undefined
75
+ }
76
+ }
58
77
 
59
78
  /**
60
79
  * The schemastery module, resolved LAZILY (v0.24.3): `@deepseek-ai/schemastery`
@@ -69,10 +88,15 @@ import { PRESET_META, minimalPluginsFor, pluginsFor } from './compositions.js'
69
88
  * verified with a fixture plugin whose only difference was the static peer
70
89
  * import. Deferring the import hides the schema where the module is absent
71
90
  * instead of killing the row.
91
+ *
92
+ * WHICH copy is resolved is no longer left to our own resolution order
93
+ * (v0.31.1, issue #12): src/schemastery.js asks the host what it expects and
94
+ * picks a copy that agrees with it. Both call sites share the one cached
95
+ * answer, so the settings namespace and the executor can never disagree.
72
96
  */
73
97
  let Schema = null
74
98
  try {
75
- Schema = (await import('@deepseek-ai/schemastery')).default
99
+ Schema = (await loadSchemastery({ parentConfig: await executorConfigSchema() })).z ?? null
76
100
  } catch (error) {
77
101
  Schema = null
78
102
  console.warn(
@@ -261,7 +285,6 @@ import {
261
285
  } from './tool-runner.js'
262
286
 
263
287
  /** The shipped preset whose skills/ dir seeds cordis-gitbash. */
264
- const SKILLS_SOURCE_PRESET = 'cordis'
265
288
 
266
289
  const here = dirname(fileURLToPath(import.meta.url))
267
290
  const pkgDir = join(here, '..')
@@ -329,465 +352,6 @@ function skillsHashes(source) {
329
352
  return out
330
353
  }
331
354
 
332
- // ── built-in era detection (dsh 0.1.2 renamed the `code` preset to `ptc`) ──
333
-
334
- /**
335
- * Which built-in PTC preset does the installed dsh ship? dsh 0.1.2 renamed
336
- * the preset id `code` to `ptc` with no compatibility alias, so the variant
337
- * compositions are era-specific where the built-in changed. The roster probe
338
- * is the version-agnostic signal. Pure companion of `detectBase`; 'ptc' wins
339
- * if both ids somehow exist, and an unknown roster conservatively maps to
340
- * 'code' — the era text the stable release accepted.
341
- */
342
- function baseForRoster(ids) {
343
- const set = new Set(ids)
344
- if (set.has('ptc')) return 'ptc'
345
- return 'code'
346
- }
347
-
348
- /** Async probe against the live roster; never throws. */
349
- async function detectBase(agentPresets) {
350
- try {
351
- const list = await agentPresets.list()
352
- return baseForRoster((Array.isArray(list) ? list : []).map((p) => p && p.id))
353
- } catch (error) {
354
- console.log(`${TAG} built-in preset probe failed (${error?.message ?? error}) — assuming the 'code' era`)
355
- return 'code'
356
- }
357
- }
358
-
359
- /** Era suffix for committed composition assets: ptc-era files carry `.ptc`. */
360
- function eraSuffix(base) {
361
- return base === 'ptc' ? '.ptc' : ''
362
- }
363
-
364
- // ── persona-form detection (dsh 0.1.3-alpha.2 split the persona row) ──────
365
-
366
- /**
367
- * Which persona config shape does one composition text use? 0.1.3-alpha.2
368
- * (40792330c0) split dsh-persona's single `text` key into `prefix:` +
369
- * `suffix:` with no compatibility alias, so compositions carrying a persona
370
- * row are persona-form-specific. Pure companion of `detectPersonaEra`.
371
- */
372
- export function personaEraForText(text) {
373
- return /- id:\s*persona[\s\S]{0,600}?\bprefix:/.test(text) ? 'split' : 'text'
374
- }
375
-
376
- /**
377
- * Async probe of a SHIPPED preset's composition through the live roster;
378
- * never throws. Built-in ids only — this plugin's own variants sit on the
379
- * roster too and would echo whichever form they were materialized with. The
380
- * roster entry path is the preset file (or its directory); either way we
381
- * read agent.cordis.yml. A missing probe conservatively maps to 'text'.
382
- */
383
- export async function detectPersonaEra(agentPresets) {
384
- try {
385
- const list = await agentPresets.list()
386
- const entries = Array.isArray(list) ? list : []
387
- const entry = ['ptc', 'standard', 'cordis', 'minimal']
388
- .map((id) => entries.find((p) => p && p.id === id && typeof p.path === 'string'))
389
- .find(Boolean)
390
- if (!entry) return 'text'
391
- const file = /\.yml$/.test(entry.path) ? entry.path : join(entry.path, 'agent.cordis.yml')
392
- return personaEraForText(readFileSync(file, 'utf8'))
393
- } catch (error) {
394
- console.log(`${TAG} persona-form probe failed (${error?.message ?? error}) — assuming the pre-split 'text' form`)
395
- return 'text'
396
- }
397
- }
398
-
399
- /**
400
- * Does the SHIPPED composition already carry the official `present` row? The
401
- * row (`@deepseek-ai/dsh-tool-present`, immutable file-delivery download
402
- * cards) first shipped with dsh 0.1.5-alpha.2, and a composition row that
403
- * cannot be imported rejects the WHOLE preset mount — so the row is injected
404
- * only on hosts whose own shipped presets have it. The live roster is the
405
- * authority: package resolution alone would lie on CLI installs (first-party
406
- * packages live outside the profile) and on linked development trees.
407
- * `minimal` never gains the row (single-tool preset). Never throws.
408
- */
409
- async function detectPresentSupport(agentPresets) {
410
- try {
411
- const list = await agentPresets.list()
412
- const entries = Array.isArray(list) ? list : []
413
- const entry = ['ptc', 'standard', 'cordis']
414
- .map((id) => entries.find((p) => p && p.id === id && typeof p.path === 'string'))
415
- .find(Boolean)
416
- if (!entry) return false
417
- const file = /\.yml$/.test(entry.path) ? entry.path : join(entry.path, 'agent.cordis.yml')
418
- return readFileSync(file, 'utf8').includes("'@deepseek-ai/dsh-tool-present'")
419
- } catch (error) {
420
- console.log(`${TAG} present-row probe failed (${error?.message ?? error}) — not injecting the row`)
421
- return false
422
- }
423
- }
424
-
425
- /** Row ids the shipped workflow-engine row has answered to (dsh 0.1.6 renamed it). */
426
- const ENGINE_ROW_IDS = ['workflow-worker-thread', 'workflow-ptc']
427
-
428
- /** Which built-in preset each of this plugin's variants mirrors, for row-form lookup. */
429
- export const ROW_SOURCE = {
430
- 'standard-gitbash': 'standard',
431
- 'cordis-gitbash': 'cordis',
432
- 'code-gitbash': 'ptc',
433
- }
434
-
435
- /** Leading whitespace of one line (pure). */
436
- function indentOf(line) {
437
- return line.slice(0, line.length - line.trimStart().length)
438
- }
439
-
440
- /** The unquoted value of a name line, or undefined when the line is not one (pure). */
441
- function quotedName(line) {
442
- const trimmed = line.trim()
443
- if (!trimmed.startsWith('name: ')) return undefined
444
- const raw = trimmed.slice(6).trim()
445
- if (raw.length > 1 && raw.charAt(0) === "'" && raw.charAt(raw.length - 1) === "'") return raw.slice(1, -1)
446
- return raw
447
- }
448
-
449
- /**
450
- * Read ONE row's spelling out of a composition text (pure): the name value
451
- * that follows its id line and whether the row's own block turns it off before
452
- * the next row starts. Returns undefined when the row is absent, and never
453
- * throws, so a composition that legitimately lacks the row (minimal) is simply
454
- * left alone.
455
- * @param text - composition text.
456
- * @param rowId - exact id value to locate.
457
- * @returns the row's spelling, or undefined.
458
- */
459
- export function rowFormOf(text, rowId) {
460
- const lines = text.split('\n')
461
- for (let i = 0; i < lines.length; i += 1) {
462
- if (lines[i].trim() !== '- id: ' + rowId) continue
463
- const nameLine = i + 1 < lines.length ? lines[i + 1] : ''
464
- const name = quotedName(nameLine)
465
- if (name === undefined) continue
466
- let disabled = false
467
- let disabledIndent = indentOf(nameLine)
468
- for (let j = i + 2; j < lines.length; j += 1) {
469
- if (lines[j].trim().startsWith('- id: ')) break
470
- if (lines[j].trim() === 'disabled: true') {
471
- disabled = true
472
- disabledIndent = indentOf(lines[j])
473
- break
474
- }
475
- }
476
- return { id: rowId, name, indent: indentOf(lines[i]), nameIndent: indentOf(nameLine), disabled, disabledIndent }
477
- }
478
- return undefined
479
- }
480
-
481
- /**
482
- * Read the two rows this plugin's committed compositions must track out of a
483
- * SHIPPED composition text (pure). The built-in preset is the authority: it
484
- * mounts on this host by construction, so whatever it spells is mountable
485
- * here, on every era, without shipping one asset variant per rename.
486
- * @param text - a shipped agent.cordis.yml.
487
- * @returns an object with the engine row and the tool-ralph row; either may be undefined.
488
- */
489
- export function rowFormsOf(text) {
490
- let engine
491
- for (const id of ENGINE_ROW_IDS) {
492
- engine = rowFormOf(text, id)
493
- if (engine !== undefined) break
494
- }
495
- return { engine, ralph: rowFormOf(text, 'tool-ralph') }
496
- }
497
-
498
- /**
499
- * Align the workflow-engine row with the host's own spelling (pure,
500
- * idempotent, plain string surgery — never a YAML round-trip, so !!js literals
501
- * survive).
502
- *
503
- * dsh 0.1.6-alpha.1 renamed this row workflow-worker-thread to workflow-ptc
504
- * AND deleted the old package. A composition row whose module fails to import
505
- * rejects the WHOLE preset mount (agent-presets mount.ts), so a committed old
506
- * spelling breaks every Git Bash preset on the new host while the new spelling
507
- * breaks them on hosts that still ship only the old package. The materializer
508
- * therefore copies the row out of the host's own built-in preset instead of
509
- * pinning either spelling.
510
- * @param text - composition text.
511
- * @param form - the host's engine row from rowFormsOf, or undefined to leave the text alone.
512
- * @param options - engineEnabled forces the row on regardless of the host's own state.
513
- * @returns the aligned text.
514
- */
515
- export function alignEngineRow(text, form, options) {
516
- if (!form) return text
517
- const engineEnabled = Boolean(options && options.engineEnabled)
518
- let current
519
- for (const id of ENGINE_ROW_IDS) {
520
- current = rowFormOf(text, id)
521
- if (current !== undefined) break
522
- }
523
- if (current === undefined) return text
524
- const head = '- id: ' + current.id + '\n' + current.nameIndent + "name: '" + current.name + "'"
525
- const wanted = '- id: ' + form.id + '\n' + current.nameIndent + "name: '" + form.name + "'"
526
- const wantOff = engineEnabled ? false : form.disabled
527
- let out = text.replace(head, wanted)
528
- if (wantOff && !current.disabled) {
529
- out = out.replace(wanted + '\n', wanted + '\n' + current.nameIndent + 'disabled: true\n')
530
- } else if (!wantOff && current.disabled) {
531
- out = out.replace(wanted + '\n' + current.disabledIndent + 'disabled: true\n', wanted + '\n')
532
- }
533
- return out
534
- }
535
-
536
- /**
537
- * Align the tool-ralph row with the host's own default (pure, idempotent).
538
- * dsh 0.1.6-alpha.1 ships ralph disabled in every built-in preset: its tool
539
- * description restricts it to runs the human explicitly asked for. The row
540
- * still imports on older hosts, so this is a semantics alignment rather than a
541
- * mount fix — but a composition claiming to mirror the built-in preset should
542
- * not silently re-enable a tool the deployment turned off.
543
- * @param text - composition text.
544
- * @param form - the host's tool-ralph row from rowFormsOf, or undefined.
545
- * @returns the aligned text.
546
- */
547
- export function alignRalphRow(text, form) {
548
- if (!form) return text
549
- const current = rowFormOf(text, 'tool-ralph')
550
- if (current === undefined) return text
551
- const head = '- id: tool-ralph\n' + current.nameIndent + "name: '" + current.name + "'"
552
- if (form.disabled && !current.disabled) {
553
- return text.replace(head + '\n', head + '\n' + current.nameIndent + 'disabled: true\n')
554
- }
555
- if (!form.disabled && current.disabled) {
556
- return text.replace(head + '\n' + current.disabledIndent + 'disabled: true\n', head + '\n')
557
- }
558
- return text
559
- }
560
-
561
- /**
562
- * Probe every built-in preset this plugin's variants mirror for those row
563
- * spellings (never throws). The live roster is the authority — package
564
- * resolution alone would lie on linked development trees and on CLI installs
565
- * whose first-party packages live outside the profile.
566
- * @param agentPresets - the roster service.
567
- * @returns a map of row forms per available built-in id, or undefined.
568
- */
569
- async function detectRowForms(agentPresets) {
570
- try {
571
- const list = await agentPresets.list()
572
- const entries = Array.isArray(list) ? list : []
573
- const out = {}
574
- for (const id of ['ptc', 'standard', 'cordis']) {
575
- const entry = entries.find((p) => p && p.id === id && typeof p.path === 'string')
576
- if (!entry) continue
577
- const file = /\.yml$/.test(entry.path) ? entry.path : join(entry.path, 'agent.cordis.yml')
578
- out[id] = rowFormsOf(readFileSync(file, 'utf8'))
579
- }
580
- return Object.keys(out).length > 0 ? out : undefined
581
- } catch (error) {
582
- console.log(TAG + ' row-form probe failed (' + (error && error.message ? error.message : error) + ') — keeping the committed spellings')
583
- return undefined
584
- }
585
- }
586
-
587
- /**
588
- * Marker fingerprint of the row spellings one variant is aligned to, so a host
589
- * that renames (or re-defaults) them re-materializes on the next boot.
590
- * @param rows - detectRowForms result.
591
- * @param presetId - the variant being materialized.
592
- * @returns a short stable string, '' when nothing was probed.
593
- */
594
- function rowFingerprint(rows, presetId) {
595
- const source = ROW_SOURCE[presetId]
596
- const form = rows && source ? rows[source] : undefined
597
- if (!form || !form.engine) return ''
598
- const ralph = form.ralph === undefined ? 'na' : (form.ralph.disabled ? 'off' : 'on')
599
- return form.engine.id + ':' + (form.engine.disabled ? 'off' : 'on') + ':' + ralph
600
- }
601
-
602
- /**
603
- * Pick the committed composition asset inside one variant directory (pure).
604
- * The ptc-era twin is preferred when the roster says `ptc`; the persona-split
605
- * twin (`.ps`, v0.12.0) is preferred when the shipped persona row carries the
606
- * split keys; variants without a twin fall back down the chain (minimal never
607
- * had a ptc twin and gained its `.ps` twin instead). Every candidate is a
608
- * committed file — no runtime text synthesis.
609
- */
610
- function pickComposition(base, persona, available) {
611
- const era = eraSuffix(base)
612
- const ps = persona === 'split' ? '.ps' : ''
613
- const candidates = base === 'ptc'
614
- ? [`agent.cordis${era}${ps}.yml`, `agent.cordis${era}.yml`, ...(ps ? ['agent.cordis.ps.yml'] : []), 'agent.cordis.yml']
615
- : ['agent.cordis.yml']
616
- for (const file of candidates) if (available.includes(file)) return file
617
- return 'agent.cordis.yml'
618
- }
619
-
620
- /** Startup decision for an existing unmodified tree: 'refresh' or 'idle'. */
621
- function syncDecision({ state, marker, version, sourceHashes, base = 'code', persona = 'text', present = false, pluginManager = false, rows = '' }) {
622
- if (state !== 'unmodified' || !marker) return 'refresh'
623
- if (marker.version !== version) return 'refresh'
624
- if (marker.base !== base) return 'refresh'
625
- if ((marker.persona ?? 'text') !== persona) return 'refresh'
626
- // Host row-form flip: dsh 0.1.6-alpha.1 renamed the workflow-engine row and
627
- // deleted the package behind the old spelling. A composition row that cannot
628
- // be imported rejects the WHOLE preset mount, so a host reporting a
629
- // different spelling must re-materialize (same marker-dimension pattern as
630
- // persona and present).
631
- if ((marker.rows ?? '') !== rows) return 'refresh'
632
- // Host-capability flip: @deepseek-ai/dsh-tool-present first shipped with dsh
633
- // 0.1.5-alpha.2. A preset composed with the row on a host that lacks the
634
- // package REJECTS THE WHOLE MOUNT (mount.ts: a row whose module failed to
635
- // import already rejects the mount), so the row is injected only when the
636
- // host resolves it — and a host upgrade past that point must re-materialize
637
- // to gain it (same marker-dimension pattern as persona).
638
- if ((marker.present ?? false) !== present) return 'refresh'
639
- // Host-capability flip: @deepseek-ai/dsh-plugin-manager/tools first shipped
640
- // with dsh 0.1.6-alpha.2 — same mount-rejection stakes and the same marker
641
- // dimension pattern as present above.
642
- if ((marker.pluginManager ?? false) !== pluginManager) return 'refresh'
643
- const recorded = {}
644
- for (const k of Object.keys(marker.files)) if (k.startsWith('skills/')) recorded[k] = marker.files[k]
645
- if (sourceHashes === null) return Object.keys(recorded).length === 0 ? 'idle' : 'refresh'
646
- const live = Object.keys(sourceHashes)
647
- const seen = Object.keys(recorded)
648
- if (live.length !== seen.length) return 'refresh'
649
- for (const k of live) if (recorded[k] !== sourceHashes[k]) return 'refresh'
650
- return 'idle'
651
- }
652
-
653
- // ── materialization ─────────────────────────────────────────────────────────
654
-
655
- /*
656
- * Conditional present-row injection (v0.13.0, dsh 0.1.5-alpha.2 sync). The
657
- * official ptc/standard/cordis compositions appended `- id: present / name:
658
- * '@deepseek-ai/dsh-tool-present'` (immutable file-delivery download cards).
659
- * The row is NOT committed into the ptc-era twin files: a host older than
660
- * 0.1.5-alpha.2 lacks the package, and a composition row that fails to
661
- * import rejects the WHOLE preset mount. Instead the materializer probes the
662
- * host (hostHasToolPresent) and splices the row in as PLAIN TEXT — anchor
663
- * string surgery only, never a YAML round-trip, so !!js literals survive.
664
- * Placement mirrors the official compositions: right after the
665
- * tool-presentation block when the composition has one (all ptc-derived
666
- * twins), otherwise appended at the tail (cordis/standard twins). Only
667
- * `.ptc.` files are ever touched: code-era (<= 0.1.1) snapshots and the
668
- * minimal variants are frozen history (official minimal never gained the
669
- * row — it is the single-tool preset).
670
- */
671
- const PRESENT_ROW = "\n- id: present\n name: '@deepseek-ai/dsh-tool-present'\n"
672
- const PRESENT_ANCHOR = " name: '@deepseek-ai/dsh-agent-tool-presentation'\n config:\n mode: ptc\n"
673
-
674
- function injectPresentRow(text) {
675
- if (text.includes("'@deepseek-ai/dsh-tool-present'")) return text
676
- const at = text.indexOf(PRESENT_ANCHOR)
677
- if (at !== -1) return text.slice(0, at + PRESENT_ANCHOR.length) + PRESENT_ROW + text.slice(at + PRESENT_ANCHOR.length)
678
- return text.endsWith('\n') ? text + PRESENT_ROW : text + '\n' + PRESENT_ROW
679
- }
680
-
681
- let toolPresentCache
682
- /** Probe whether THIS host can resolve the present package (cached per boot). */
683
- async function hostHasToolPresent() {
684
- if (toolPresentCache !== undefined) return toolPresentCache
685
- try {
686
- const { createRequire } = await import('node:module')
687
- createRequire(import.meta.url).resolve('@deepseek-ai/dsh-tool-present')
688
- toolPresentCache = true
689
- } catch {
690
- toolPresentCache = false
691
- }
692
- return toolPresentCache
693
- }
694
-
695
- /*
696
- * Conditional plugin-manager-row injection (v0.15.0, dsh 0.1.6-alpha.2 sync).
697
- * Official ptc/standard/cordis gained `- id: tool-plugin-manager / name:
698
- * '@deepseek-ai/dsh-plugin-manager/tools'` (persistent plugin management;
699
- * the official ptc preset carries it DISABLED, Creation keeps it on). The
700
- * package only exists from 0.1.6-alpha.2 on, so the row is injected as plain
701
- * text behind a host probe — never committed into the assets — anchored
702
- * after the present row when there is one, else at the tail, mirroring the
703
- * official order (tool-presentation → present → tool-plugin-manager).
704
- * Per-variant shape mirrors each variant's official base: code-gitbash (ptc
705
- * base) DISABLED, standard/cordis-gitbash ENABLED, minimal untouched
706
- * (official minimal never gained the row), code-era files frozen history.
707
- */
708
- const PLUGIN_MANAGER_ROW_ON = "- id: tool-plugin-manager\n name: '@deepseek-ai/dsh-plugin-manager/tools'\n"
709
- const PLUGIN_MANAGER_ROW_OFF = "- id: tool-plugin-manager\n name: '@deepseek-ai/dsh-plugin-manager/tools'\n disabled: true\n"
710
- const PRESENT_ROW_FULL = "\n- id: present\n name: '@deepseek-ai/dsh-tool-present'\n"
711
-
712
- function injectPluginManagerRow(text, { enabled }) {
713
- if (text.includes("'@deepseek-ai/dsh-plugin-manager/tools'")) return text
714
- const body = enabled ? PLUGIN_MANAGER_ROW_ON : PLUGIN_MANAGER_ROW_OFF
715
- const at = text.indexOf(PRESENT_ROW_FULL)
716
- if (at !== -1) return text.slice(0, at + PRESENT_ROW_FULL.length) + body + text.slice(at + PRESENT_ROW_FULL.length)
717
- return text.endsWith('\n') ? text + body : text + '\n' + body
718
- }
719
-
720
-
721
- let pluginManagerToolsCache
722
- /** Probe whether THIS host can resolve the plugin-manager tools package (cached per boot). */
723
- async function hostHasPluginManagerTools() {
724
- if (pluginManagerToolsCache !== undefined) return pluginManagerToolsCache
725
- try {
726
- const { createRequire } = await import('node:module')
727
- createRequire(import.meta.url).resolve('@deepseek-ai/dsh-plugin-manager/tools')
728
- pluginManagerToolsCache = true
729
- } catch {
730
- pluginManagerToolsCache = false
731
- }
732
- return pluginManagerToolsCache
733
- }
734
-
735
- /** Write one preset directory from scratch. Returns 'ok' or 'no-skills-source'. */
736
- function materialize({ target, presetId, skillsSource, version, base = 'code', persona = 'text', present = false, pluginManager = false, rows }) {
737
- rmSync(target, { recursive: true, force: true })
738
- mkdirSync(target, { recursive: true })
739
-
740
- const available = readdirSync(join(pkgDir, 'assets', presetId)).filter((f) => f.endsWith('.yml'))
741
- const compositionFile = pickComposition(base, persona, available)
742
- let composition = readFileSync(join(pkgDir, 'assets', presetId, compositionFile), 'utf8')
743
- if (present && compositionFile.includes('.ptc.')) composition = injectPresentRow(composition)
744
- // v0.15.0: ptc-era files only; the ptc-based variant mirrors the official
745
- // ptc DISABLED form, standard/cordis mirror their ENABLED bases, minimal is
746
- // untouched (official minimal never gained the row).
747
- if (pluginManager && compositionFile.includes('.ptc.') && presetId !== 'minimal-gitbash') {
748
- composition = injectPluginManagerRow(composition, { enabled: presetId !== 'code-gitbash' })
749
- }
750
- // Row-form alignment (v0.14.0): the committed assets pin one spelling of the
751
- // workflow-engine row, but a host renames it out from under them (dsh
752
- // 0.1.6-alpha.1) and a row whose module fails to import rejects the whole
753
- // mount. Copy the host's own spelling rather than shipping a third asset
754
- // variant per era. Absent probe = leave the frozen history untouched.
755
- const rowSource = ROW_SOURCE[presetId]
756
- const rowForm = rows && rowSource ? rows[rowSource] : undefined
757
- if (rowForm) {
758
- composition = alignEngineRow(composition, rowForm.engine)
759
- composition = alignRalphRow(composition, rowForm.ralph)
760
- }
761
- writeFileSync(join(target, 'agent.cordis.yml'), composition)
762
- writeFileSync(join(target, 'preset.yml'), readFileSync(join(pkgDir, 'assets', presetId, 'preset.yml')))
763
-
764
- let skills = 'none'
765
- if (presetId === 'cordis-gitbash') {
766
- if (skillsSource && existsSync(skillsSource)) {
767
- cpSync(skillsSource, join(target, 'skills'), { recursive: true, force: true, dereference: true })
768
- skills = 'copied'
769
- } else {
770
- mkdirSync(join(target, 'skills'), { recursive: true })
771
- skills = 'missing-source'
772
- }
773
- }
774
-
775
- const marker = { managedBy: MANAGED_BY, version, presetId, base, persona, present, pluginManager, rows: rowFingerprint(rows, presetId), files: hashTree(target) }
776
- writeFileSync(join(target, MARKER_FILE), JSON.stringify(marker, null, 2) + '\n')
777
- return skills
778
- }
779
-
780
- /** Disposal-time cleanup for one preset. */
781
- function cleanupOnDispose({ target, packageJsonExists }) {
782
- if (packageJsonExists) return 'kept-package-intact'
783
- const state = classify(target)
784
- if (state === 'unmodified') {
785
- rmSync(target, { recursive: true, force: true })
786
- return 'removed'
787
- }
788
- return `kept-${state}`
789
- }
790
-
791
355
  // ── inspect-registry compatibility shim ─────────────────────────────────────
792
356
  //
793
357
  // The host-plane runner's inspect registry is a process-global singleton and
@@ -1076,36 +640,6 @@ export function readSuppressPeerCordis(ctxLike) {
1076
640
  }
1077
641
  }
1078
642
 
1079
- /**
1080
- * Bounded probe for dsh-ptc-cordis-preset's coverage capability. The OLD era
1081
- * has no registration registry to watch, and rows mount concurrently, so poll
1082
- * briefly when the service is not there yet; a short absence means the peer is
1083
- * not installed (or is older than 0.14.0) and every configured variant stays.
1084
- * @param {object} ctx - the plugin's mounting context.
1085
- * @param {number} [timeoutMs] - total wait before giving up.
1086
- * @param {number} [intervalMs] - poll interval.
1087
- * @returns {Promise<boolean>} the peer's `gitBashActive`, false when unknown.
1088
- */
1089
- export async function detectPeerCoverage(ctx, timeoutMs = 1000, intervalMs = 25) {
1090
- try {
1091
- const probe = () => {
1092
- const capability = ctx.get(PEER_CAPABILITY)
1093
- return capability === null || capability === undefined ? undefined : capability
1094
- }
1095
- let capability = probe()
1096
- if (capability === undefined) {
1097
- const deadline = Date.now() + timeoutMs
1098
- while (Date.now() < deadline) {
1099
- await new Promise((resolve) => setTimeout(resolve, intervalMs))
1100
- capability = probe()
1101
- if (capability !== undefined) break
1102
- }
1103
- }
1104
- return capability?.gitBashActive === true
1105
- } catch {
1106
- return false
1107
- }
1108
- }
1109
643
 
1110
644
  // Windows absolute path -> MSYS drive-root form, for rewriting PROSE in place
1111
645
  // (v0.10.0). Quoted spaced paths ("C:\Program Files\Git") are matched whole; a bare
@@ -2072,10 +1606,6 @@ function adoptSidebarShell(ctx, bashPath, readAdopt) {
2072
1606
  // ── plugin ──────────────────────────────────────────────────────────────────
2073
1607
 
2074
1608
  /** First user-trust root: the roster's authoring target. */
2075
- function firstUserRoot(roots) {
2076
- for (const r of roots) if (r && r.trust === 'user' && typeof r.path === 'string') return r
2077
- return undefined
2078
- }
2079
1609
 
2080
1610
  /** Directory of skills inside the installed shipped `cordis` preset, if any. */
2081
1611
  async function findSkillsSource(agentPresets) {
@@ -2415,11 +1945,14 @@ export async function apply(ctx, config = {}) {
2415
1945
  })()
2416
1946
  if (legacySettings) try {
2417
1947
  ctx.inject(['settings'], (sctx) => {
2418
- Promise.all([import('@deepseek-ai/dsh-settings'), import('@deepseek-ai/schemastery')])
2419
- .then(([ds, sm]) => {
1948
+ Promise.resolve(import('@deepseek-ai/dsh-settings'))
1949
+ .then((ds) => {
2420
1950
  const settings = sctx && sctx.settings
2421
1951
  if (!settings || typeof settings.register !== 'function') return
2422
- const Schema = sm.default
1952
+ // The SAME copy the executor and the row Config were built from
1953
+ // (issue #12): one resolution per process, so the two faces of this
1954
+ // plugin can never disagree about the schema vocabulary.
1955
+ if (!Schema) return
2423
1956
  // Era probe: newer dsh register() takes a plain string; the older
2424
1957
  // one accepted the branded helper — one call satisfies both.
2425
1958
  const ns = typeof ds.settingsNamespace === 'function'
@@ -2981,92 +2514,6 @@ export async function apply(ctx, config = {}) {
2981
2514
  return
2982
2515
  }
2983
2516
 
2984
- const roots = ctx.agentPresets?.roots ?? []
2985
- const userRoot = firstUserRoot(roots)
2986
- if (!userRoot) {
2987
- console.log(`${TAG} no user-trust preset root configured — nothing to materialize`)
2988
- return
2989
- }
2990
-
2991
- let version = '0.0.0'
2992
- try {
2993
- version = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')).version ?? version
2994
- } catch {
2995
- /* fall back to the placeholder */
2996
- }
2997
-
2998
- const skillsSource = await findSkillsSource(ctx.agentPresets)
2999
- const base = await detectBase(ctx.agentPresets)
3000
- const persona = await detectPersonaEra(ctx.agentPresets)
3001
- // Two independent signals, either of which is sufficient: the shipped
3002
- // composition text (authoritative on every install layout) and the package
3003
- // resolving from this plugin's own tree (covers hosts where the roster
3004
- // probe is unavailable).
3005
- const present = (await detectPresentSupport(ctx.agentPresets)) || (await hostHasToolPresent())
3006
- // v0.15.0 (dsh 0.1.6-alpha.2): official ptc/standard/cordis gained the
3007
- // tool-plugin-manager row; inject behind the same host-package probe as
3008
- // present (resolve-only is sufficient — the package cannot exist on an
3009
- // older host, so a roster text probe adds nothing).
3010
- const pluginManager = await hostHasPluginManagerTools()
3011
- // Row spellings of the built-in presets these variants mirror: the
3012
- // workflow-engine row was renamed and its old package deleted in dsh
3013
- // 0.1.6-alpha.1, so the materializer copies whatever the host itself ships.
3014
- const rows = await detectRowForms(ctx.agentPresets)
3015
- const userRootPath = userRoot.path
3016
- // Same dedupe decision as the declarative era, taken once at startup here:
3017
- // this host has no roster registry to watch and no live Config writes, so the
3018
- // switch is read per boot (documented in the README). `purgeOrphans` retires
3019
- // a copy an earlier boot materialized, and flipping the switch back
3020
- // re-materializes it on the next start.
3021
- const suppressPeer = liveSettings.suppressPeerCordis()
3022
- const peerGitBash = suppressPeer ? await detectPeerCoverage(ctx) : false
3023
- const effectiveIds = effectivePresetIds(presetIds, { suppress: suppressPeer, peerGitBash })
3024
- if (effectiveIds.length !== presetIds.length) {
3025
- const skipped = presetIds.filter((presetId) => !effectiveIds.includes(presetId))
3026
- console.log(`${TAG} dsh-ptc-cordis-preset covers Creation mode on Git Bash — not materializing: ${skipped.join(', ')}`)
3027
- }
3028
- purgeOrphans(userRootPath, effectiveIds)
3029
-
3030
- for (const presetId of effectiveIds) {
3031
- const target = join(userRootPath, presetId)
3032
- const state = classify(target)
3033
- if (state === 'foreign') {
3034
- console.log(`${TAG} a preset not written by this plugin already exists at ${target} — leaving it alone`)
3035
- continue
3036
- }
3037
- if (state === 'user-modified') {
3038
- console.log(`${TAG} preset at ${target} was modified after materialization — keeping the user's version (delete the directory to re-materialize)`)
3039
- continue
3040
- }
3041
-
3042
- // Reversible side effect, registered BEFORE the idle check so a quiet
3043
- // startup keeps uninstall hygiene.
3044
- ctx.effect(() => () => {
3045
- try {
3046
- const result = cleanupOnDispose({ target, packageJsonExists: existsSync(join(pkgDir, 'package.json')) })
3047
- if (result === 'removed') console.log(`${TAG} package uninstalled — removed the unmodified '${presetId}' preset`)
3048
- else if (result !== 'kept-package-intact' && result !== 'kept-absent') console.log(`${TAG} preset ${result} on disposal — kept`)
3049
- } catch (error) {
3050
- console.log(`${TAG} cleanup skipped: ${error?.message ?? error}`)
3051
- }
3052
- }, `dsh-gitbash-shell: preset materialization (${presetId})`)
3053
-
3054
- const sourceHashes = presetId === 'cordis-gitbash' ? skillsHashes(skillsSource) : null
3055
- const marker = readMarker(target)
3056
- const markerRows = rowFingerprint(rows, presetId)
3057
- if (state === 'unmodified' && syncDecision({ state, marker, version, sourceHashes, base, persona, present, pluginManager, rows: markerRows }) === 'idle') {
3058
- ctx.logger?.('gitbash-shell')?.debug?.( `preset '${presetId}' up to date (v${version}, ${base}-era${persona === 'split' ? ', persona-split' : ''}) — idle`)
3059
- continue
3060
- }
3061
-
3062
- const skills = materialize({ target, presetId, skillsSource, version, base, persona, present, pluginManager, rows })
3063
- const verb = state === 'absent' ? 'materialized' : 'refreshed'
3064
- console.log(
3065
- `${TAG} ${verb} preset '${presetId}' into ${userRootPath} (v${version}, ${base}-era composition${persona === 'split' ? ', persona-split' : ''})` +
3066
- (skills === 'copied' ? " (skills copied from the installed 'cordis' preset)" : '')
3067
- )
3068
- }
3069
2517
  }
3070
-
3071
2518
  // Test surface: pure helpers, no Cordis context required.
3072
- export const _internal = { PRESET_IDS, PEER_COVERED_PRESET_ID, PEER_CAPABILITY, isDelegatedAgent, dialectApplies, PEER_PYTHON_FIELD, PEER_PYTHON_BACKEND_FIELD, peerFact, peerBackend, pythonBackendActive, effectivePresetIds, detectPeerCoverage, readSuppressPeerCordis, translateDispatch, translateMsysPath, translatePathArguments, rewriteCodePaths, scanCodeLiterals, programPrelude, translateGlobArguments, buildTranslateEnv, rewriteErrorContent, rewriteErrorMessage, rewriteFailureMessage, msysEcho, driveToMsys, pathEcho, ERROR_CONTENT_TOOLS, readPosixPaths, readAdoptSidebar, readDialectSettings, windowsToMsys, rewriteResultPaths, adoptSidebarShell, MARKER_FILE, classify, materialize, cleanupOnDispose, firstUserRoot, hashTree, skillsHashes, syncDecision, installRegisterShim, baseForRoster, detectBase, pickComposition, personaEraForText, detectPersonaEra, injectPresentRow, hostHasToolPresent, detectPresentSupport, injectPluginManagerRow, hostHasPluginManagerTools, rowFormOf, rowFormsOf, alignEngineRow, alignRalphRow, ROW_SOURCE }
2519
+ export const _internal = { PRESET_IDS, PEER_COVERED_PRESET_ID, PEER_CAPABILITY, isDelegatedAgent, dialectApplies, PEER_PYTHON_FIELD, PEER_PYTHON_BACKEND_FIELD, peerFact, peerBackend, pythonBackendActive, effectivePresetIds, readSuppressPeerCordis, translateDispatch, translateMsysPath, translatePathArguments, rewriteCodePaths, scanCodeLiterals, programPrelude, translateGlobArguments, buildTranslateEnv, rewriteErrorContent, rewriteErrorMessage, rewriteFailureMessage, msysEcho, driveToMsys, pathEcho, ERROR_CONTENT_TOOLS, readPosixPaths, readAdoptSidebar, readDialectSettings, windowsToMsys, rewriteResultPaths, adoptSidebarShell, MARKER_FILE, classify, hashTree, installRegisterShim }