@zfdx123/dsh-hooks-ordering 1.0.4 → 1.0.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/client.js CHANGED
@@ -14,9 +14,11 @@
14
14
  // - the file is a classic script, so it has no import/export: React arrives
15
15
  // through the synchronous `require` handed to the factory.
16
16
  //
17
- // Writes go through `scope.mutate` rather than `scope.set`: the latter is
18
- // documented for a "scalar field", while our fields are string arrays, and
19
- // path-addressed ops say exactly what is meant for both save and reset.
17
+ // Writes go through two path-addressed ops rather than a scalar setter: the
18
+ // fields are string arrays, and `{op:'set'|'unset', path}` says exactly what is
19
+ // meant for both save and reset. They are sent to the settings Remote namespace
20
+ // (`ctx.remote.settings.mutate`), which resolves them against the stored section
21
+ // rather than against whatever this page last read.
20
22
  //
21
23
  // The namespace is `applies: 'restart'`, so the page says so instead of
22
24
  // pretending a save took effect immediately.
@@ -102,7 +104,7 @@
102
104
  fieldHooks: 'waterfall 钩子(每行一个)',
103
105
  fieldSerialHooks: 'serial 钩子(每行一个)',
104
106
  fieldLog: '约束 DAG 日志文件(留空即不记录)',
105
- note: '改动在重启 dsh 后生效(该命名空间 applies: restart)。清空某字段即回到组合层:目前 base = {hooks} 个 waterfall / {serial} 个 serial 钩子。',
107
+ note: '改动在重启 dsh 后生效(hooks/serialHooks 的变更需要重新挂载钩子)。清空某字段即回到 schema 默认值。',
106
108
  saveFailed: '保存失败:{message}',
107
109
  saving: '保存中…',
108
110
  save: '保存',
@@ -119,7 +121,7 @@
119
121
  fieldHooks: 'Waterfall hooks (one per line)',
120
122
  fieldSerialHooks: 'Serial hooks (one per line)',
121
123
  fieldLog: 'Constraint DAG log file (leave empty to log nothing)',
122
- note: 'Changes take effect after dsh restarts (the namespace is applies: restart). Clearing a field returns it to the composition layer: base = {hooks} waterfall / {serial} serial hooks right now.',
124
+ note: 'Changes take effect after dsh restarts (editing hooks/serialHooks re-mounts the hook brackets). Clearing a field returns it to the schema default.',
123
125
  saveFailed: 'Save failed: {message}',
124
126
  saving: 'Saving…',
125
127
  save: 'Save',
@@ -344,17 +346,285 @@
344
346
  return style
345
347
  }
346
348
 
349
+ /**
350
+ * The name this plugin's settings entry actually has, resolved at runtime
351
+ * from the descriptors the host returns.
352
+ *
353
+ * It CANNOT be a constant. The settings namespace is the **loader entry
354
+ * id**, which belongs to whoever wrote the row that mounted this plugin,
355
+ * not to the plugin: the aggregator's `cordis.patch.yml` uses
356
+ * `dsh-plugin-hooks-ordering`, while this package's own patch uses
357
+ * `hooks-ordering`. Hardcoding the latter against the former produced
358
+ *
359
+ * no settings entry is named "hooks-ordering" (describe() returned: …, dsh-plugin-hooks-ordering, …)
360
+ *
361
+ * and the write path was refused outright:
362
+ *
363
+ * settings/rejected: No configurable plugin entry "hooks-ordering"
364
+ *
365
+ * Matching on the descriptor's own `ns` removes the guesswork: whatever id
366
+ * the mounting row has, this is the entry it produced.
367
+ */
368
+ var resolvedNs = null
369
+
370
+ /**
371
+ * Normalise a Remote answer to `{ ok, value }` / `{ ok: false, reason }`.
372
+ *
373
+ * The 0.1.7 Remote protocol answers every call with a `RemoteResult`
374
+ * envelope and the client proxy hands that envelope through as-is, so a
375
+ * caller that reads fields off the answer directly sees `undefined` while
376
+ * the wire traffic is healthy. A plain value is accepted too, so this
377
+ * keeps working if a future runtime unwraps for us.
378
+ *
379
+ * @param answer - whatever the Remote call resolved to.
380
+ * @returns the unwrapped outcome.
381
+ */
382
+ function unwrapRemote(answer) {
383
+ if (answer !== null && typeof answer === 'object' && typeof answer.ok === 'boolean') {
384
+ if (answer.ok === false) {
385
+ var failure = answer.error
386
+ var reason =
387
+ failure && typeof failure === 'object'
388
+ ? String(failure.code || 'remote error') + ': ' + String(failure.message || '')
389
+ : String(failure || 'remote error')
390
+ return { ok: false, reason: reason }
391
+ }
392
+ return { ok: true, value: answer.value }
393
+ }
394
+ return { ok: true, value: answer }
395
+ }
396
+
397
+ /**
398
+ * @param list - descriptor list from `describe()`.
399
+ * @returns the descriptor for this plugin, or undefined.
400
+ */
401
+ function pickEntry(list) {
402
+ // Fast path: the row used this package's own documented id.
403
+ var direct = list.find(function (entry) {
404
+ return entry && entry.ns === NS
405
+ })
406
+ if (direct !== undefined) return direct
407
+ // Otherwise take the entry that was mounting this plugin created.
408
+ // `dsh-plugin-hooks-ordering` is the shipped aggregator row; the suffix
409
+ // test also covers a user's own row name.
410
+ var suffixed = list.filter(function (entry) {
411
+ return entry && typeof entry.ns === 'string' && entry.ns.slice(-NS.length) === NS
412
+ })
413
+ return suffixed.length === 1 ? suffixed[0] : undefined
414
+ }
415
+
347
416
  // ── the settings page ──────────────────────────────────────────────────
417
+
418
+ /**
419
+ * Interpret the setting page's view of one entry.
420
+ *
421
+ * Three inputs can describe it: an already-assembled view (`{status,
422
+ * value, ...}`), a raw settings descriptor (`{ns, value, revision}`), or
423
+ * nothing at all.
424
+ *
425
+ * @param raw - one of the shapes above, or null/undefined.
426
+ * @returns a view this page can render, or `null` when there is no entry.
427
+ */
428
+ function normaliseSnapshot(raw) {
429
+ if (raw === null || raw === undefined) return null
430
+ var writable = raw.writable !== false
431
+ if (raw.status === 'unavailable' || raw.available === false) {
432
+ return {
433
+ status: 'unavailable',
434
+ available: false,
435
+ writable: false,
436
+ revision: undefined,
437
+ value: null,
438
+ base: {},
439
+ reason: raw.reason,
440
+ }
441
+ }
442
+ return {
443
+ status: 'ready',
444
+ available: true,
445
+ writable: writable,
446
+ revision: raw.revision,
447
+ value: raw.value || null,
448
+ base: raw.base || {},
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Adapt the 0.1.7 `ctx.remote.settings` namespace to the snapshot surface
454
+ * this page renders from.
455
+ *
456
+ * 0.1.6 exposed a `settingsScope` service that answered snapshots
457
+ * synchronously. 0.1.7 removed it: a page now talks to the settings
458
+ * Remote, whose `describe()`/`mutate()` are async and keyed by the
459
+ * profile entry id. This adapter resolves the descriptor once, re-resolves
460
+ * it on the host's `settings/document-updated` notification.
461
+ *
462
+ * @param ctx - the plugin context (provides `remote`).
463
+ */
464
+ function createSettingsSource(ctx) {
465
+ var remote = ctx && ctx.remote && ctx.remote.settings
466
+ /**
467
+ * The name this plugin's settings entry actually has is resolved at
468
+ * runtime by {@link pickEntry} into the module-level `resolvedNs`; see
469
+ * the note there for why it cannot be a constant.
470
+ */
471
+
472
+ /**
473
+ * Why the form has no data. Kept as a concrete, user-visible string:
474
+ * "not available" on its own made a wiring fault indistinguishable from
475
+ * a not-yet-mounted host half, and that cost a lot of blind debugging.
476
+ */
477
+ function unavailableBecause(reason) {
478
+ return {
479
+ status: 'unavailable',
480
+ available: false,
481
+ writable: false,
482
+ value: null,
483
+ revision: undefined,
484
+ reason: reason,
485
+ }
486
+ }
487
+
488
+ /**
489
+ * @returns a promise of the current descriptor view, or of an
490
+ * `unavailable` view carrying the concrete reason.
491
+ */
492
+ function read() {
493
+ if (!ctx || !ctx.remote) {
494
+ return Promise.resolve(unavailableBecause('ctx.remote is not injected'))
495
+ }
496
+ if (!remote) {
497
+ return Promise.resolve(unavailableBecause('the ctx.remote.settings namespace is absent'))
498
+ }
499
+ if (typeof remote.describe !== 'function') {
500
+ return Promise.resolve(unavailableBecause('ctx.remote.settings.describe is not a function'))
501
+ }
502
+ return Promise.resolve()
503
+ .then(function () {
504
+ return remote.describe()
505
+ })
506
+ .then(function (answer) {
507
+ // A Remote call answers with a `RemoteResult` envelope
508
+ // (`{ok:true, value}` / `{ok:false, error}`) and the client proxy
509
+ // does NOT unwrap it: reading `answer.namespaces` directly got
510
+ // `undefined`, which surfaced as the misleading
511
+ // "describe() returned no namespaces array" while the network
512
+ // exchange was perfectly healthy (ok:true, 20 namespaces).
513
+ var result = unwrapRemote(answer)
514
+ if (result.ok === false) {
515
+ return unavailableBecause('describe() failed: ' + result.reason)
516
+ }
517
+ var value = result.value
518
+ if (value === null || value === undefined) {
519
+ return unavailableBecause('describe() returned no value')
520
+ }
521
+ var list = Array.isArray(value.namespaces) ? value.namespaces : null
522
+ if (list === null) {
523
+ return unavailableBecause(
524
+ 'describe() value has no namespaces array (keys: ' + Object.keys(value).join(', ') + ')',
525
+ )
526
+ }
527
+ var found = pickEntry(list)
528
+ if (found === undefined) {
529
+ var seen = list
530
+ .map(function (entry) {
531
+ return entry && entry.ns
532
+ })
533
+ .filter(Boolean)
534
+ .join(', ')
535
+ return unavailableBecause(
536
+ 'no settings entry for this plugin (looked for "' +
537
+ NS +
538
+ '"; describe() returned: ' +
539
+ (seen || 'nothing') +
540
+ ')',
541
+ )
542
+ }
543
+ resolvedNs = found.ns
544
+ return {
545
+ status: 'ready',
546
+ available: true,
547
+ // The provider's writability gates the whole form; a read-only
548
+ // deployment still shows the effective values.
549
+ writable: answer.writable !== false,
550
+ value: found.value || null,
551
+ revision: found.revision,
552
+ base: {},
553
+ }
554
+ }, function (failure) {
555
+ return unavailableBecause(
556
+ 'describe() failed: ' + String((failure && failure.message) || failure),
557
+ )
558
+ })
559
+ }
560
+
561
+ return {
562
+ available: remote !== undefined && typeof remote.describe === 'function',
563
+ /**
564
+ * @param ops - `SettingsPathOp` list, e.g. `{op:'set', path, value}`.
565
+ * @returns a promise resolving to the refreshed view.
566
+ */
567
+ mutate: function (ops) {
568
+ if (!remote || typeof remote.mutate !== 'function') {
569
+ return Promise.reject(new Error('settings remote is unavailable in this deployment'))
570
+ }
571
+ // The revision is read fresh immediately before the write so a page
572
+ // left open across another edit reports a conflict instead of
573
+ // silently overwriting it. `read()` also refreshes `resolvedNs`.
574
+ return read().then(function (snap) {
575
+ if (!snap.available) {
576
+ throw new Error(snap.reason || 'this plugin has no settings entry in the active profile')
577
+ }
578
+ if (resolvedNs === null) {
579
+ throw new Error('this plugin did not resolve its settings entry id')
580
+ }
581
+ // The write answers with a RemoteResult envelope as well; unwrap
582
+ // it so a refusal surfaces as a real message instead of looking
583
+ // like a successful save.
584
+ return remote.mutate(resolvedNs, ops, snap.revision).then(function (answer) {
585
+ var result = unwrapRemote(answer)
586
+ if (result.ok === false) throw new Error(result.reason)
587
+ return result.value
588
+ })
589
+ })
590
+ },
591
+ /**
592
+ * @param listener - called after every host-side change to this entry.
593
+ * @returns an unsubscribe function.
594
+ */
595
+ subscribe: function (listener) {
596
+ // The event surface lives on `ctx.remote` itself, not on the
597
+ // namespace: a shipped settings page listens as
598
+ // `ctx.remote.$on('credentials/reference-updated', …)` while
599
+ // declaring `inject: ['remote', 'remote.<ns>']`.
600
+ var events = ctx && ctx.remote
601
+ if (!events || typeof events.$on !== 'function') return function () {}
602
+ try {
603
+ return events.$on('settings/document-updated', function (ns) {
604
+ // Match the resolved entry id, not the package-relative name.
605
+ if (ns === undefined || ns === resolvedNs || ns === NS) listener()
606
+ })
607
+ } catch (error) {
608
+ // A missing gateway listener must not take the page down.
609
+ return function () {}
610
+ }
611
+ },
612
+ read: read,
613
+ }
614
+ }
615
+
348
616
  /** @param props - the section owner props plus the plugin context. */
349
617
  function SettingsSection(props) {
350
618
  var ctx = props.ctx
351
- var scopeRef = useRef(null)
352
- if (scopeRef.current === null) scopeRef.current = ctx.settingsScope.bind({ namespace: NS })
353
- var scope = scopeRef.current
354
-
355
- var snapState = useState(function () {
356
- return scope.getSnapshot()
357
- })
619
+ // A host that can answer synchronously may hand the view over directly;
620
+ // against the real shell this is undefined and the page reads the
621
+ // settings Remote instead.
622
+ var seed = normaliseSnapshot(props.settingsSnapshot)
623
+ var sourceRef = useRef(null)
624
+ if (sourceRef.current === null) sourceRef.current = createSettingsSource(ctx)
625
+ var source = sourceRef.current
626
+
627
+ var snapState = useState(seed)
358
628
  var snap = snapState[0]
359
629
  var setSnap = snapState[1]
360
630
 
@@ -372,23 +642,39 @@
372
642
 
373
643
  useEffect(
374
644
  function () {
375
- return scope.subscribe(function () {
376
- setSnap(scope.getSnapshot())
377
- })
645
+ var alive = true
646
+ function refresh() {
647
+ source.read().then(
648
+ function (next) {
649
+ if (alive) setSnap(next)
650
+ },
651
+ function (failure) {
652
+ if (alive) setError(String((failure && failure.message) || failure))
653
+ },
654
+ )
655
+ }
656
+ // A supplied snapshot is already the current view; only the Remote
657
+ // path needs the initial round trip.
658
+ if (seed === null) refresh()
659
+ var off = source.subscribe(refresh)
660
+ return function () {
661
+ alive = false
662
+ off()
663
+ }
378
664
  },
379
- [scope],
665
+ [source],
380
666
  )
381
667
 
382
668
  // Re-seed the editor from every accepted snapshot, but never over an
383
669
  // unsaved edit: the user's typing outranks a background refresh.
384
- var revision = snap.revision
670
+ var revision = snap ? snap.revision : undefined
385
671
  useEffect(
386
672
  function () {
387
- if (dirtyRef.current) return
673
+ if (dirtyRef.current || snap === null) return
388
674
  var value = snap.value || {}
389
675
  setDraft({ hooks: toLines(value.hooks), serialHooks: toLines(value.serialHooks), log: value.log || '' })
390
676
  },
391
- [revision, snap.value],
677
+ [revision, snap],
392
678
  )
393
679
 
394
680
  /** @param fieldName - settings field to edit; @param next - its new textarea content. */
@@ -417,7 +703,7 @@
417
703
  }
418
704
 
419
705
  function save() {
420
- var value = snap.value || {}
706
+ var value = (snap && snap.value) || {}
421
707
  var ops = []
422
708
  if (!sameLines(value.hooks, draft.hooks)) {
423
709
  ops.push({ op: 'set', path: ['hooks'], value: fromLines(draft.hooks) })
@@ -429,13 +715,13 @@
429
715
  ops.push({ op: 'set', path: ['log'], value: draft.log })
430
716
  }
431
717
  if (ops.length === 0) return
432
- settle(scope.mutate(ops))
718
+ settle(source.mutate(ops))
433
719
  }
434
720
 
435
- /** Send every field back to the composition layer (`base`). */
721
+ /** Send every field back to the composition default (the schema default). */
436
722
  function reset() {
437
723
  settle(
438
- scope.mutate([
724
+ source.mutate([
439
725
  { op: 'unset', path: ['hooks'] },
440
726
  { op: 'unset', path: ['serialHooks'] },
441
727
  { op: 'unset', path: ['log'] },
@@ -443,7 +729,9 @@
443
729
  )
444
730
  }
445
731
 
446
- var writable = snap.writable
732
+ // Unknown until the descriptor arrives; a read-only provider disables
733
+ // the whole form rather than letting the user type into a dead editor.
734
+ var writable = snap !== null && snap.writable
447
735
 
448
736
  /**
449
737
  * @param label - field caption.
@@ -496,37 +784,44 @@
496
784
  )
497
785
  }
498
786
 
499
- var dirty =
500
- !sameLines(snap.value && snap.value.hooks, draft.hooks) ||
501
- !sameLines(snap.value && snap.value.serialHooks, draft.serialHooks) ||
502
- ((snap.value && snap.value.log) || '') !== draft.log
787
+ var current = (snap && snap.value) || {}
503
788
 
504
- var base = snap.base || {}
789
+ var dirty =
790
+ !sameLines(current.hooks, draft.hooks) ||
791
+ !sameLines(current.serialHooks, draft.serialHooks) ||
792
+ (current.log || '') !== draft.log
793
+
794
+ // Three states: the descriptor has not arrived yet, this deployment has
795
+ // no settings entry for the plugin, or the form is ready.
796
+ var body
797
+ if (snap === null) {
798
+ body = e('p', null, t('loading'))
799
+ } else if (!snap.available) {
800
+ // Print the concrete cause next to the headline: the generic sentence
801
+ // alone could not tell a wiring fault from an unmounted host half.
802
+ body = e(
803
+ 'div',
804
+ null,
805
+ e('p', null, t('unavailable')),
806
+ snap.reason ? e('p', { className: 'ho-note' }, String(snap.reason)) : null,
807
+ )
808
+ } else {
809
+ body = e(
810
+ 'div',
811
+ { className: CLASS },
812
+ renderListField(t('fieldHooks'), 'hooks', draft.hooks, 8),
813
+ renderListField(t('fieldSerialHooks'), 'serialHooks', draft.serialHooks, 3),
814
+ renderLineField(t('fieldLog'), 'log', draft.log),
815
+ )
816
+ }
505
817
 
506
818
  return e(
507
819
  'div',
508
820
  { className: CLASS },
509
821
  e('h3', null, t('nav')),
510
822
  e('p', null, t('intro')),
511
- snap.status === 'loading'
512
- ? e('p', null, t('loading'))
513
- : snap.status === 'unavailable'
514
- ? e('p', null, t('unavailable'))
515
- : e(
516
- 'div',
517
- { className: CLASS },
518
- renderListField(t('fieldHooks'), 'hooks', draft.hooks, 8),
519
- renderListField(t('fieldSerialHooks'), 'serialHooks', draft.serialHooks, 3),
520
- renderLineField(t('fieldLog'), 'log', draft.log),
521
- ),
522
- e(
523
- 'p',
524
- { className: 'ho-note' },
525
- t('note', {
526
- hooks: String(Array.isArray(base.hooks) ? base.hooks.length : 0),
527
- serial: String(Array.isArray(base.serialHooks) ? base.serialHooks.length : 0),
528
- }),
529
- ),
823
+ body,
824
+ e('p', { className: 'ho-note' }, t('note')),
530
825
  error ? saveFailedStatus(error) : null,
531
826
  e(
532
827
  'div',
@@ -546,7 +841,7 @@
546
841
  }
547
842
 
548
843
  /**
549
- * @param message - the failure text reported by `scope.mutate`.
844
+ * @param message - the failure text reported by the settings write.
550
845
  * @returns the save-failure status: the shell's StateDot + Tag when the kit is
551
846
  * there, the plugin's own chip when it is not.
552
847
  */
@@ -560,11 +855,12 @@
560
855
  }
561
856
 
562
857
  // ── plugin ─────────────────────────────────────────────────────────────
563
- // 'locale' is a required service: without the locale plugin in the
564
- // composition this plugin does not activate at all, rather than
565
- // registering a page with no copy. bindLocale still degrades for a
566
- // partial context (see above).
567
- var inject = ['slots', 'settingsScope', 'locale']
858
+ // 'remote.settings' is a required service: the settings Remote namespace
859
+ // is how a 0.1.7 page reads and writes its entry. Without it the page is
860
+ // not registered at all, rather than rendering an editor that cannot save.
861
+ // 0.1.6 exposed `settingsScope` instead; that service no longer exists, so
862
+ // naming it here would leave this plugin permanently pending.
863
+ var inject = ['slots', 'remote', 'remote.settings', 'locale']
568
864
 
569
865
  /** @param ctx - the client plugin context. */
570
866
  function apply(ctx) {
@@ -599,6 +895,8 @@
599
895
  exports.apply = apply
600
896
  exports.inject = inject
601
897
  exports.NS = NS
898
+ exports.pickEntry = pickEntry
899
+ exports.unwrapRemote = unwrapRemote
602
900
  exports.CLASS = CLASS
603
901
  exports.CSS = CSS
604
902
  exports.ZH = zh
package/lib/index.d.ts CHANGED
@@ -4,6 +4,7 @@ import { a as Phase, i as HookOrderingLogConfig, n as HookControlError } from ".
4
4
  import { n as HookOrdering, r as HookOrderingConfig, t as HookEntry } from "./waterfall-_5HkptkS.js";
5
5
  import { n as SerialHookOrdering, r as SerialHookOrderingConfig, t as SerialHookEntry } from "./serial-D8ZJCBKL.js";
6
6
  import { Context } from "@deepseek-ai/cordis";
7
+ import * as _deepseek_ai_schemastery0 from "@deepseek-ai/schemastery";
7
8
 
8
9
  //#region src/dsh.d.ts
9
10
 
@@ -75,8 +76,23 @@ declare const DEFAULT_WATERFALL_HOOKS: readonly string[];
75
76
  declare const DEFAULT_SYNC_RETURN_HOOKS: readonly string[];
76
77
  /** The dsh serial hook controlled by default. */
77
78
  declare const DEFAULT_SERIAL_HOOKS: readonly string[];
78
- /** Plugin config. */
79
- interface Config {
79
+ /**
80
+ * This plugin's Cordis Config schema.
81
+ *
82
+ * Since DSH 0.1.7 this *is* the Settings page: the fields are `.volatile()`, so
83
+ * the settings service projects exactly them into an editable form and commits
84
+ * an edit into the live reference rather than remounting the plugin. The
85
+ * defaults are the real hook sets below, so an entry whose `config:` key is
86
+ * empty resolves to them.
87
+ */
88
+ declare const Config: _deepseek_ai_schemastery0.default;
89
+ /**
90
+ * Plugin config as a plain value.
91
+ *
92
+ * A loader-resolved config carries the volatile fields as references instead of
93
+ * plain arrays, so this describes the *plain* shape callers and tests construct.
94
+ */
95
+ interface ConfigShape {
80
96
  /**
81
97
  * Waterfall hooks to control. Defaults to {@link DEFAULT_WATERFALL_HOOKS}.
82
98
  * Pass `[]` to disable the waterfall service entirely.
@@ -92,10 +108,16 @@ interface Config {
92
108
  * therefore refuse participants. Defaults to {@link DEFAULT_SYNC_RETURN_HOOKS}.
93
109
  * Pass `[]` — or a set without a given hook — to opt in to ordering it, once
94
110
  * the host awaits that dispatch.
111
+ *
112
+ * Composition-only: this describes the HOST's dispatch, not a user
113
+ * preference, so it is intentionally not a field of {@link Config} and never
114
+ * appears on the Settings page.
95
115
  */
96
116
  syncReturnHooks?: readonly string[];
97
117
  /** When set, the constraint DAG (JSON) is logged to this file on every change. */
98
118
  log?: string;
119
+ /** Overrides the loader entry id; only non-loader harnesses need it. */
120
+ entryId?: string;
99
121
  }
100
122
  /**
101
123
  * Mount {@link HookOrdering} and/or {@link SerialHookOrdering} and control the
@@ -107,15 +129,23 @@ interface Config {
107
129
  * package's own `cordis.patch.yml` is written exactly that way — every option
108
130
  * commented out — so a null config must mean "all defaults", not a crash.
109
131
  *
110
- * The row is the *composition* layer of the settings namespace: the Settings
111
- * page edits a user layer over it, and a namespace the user has not touched
112
- * resolves back to this row (or to the built-in defaults). Without a settings
113
- * provider — plain Cordis — the row is the whole answer.
132
+ * Since DSH 0.1.7 there is no second "user layer": the row's `config` IS the
133
+ * editable settings, because Cordis validates it against {@link Config} and the
134
+ * settings service projects that schema. A volatile field arrives here as a
135
+ * reference rather than a plain array, which is why every read goes through
136
+ * {@link readSetting} — that also keeps a plain Cordis mount (and an older
137
+ * harness) working, where the fields are plain values.
138
+ *
139
+ * `hooks`/`serialHooks` are read once, at apply time. An edit from the Settings
140
+ * page therefore needs a plugin reload to take effect — the same restart
141
+ * semantics this namespace declared before 0.1.7 (`applies: 'restart'`), since
142
+ * changing the controlled set means adding or removing live hook brackets and
143
+ * the coordinator has no public "release one hook" operation.
114
144
  *
115
145
  * @param ctx - the Cordis context.
116
146
  * @param config - which hooks to control and an optional DAG `log` file; null means all defaults.
117
147
  */
118
- declare function apply(ctx: Context, config?: Config | null): void;
148
+ declare function apply(ctx: Context, config?: ConfigShape | null): void;
119
149
  //#endregion
120
- export { type Config, DEFAULT_SERIAL_HOOKS, DEFAULT_SYNC_RETURN_HOOKS, DEFAULT_WATERFALL_HOOKS, type Dag, type DagEdge, type DagSection, type DagSectionGraph, DuplicateNameError, HookControlError, type HookEntry, HookOrdering, type HookOrderingConfig, type HookOrderingLogConfig, type Orderable, OrderingCycleError, type Phase, type SerialHookEntry, SerialHookOrdering, type SerialHookOrderingConfig, apply, buildDag, inject, name, topoSort };
150
+ export { Config, type ConfigShape, DEFAULT_SERIAL_HOOKS, DEFAULT_SYNC_RETURN_HOOKS, DEFAULT_WATERFALL_HOOKS, type Dag, type DagEdge, type DagSection, type DagSectionGraph, DuplicateNameError, HookControlError, type HookEntry, HookOrdering, type HookOrderingConfig, type HookOrderingLogConfig, type Orderable, OrderingCycleError, type Phase, type SerialHookEntry, SerialHookOrdering, type SerialHookOrderingConfig, apply, buildDag, inject, name, topoSort };
121
151
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/dsh.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;cAuBa,IAAA;;;;;;;;;;;;;;;;;;cAmBA;;;;;;;;;;;;;;;;;;;cAoBA;;;;;;;;;;;;;;;;;;;;;;;;;;;cAuCA;;cAOA;;UAGI,MAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAwCD,KAAA,MAAW,kBAAiB"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/dsh.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;cAuBa,IAAA;;;;;;;;;;;;;;;;;;cAmBA;;;;;;;;;;;;;;;;;;;cAoBA;;;;;;;;;;;;;;;;;;;;;;;;;;;cAuCA;;cAOA;;;;;;;;;;cAWA,QAA2E,yBAAA,CAArE;;;;;;;UAWF,WAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAsDD,KAAA,MAAW,kBAAiB"}
package/lib/index.js CHANGED
@@ -6,51 +6,43 @@ import { t as SerialHookOrdering } from "./serial-Cu7usHjI.js";
6
6
  import Schema from "@deepseek-ai/schemastery";
7
7
 
8
8
  //#region src/settings.ts
9
- /** Namespace name — dsh requires a lowercase hyphenated identifier. */
10
- const SETTINGS_NS = "hooks-ordering";
11
9
  /**
12
- * Schema for {@link SETTINGS_NS}.
10
+ * Build the Config schema from the caller's default hook lists.
13
11
  *
14
- * Every field defaults to its "off" value. The *meaningful* defaults — the
15
- * hooks this plugin controls when nothing overrides them — are supplied by the
16
- * caller as the composition `base` layer (see {@link resolveSettings}), so a
17
- * namespace the user has never touched still resolves to a complete object.
12
+ * Every field is `.volatile()`, so every field is user-editable from the
13
+ * Settings page and an edit is committed into the live reference instead of
14
+ * remounting the plugin. `log` is volatile too, because a non-volatile field
15
+ * would not appear in the form at all.
16
+ *
17
+ * @param defaultWaterfallHooks - waterfall hooks controlled when unconfigured.
18
+ * @param defaultSerialHooks - serial hooks controlled when unconfigured.
19
+ * @returns a callable schemastery schema, which is what Cordis resolves through.
18
20
  */
19
- const HooksOrderingSettingsSchema = Schema.object({
20
- hooks: Schema.array(Schema.string()).default([]),
21
- serialHooks: Schema.array(Schema.string()).default([]),
22
- log: Schema.string().default("")
23
- });
21
+ function hooksOrderingConfig(defaultWaterfallHooks, defaultSerialHooks) {
22
+ return Schema.object({
23
+ hooks: Schema.array(Schema.string()).default([...defaultWaterfallHooks]).volatile(),
24
+ serialHooks: Schema.array(Schema.string()).default([...defaultSerialHooks]).volatile(),
25
+ log: Schema.string().default("").volatile()
26
+ });
27
+ }
24
28
  /**
25
- * Register the settings namespace and read its resolved value.
26
- *
27
- * `base` is the composition layer — what the row (or the built-in defaults)
28
- * asks for — so the user layer edits *over* it and a reset returns to it rather
29
- * than to an empty form.
30
- *
31
- * `applies: 'restart'` is deliberate. Changing `hooks`/`serialHooks` means
32
- * installing or removing bracket listeners on live hooks, and the coordinator
33
- * has no public "release one hook" operation; a restart applies the change
34
- * cleanly instead of re-wiring the dispatch chain mid-flight.
35
- *
36
- * @param ctx - the Cordis context to look the service up on.
37
- * @param base - the composition layer to register as the base value.
38
- * @returns the resolved settings, or `undefined` when the provider is missing
39
- * (which `inject: ['settings']` rules out in dsh — the guard is defensive) or
40
- * registration failed — the caller then uses `base`.
29
+ * Read one resolved field, unwrapping a volatile reference when the loader
30
+ * supplied one and falling back to a default when the field is absent.
31
+ *
32
+ * A loader-provided Config carries volatile fields as references (`.get()`);
33
+ * a plain Cordis mount, or an older harness, passes plain values. Both must
34
+ * work, and an absent field must not be confused with an explicit empty one.
35
+ *
36
+ * @param config - the resolved plugin config (possibly `null`).
37
+ * @param field - the field to read.
38
+ * @param fallback - value to use when the field is absent.
39
+ * @returns the field's current value, or `fallback`.
41
40
  */
42
- function resolveSettings(ctx, base) {
43
- const settings = ctx.get("settings");
44
- if (settings === void 0) return void 0;
45
- try {
46
- return settings.register(SETTINGS_NS, HooksOrderingSettingsSchema, {
47
- base,
48
- applies: "restart"
49
- }).get();
50
- } catch (error) {
51
- console.warn("hooks-ordering: settings registration failed; falling back to the composition config:", error);
52
- return;
53
- }
41
+ function readSetting(config, field, fallback) {
42
+ const value = config?.[field];
43
+ if (value === void 0 || value === null) return fallback;
44
+ if (typeof value.get === "function") return value.get() ?? fallback;
45
+ return value;
54
46
  }
55
47
 
56
48
  //#endregion
@@ -139,6 +131,16 @@ const DEFAULT_SYNC_RETURN_HOOKS = [
139
131
  /** The dsh serial hook controlled by default. */
140
132
  const DEFAULT_SERIAL_HOOKS = ["agent/turn-stopping"];
141
133
  /**
134
+ * This plugin's Cordis Config schema.
135
+ *
136
+ * Since DSH 0.1.7 this *is* the Settings page: the fields are `.volatile()`, so
137
+ * the settings service projects exactly them into an editable form and commits
138
+ * an edit into the live reference rather than remounting the plugin. The
139
+ * defaults are the real hook sets below, so an entry whose `config:` key is
140
+ * empty resolves to them.
141
+ */
142
+ const Config = hooksOrderingConfig(DEFAULT_WATERFALL_HOOKS, DEFAULT_SERIAL_HOOKS);
143
+ /**
142
144
  * Mount {@link HookOrdering} and/or {@link SerialHookOrdering} and control the
143
145
  * configured hooks once the services are active.
144
146
  *
@@ -148,22 +150,29 @@ const DEFAULT_SERIAL_HOOKS = ["agent/turn-stopping"];
148
150
  * package's own `cordis.patch.yml` is written exactly that way — every option
149
151
  * commented out — so a null config must mean "all defaults", not a crash.
150
152
  *
151
- * The row is the *composition* layer of the settings namespace: the Settings
152
- * page edits a user layer over it, and a namespace the user has not touched
153
- * resolves back to this row (or to the built-in defaults). Without a settings
154
- * provider — plain Cordis — the row is the whole answer.
153
+ * Since DSH 0.1.7 there is no second "user layer": the row's `config` IS the
154
+ * editable settings, because Cordis validates it against {@link Config} and the
155
+ * settings service projects that schema. A volatile field arrives here as a
156
+ * reference rather than a plain array, which is why every read goes through
157
+ * {@link readSetting} — that also keeps a plain Cordis mount (and an older
158
+ * harness) working, where the fields are plain values.
159
+ *
160
+ * `hooks`/`serialHooks` are read once, at apply time. An edit from the Settings
161
+ * page therefore needs a plugin reload to take effect — the same restart
162
+ * semantics this namespace declared before 0.1.7 (`applies: 'restart'`), since
163
+ * changing the controlled set means adding or removing live hook brackets and
164
+ * the coordinator has no public "release one hook" operation.
155
165
  *
156
166
  * @param ctx - the Cordis context.
157
167
  * @param config - which hooks to control and an optional DAG `log` file; null means all defaults.
158
168
  */
159
169
  function apply(ctx, config = {}) {
160
170
  const row = config ?? {};
161
- const base = {
162
- hooks: row.hooks ?? DEFAULT_WATERFALL_HOOKS,
163
- serialHooks: row.serialHooks ?? DEFAULT_SERIAL_HOOKS,
164
- log: row.log ?? ""
171
+ const { hooks, serialHooks, log } = {
172
+ hooks: readSetting(row, "hooks", DEFAULT_WATERFALL_HOOKS),
173
+ serialHooks: readSetting(row, "serialHooks", DEFAULT_SERIAL_HOOKS),
174
+ log: readSetting(row, "log", "")
165
175
  };
166
- const { hooks, serialHooks, log } = resolveSettings(ctx, base) ?? base;
167
176
  const serviceConfig = log === "" ? {} : { log };
168
177
  const syncReturnHooks = row.syncReturnHooks ?? DEFAULT_SYNC_RETURN_HOOKS;
169
178
  const deps = [];
@@ -186,5 +195,5 @@ function apply(ctx, config = {}) {
186
195
  }
187
196
 
188
197
  //#endregion
189
- export { DEFAULT_SERIAL_HOOKS, DEFAULT_SYNC_RETURN_HOOKS, DEFAULT_WATERFALL_HOOKS, DuplicateNameError, HookControlError, HookOrdering, OrderingCycleError, SerialHookOrdering, apply, buildDag, inject, name, topoSort };
198
+ export { Config, DEFAULT_SERIAL_HOOKS, DEFAULT_SYNC_RETURN_HOOKS, DEFAULT_WATERFALL_HOOKS, DuplicateNameError, HookControlError, HookOrdering, OrderingCycleError, SerialHookOrdering, apply, buildDag, inject, name, topoSort };
190
199
  //# sourceMappingURL=index.js.map
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["DEFAULT_WATERFALL_HOOKS: readonly string[]","DEFAULT_SYNC_RETURN_HOOKS: readonly string[]","DEFAULT_SERIAL_HOOKS: readonly string[]","base: HooksOrderingSettings","deps: string[]","HookOrdering"],"sources":["../src/settings.ts","../src/dsh.ts"],"sourcesContent":["/**\n * The dsh settings namespace for this plugin: the three fields a user can edit\n * from the Settings page instead of a `cordis.patch.yml` row.\n *\n * Two constraints are load-bearing and easy to get wrong:\n *\n * - The schema handed to `settings.register` must be a **callable** schemastery\n * schema. dsh resolves a namespace by calling `schema(merged)` and reads\n * `schema.toJSON()` for the form, so a plain object throws\n * `schema is not a function` — during plugin assembly, which takes the whole\n * profile down rather than failing one form.\n * - That is also why registration is wrapped in `try/catch` here: an optional\n * settings form must never be able to stop the harness from booting.\n *\n * @module dsh-hooks-ordering/settings\n */\n\nimport type { Context } from '@deepseek-ai/cordis'\nimport Schema from '@deepseek-ai/schemastery'\n\n/** The three fields this plugin exposes for configuration. */\nexport interface HooksOrderingSettings {\n /** Waterfall hooks to control; `[]` disables the waterfall service entirely. */\n readonly hooks: readonly string[]\n /** Serial hooks to control; `[]` disables the serial service entirely. */\n readonly serialHooks: readonly string[]\n /** Constraint-DAG log file; the empty string means \"do not log\". */\n readonly log: string\n}\n\n/** Namespace name — dsh requires a lowercase hyphenated identifier. */\nexport const SETTINGS_NS = 'hooks-ordering'\n\n/**\n * Schema for {@link SETTINGS_NS}.\n *\n * Every field defaults to its \"off\" value. The *meaningful* defaults — the\n * hooks this plugin controls when nothing overrides them — are supplied by the\n * caller as the composition `base` layer (see {@link resolveSettings}), so a\n * namespace the user has never touched still resolves to a complete object.\n */\nexport const HooksOrderingSettingsSchema = Schema.object({\n hooks: Schema.array(Schema.string()).default([]),\n serialHooks: Schema.array(Schema.string()).default([]),\n log: Schema.string().default(''),\n})\n\n/**\n * The slice of dsh's `settings` service this plugin uses.\n *\n * Structural rather than imported from `@deepseek-ai/dsh-settings`: it describes\n * exactly the contract this plugin relies on without adding a build-time\n * dependency on a dsh package, whose internals are still 0.1.x-rc.\n */\nexport interface SettingsServiceLike {\n /**\n * @param ns - the namespace to register; must be a lowercase hyphenated identifier.\n * @param schema - a callable schemastery schema, not a plain object.\n * @param options - `base` is the composition layer, `applies` says whether an\n * edit takes effect live or needs a restart.\n * @returns a scope whose `get()` is the resolved value (schema defaults, then `base`, then the user layer).\n */\n register(\n ns: string,\n schema: unknown,\n options?: { readonly base?: unknown; readonly applies?: 'live' | 'restart' },\n ): { get(): HooksOrderingSettings }\n}\n\n/**\n * Register the settings namespace and read its resolved value.\n *\n * `base` is the composition layer — what the row (or the built-in defaults)\n * asks for — so the user layer edits *over* it and a reset returns to it rather\n * than to an empty form.\n *\n * `applies: 'restart'` is deliberate. Changing `hooks`/`serialHooks` means\n * installing or removing bracket listeners on live hooks, and the coordinator\n * has no public \"release one hook\" operation; a restart applies the change\n * cleanly instead of re-wiring the dispatch chain mid-flight.\n *\n * @param ctx - the Cordis context to look the service up on.\n * @param base - the composition layer to register as the base value.\n * @returns the resolved settings, or `undefined` when the provider is missing\n * (which `inject: ['settings']` rules out in dsh — the guard is defensive) or\n * registration failed — the caller then uses `base`.\n */\nexport function resolveSettings(ctx: Context, base: HooksOrderingSettings): HooksOrderingSettings | undefined {\n const settings = ctx.get('settings') as SettingsServiceLike | undefined\n if (settings === undefined) return undefined\n try {\n return settings.register(SETTINGS_NS, HooksOrderingSettingsSchema, { base, applies: 'restart' }).get()\n } catch (error) {\n console.warn('hooks-ordering: settings registration failed; falling back to the composition config:', error)\n return undefined\n }\n}\n","/**\n * The DeepSeek-Harness layer: a dsh plugin that mounts the ordering services\n * and takes control of the real dsh hooks that multiple independent packages\n * contribute to, so a profile can opt into deterministic ordering with one row.\n *\n * dsh (deepseek-harness) ships waterfall hooks such as `agent/pre-step`\n * (subscribed by a dozen+ independent packages), `tools/post-execute`,\n * `llm/stream`, and `system-prompt/assemble`, plus the serial hook\n * `agent/turn-stopping`. Their relative listener order is load-bearing yet\n * today decided only by binary `prepend` and registration timing. This plugin\n * controls those hooks up front; controlling an empty hook is a transparent\n * pass-through, so nothing changes until participants register with\n * `before`/`after`.\n *\n * @module dsh-hooks-ordering/dsh\n */\n\nimport type { Context } from '@deepseek-ai/cordis'\nimport HookOrdering from './waterfall.ts'\nimport { SerialHookOrdering } from './serial.ts'\nimport { type HooksOrderingSettings, resolveSettings } from './settings.ts'\n\n/** Cordis plugin name used by loader diagnostics. */\nexport const name = 'hooks-ordering'\n\n/**\n * `settings` is a **hard** dependency, and it has to be declared rather than\n * probed.\n *\n * cordis activates a plugin as soon as the services it declares exist, and\n * `ctx.get('settings')` reads the service store *without* creating that\n * requirement. Without this line the dsh layer loads in the first wave — before\n * the host has provided `settings` — the probe returns `undefined`, and the\n * settings namespace is never registered. Silently: no form, no error.\n *\n * Declaring it makes cordis wait, which also lands this plugin's prepended\n * brackets later in the boot, exactly where the ordering guarantee wants them.\n *\n * This entry targets dsh only — the hook names below are dsh's, and dsh always\n * provides `settings`, so the dependency costs nothing here. `/waterfall` and\n * `/serial` are the entries that carry no host-service requirement.\n */\nexport const inject = ['settings']\n\n/**\n * The dsh waterfall hooks this plugin controls by default — the ones multiple\n * independent packages contribute to, where relative order matters. Controlling\n * a hook with no registered participants is a no-op pass-through.\n *\n * Every name here was checked against the events dsh actually declares: each is\n * a `@mode waterfall` event in the installed build, and each is dispatched with\n * `await`, so the caller already handles a Promise and the bracket's async\n * phases cannot change the hook's return type. `tools/code-dispatch-log` used\n * to sit in this list and no dsh package declares it at all — controlling a\n * name nothing dispatches \"succeeds\" and then does nothing forever, which is\n * exactly the dead configuration this list must not carry.\n *\n * The other admission rule is {@link DEFAULT_SYNC_RETURN_HOOKS}: a hook whose\n * value the caller consumes without awaiting cannot carry ordered participants,\n * so controlling it by default would install a bracket nothing may ever\n * register into — the same dead configuration by a different route.\n */\nexport const DEFAULT_WATERFALL_HOOKS: readonly string[] = [\n 'agent/pre-step',\n 'agent/request',\n 'agent/request-error',\n 'system-prompt/assemble',\n 'tools/pre-execute',\n 'tools/execute',\n 'tools/post-execute',\n 'fs/write-intent',\n 'fs/edit-intent',\n 'approval/request',\n]\n\n/**\n * dsh hooks whose dispatch return value is consumed by the caller without\n * awaiting, and which therefore cannot carry ordered participants. Verified\n * against the installed build:\n *\n * - `llm/stream` — `dsh-llm` dispatches it as\n * `return this.ctx.waterfall(this, \"llm/stream\", options, …)` (no await), and\n * `dsh-session-title-llm` iterates the result with\n * `for await (const chunk of ctx.llm.stream(options))`. A Promise there throws\n * \"not async iterable\". It is a genuine multi-contributor hook (dsh-agent-loop,\n * dsh-llm's own invariant, dsh-session-checkpoint-policy, dsh-session-title),\n * so it is a real loss — but ordering it requires dsh to await the dispatch,\n * not a plugin-side workaround.\n * - `session-telemetry/record` — `dsh-session-telemetry` returns the record\n * straight out of the waterfall and hands it to the backend, so a Promise\n * would be emitted as a record: silent corruption, no error anywhere.\n * - `compaction/summary-error` — `dsh-compaction-basic` dispatches it as\n * `recover: (…) => this.ctx.waterfall(this, \"compaction/summary-error\", …, () => false)`\n * (no await) and consumes the boolean synchronously in\n * `if (!dependencies.recover(error, agent, prepared.shadowedSeqs, signal)) throw error`.\n * A Promise is always truthy, so `!recover(…)` is always false and every\n * summarizer failure is swallowed instead of rethrown — the compaction then\n * proceeds as if recovery had succeeded.\n *\n * Override per profile with `syncReturnHooks` once the host awaits one of them.\n */\nexport const DEFAULT_SYNC_RETURN_HOOKS: readonly string[] = [\n 'llm/stream',\n 'session-telemetry/record',\n 'compaction/summary-error',\n]\n\n/** The dsh serial hook controlled by default. */\nexport const DEFAULT_SERIAL_HOOKS: readonly string[] = ['agent/turn-stopping']\n\n/** Plugin config. */\nexport interface Config {\n /**\n * Waterfall hooks to control. Defaults to {@link DEFAULT_WATERFALL_HOOKS}.\n * Pass `[]` to disable the waterfall service entirely.\n */\n hooks?: readonly string[]\n /**\n * Serial hooks to control. Defaults to {@link DEFAULT_SERIAL_HOOKS}.\n * Pass `[]` to disable the serial service entirely.\n */\n serialHooks?: readonly string[]\n /**\n * Hooks whose return value the host consumes without awaiting, and which must\n * therefore refuse participants. Defaults to {@link DEFAULT_SYNC_RETURN_HOOKS}.\n * Pass `[]` — or a set without a given hook — to opt in to ordering it, once\n * the host awaits that dispatch.\n */\n syncReturnHooks?: readonly string[]\n /** When set, the constraint DAG (JSON) is logged to this file on every change. */\n log?: string\n}\n\n/**\n * Mount {@link HookOrdering} and/or {@link SerialHookOrdering} and control the\n * configured hooks once the services are active.\n *\n * `config` may be `null`, and that is the ordinary case rather than an edge\n * case: a loader row whose `config:` key is followed only by comments parses as\n * YAML null, and a default parameter covers `undefined` but not `null`. This\n * package's own `cordis.patch.yml` is written exactly that way — every option\n * commented out — so a null config must mean \"all defaults\", not a crash.\n *\n * The row is the *composition* layer of the settings namespace: the Settings\n * page edits a user layer over it, and a namespace the user has not touched\n * resolves back to this row (or to the built-in defaults). Without a settings\n * provider — plain Cordis — the row is the whole answer.\n *\n * @param ctx - the Cordis context.\n * @param config - which hooks to control and an optional DAG `log` file; null means all defaults.\n */\nexport function apply(ctx: Context, config: Config | null = {}): void {\n const row = config ?? {}\n const base: HooksOrderingSettings = {\n hooks: row.hooks ?? DEFAULT_WATERFALL_HOOKS,\n serialHooks: row.serialHooks ?? DEFAULT_SERIAL_HOOKS,\n log: row.log ?? '',\n }\n const { hooks, serialHooks, log } = resolveSettings(ctx, base) ?? base\n const serviceConfig = log === '' ? {} : { log }\n // Not part of the settings namespace: the sync-return list describes the\n // HOST's dispatch, not a user preference, so it is composition (row) only.\n const syncReturnHooks = row.syncReturnHooks ?? DEFAULT_SYNC_RETURN_HOOKS\n\n const deps: string[] = []\n if (hooks.length > 0) {\n ctx.plugin(HookOrdering, { ...serviceConfig, syncReturnHooks })\n deps.push('hooksOrdering')\n }\n if (serialHooks.length > 0) {\n ctx.plugin(SerialHookOrdering, serviceConfig)\n deps.push('serialHooksOrdering')\n }\n if (deps.length === 0) return\n\n // The services activate asynchronously; control the hooks once they exist.\n ctx.inject(deps, (ready) => {\n for (const hook of hooks) ready.hooksOrdering.control(hook)\n for (const hook of serialHooks) ready.serialHooksOrdering.control(hook)\n })\n}\n"],"mappings":";;;;;;;;;AA+BA,MAAa,cAAc;;;;;;;;;AAU3B,MAAa,8BAA8B,OAAO,OAAO;CACvD,OAAO,OAAO,MAAM,OAAO,QAAQ,CAAC,CAAC,QAAQ,EAAE,CAAC;CAChD,aAAa,OAAO,MAAM,OAAO,QAAQ,CAAC,CAAC,QAAQ,EAAE,CAAC;CACtD,KAAK,OAAO,QAAQ,CAAC,QAAQ,GAAG;CACjC,CAAC;;;;;;;;;;;;;;;;;;;AA0CF,SAAgB,gBAAgB,KAAc,MAAgE;CAC5G,MAAM,WAAW,IAAI,IAAI,WAAW;AACpC,KAAI,aAAa,OAAW,QAAO;AACnC,KAAI;AACF,SAAO,SAAS,SAAS,aAAa,6BAA6B;GAAE;GAAM,SAAS;GAAW,CAAC,CAAC,KAAK;UAC/F,OAAO;AACd,UAAQ,KAAK,yFAAyF,MAAM;AAC5G;;;;;;;ACvEJ,MAAa,OAAO;;;;;;;;;;;;;;;;;;AAmBpB,MAAa,SAAS,CAAC,WAAW;;;;;;;;;;;;;;;;;;;AAoBlC,MAAaA,0BAA6C;CACxD;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BD,MAAaC,4BAA+C;CAC1D;CACA;CACA;CACD;;AAGD,MAAaC,uBAA0C,CAAC,sBAAsB;;;;;;;;;;;;;;;;;;;AA2C9E,SAAgB,MAAM,KAAc,SAAwB,EAAE,EAAQ;CACpE,MAAM,MAAM,UAAU,EAAE;CACxB,MAAMC,OAA8B;EAClC,OAAO,IAAI,SAAS;EACpB,aAAa,IAAI,eAAe;EAChC,KAAK,IAAI,OAAO;EACjB;CACD,MAAM,EAAE,OAAO,aAAa,QAAQ,gBAAgB,KAAK,KAAK,IAAI;CAClE,MAAM,gBAAgB,QAAQ,KAAK,EAAE,GAAG,EAAE,KAAK;CAG/C,MAAM,kBAAkB,IAAI,mBAAmB;CAE/C,MAAMC,OAAiB,EAAE;AACzB,KAAI,MAAM,SAAS,GAAG;AACpB,MAAI,OAAOC,mBAAc;GAAE,GAAG;GAAe;GAAiB,CAAC;AAC/D,OAAK,KAAK,gBAAgB;;AAE5B,KAAI,YAAY,SAAS,GAAG;AAC1B,MAAI,OAAO,oBAAoB,cAAc;AAC7C,OAAK,KAAK,sBAAsB;;AAElC,KAAI,KAAK,WAAW,EAAG;AAGvB,KAAI,OAAO,OAAO,UAAU;AAC1B,OAAK,MAAM,QAAQ,MAAO,OAAM,cAAc,QAAQ,KAAK;AAC3D,OAAK,MAAM,QAAQ,YAAa,OAAM,oBAAoB,QAAQ,KAAK;GACvE"}
1
+ {"version":3,"file":"index.js","names":["DEFAULT_WATERFALL_HOOKS: readonly string[]","DEFAULT_SYNC_RETURN_HOOKS: readonly string[]","DEFAULT_SERIAL_HOOKS: readonly string[]","deps: string[]","HookOrdering"],"sources":["../src/settings.ts","../src/dsh.ts"],"sourcesContent":["/**\n * This plugin's Cordis Config — and, since DSH 0.1.7, its Settings page.\n *\n * 0.1.7 removed `ctx.settings.register(ns, schema)`. Settings are now *projected\n * from each entry's own Config*: the loader validates the row's `config` against\n * this schema, resolves the defaults, and the settings service renders exactly\n * the nodes marked `.volatile()` as an editable form. Three consequences are\n * load-bearing here:\n *\n * - The defaults below are the *real* defaults, not \"off\" placeholders. Nothing\n * supplies a second `base` layer any more, so an entry whose `config:` key is\n * empty must resolve to the hook set this plugin controls by default.\n * - `.volatile()` belongs on the individual fields, never on the root object:\n * a volatile root collapses the whole Config into a single reference and\n * discards every nested default (verified against schemastery 3.18.4), and\n * schemastery rejects a volatile node nested inside another volatile node.\n * - The schema is built lazily by {@link hooksOrderingConfig}. `dsh.ts` imports\n * this module and this module needs `dsh.ts`'s default hook lists, so building\n * the schema eagerly would read those bindings before they are initialized.\n *\n * `syncReturnHooks` is deliberately absent: it describes the HOST's dispatch\n * (which hooks are consumed without awaiting), not a user preference, so it\n * stays composition-only in `apply()`.\n *\n * @module dsh-hooks-ordering/settings\n */\n\nimport Schema from '@deepseek-ai/schemastery'\n\n/** The fields this plugin exposes as editable settings. */\nexport interface HooksOrderingSettings {\n /** Waterfall hooks to control; `[]` disables the waterfall service entirely. */\n readonly hooks: readonly string[]\n /** Serial hooks to control; `[]` disables the serial service entirely. */\n readonly serialHooks: readonly string[]\n /** Constraint-DAG log file; the empty string means \"do not log\". */\n readonly log: string\n}\n\n/**\n * Entry id used by `cordis.patch.yml` and, in 0.1.7, by the settings form.\n *\n * The form is keyed by the **loader entry id**; this constant is the shipped\n * row's id, and it is also the client half's settings-section id.\n */\nexport const SETTINGS_NS = 'hooks-ordering'\n\n/**\n * Build the Config schema from the caller's default hook lists.\n *\n * Every field is `.volatile()`, so every field is user-editable from the\n * Settings page and an edit is committed into the live reference instead of\n * remounting the plugin. `log` is volatile too, because a non-volatile field\n * would not appear in the form at all.\n *\n * @param defaultWaterfallHooks - waterfall hooks controlled when unconfigured.\n * @param defaultSerialHooks - serial hooks controlled when unconfigured.\n * @returns a callable schemastery schema, which is what Cordis resolves through.\n */\nexport function hooksOrderingConfig(\n defaultWaterfallHooks: readonly string[],\n defaultSerialHooks: readonly string[],\n): Schema {\n return Schema.object({\n hooks: Schema.array(Schema.string()).default([...defaultWaterfallHooks]).volatile(),\n serialHooks: Schema.array(Schema.string()).default([...defaultSerialHooks]).volatile(),\n log: Schema.string().default('').volatile(),\n })\n}\n\n/**\n * Read one resolved field, unwrapping a volatile reference when the loader\n * supplied one and falling back to a default when the field is absent.\n *\n * A loader-provided Config carries volatile fields as references (`.get()`);\n * a plain Cordis mount, or an older harness, passes plain values. Both must\n * work, and an absent field must not be confused with an explicit empty one.\n *\n * @param config - the resolved plugin config (possibly `null`).\n * @param field - the field to read.\n * @param fallback - value to use when the field is absent.\n * @returns the field's current value, or `fallback`.\n */\nexport function readSetting<T>(\n config: object | null | undefined,\n field: string,\n fallback: T,\n): T {\n const value = (config as Record<string, unknown> | null | undefined)?.[field]\n if (value === undefined || value === null) return fallback\n if (typeof (value as { get?: unknown }).get === 'function') {\n const current = (value as { get(): unknown }).get()\n return (current ?? fallback) as T\n }\n return value as T\n}\n","/**\n * The DeepSeek-Harness layer: a dsh plugin that mounts the ordering services\n * and takes control of the real dsh hooks that multiple independent packages\n * contribute to, so a profile can opt into deterministic ordering with one row.\n *\n * dsh (deepseek-harness) ships waterfall hooks such as `agent/pre-step`\n * (subscribed by a dozen+ independent packages), `tools/post-execute`,\n * `llm/stream`, and `system-prompt/assemble`, plus the serial hook\n * `agent/turn-stopping`. Their relative listener order is load-bearing yet\n * today decided only by binary `prepend` and registration timing. This plugin\n * controls those hooks up front; controlling an empty hook is a transparent\n * pass-through, so nothing changes until participants register with\n * `before`/`after`.\n *\n * @module dsh-hooks-ordering/dsh\n */\n\nimport type { Context } from '@deepseek-ai/cordis'\nimport HookOrdering from './waterfall.ts'\nimport { SerialHookOrdering } from './serial.ts'\nimport { type HooksOrderingSettings, hooksOrderingConfig, readSetting } from './settings.ts'\n\n/** Cordis plugin name used by loader diagnostics. */\nexport const name = 'hooks-ordering'\n\n/**\n * `settings` is a **hard** dependency, and it has to be declared rather than\n * probed.\n *\n * cordis activates a plugin as soon as the services it declares exist, and\n * `ctx.get('settings')` reads the service store *without* creating that\n * requirement. Without this line the dsh layer loads in the first wave — before\n * the host has provided `settings` — the probe returns `undefined`, and the\n * settings namespace is never registered. Silently: no form, no error.\n *\n * Declaring it makes cordis wait, which also lands this plugin's prepended\n * brackets later in the boot, exactly where the ordering guarantee wants them.\n *\n * This entry targets dsh only — the hook names below are dsh's, and dsh always\n * provides `settings`, so the dependency costs nothing here. `/waterfall` and\n * `/serial` are the entries that carry no host-service requirement.\n */\nexport const inject = ['settings']\n\n/**\n * The dsh waterfall hooks this plugin controls by default — the ones multiple\n * independent packages contribute to, where relative order matters. Controlling\n * a hook with no registered participants is a no-op pass-through.\n *\n * Every name here was checked against the events dsh actually declares: each is\n * a `@mode waterfall` event in the installed build, and each is dispatched with\n * `await`, so the caller already handles a Promise and the bracket's async\n * phases cannot change the hook's return type. `tools/code-dispatch-log` used\n * to sit in this list and no dsh package declares it at all — controlling a\n * name nothing dispatches \"succeeds\" and then does nothing forever, which is\n * exactly the dead configuration this list must not carry.\n *\n * The other admission rule is {@link DEFAULT_SYNC_RETURN_HOOKS}: a hook whose\n * value the caller consumes without awaiting cannot carry ordered participants,\n * so controlling it by default would install a bracket nothing may ever\n * register into — the same dead configuration by a different route.\n */\nexport const DEFAULT_WATERFALL_HOOKS: readonly string[] = [\n 'agent/pre-step',\n 'agent/request',\n 'agent/request-error',\n 'system-prompt/assemble',\n 'tools/pre-execute',\n 'tools/execute',\n 'tools/post-execute',\n 'fs/write-intent',\n 'fs/edit-intent',\n 'approval/request',\n]\n\n/**\n * dsh hooks whose dispatch return value is consumed by the caller without\n * awaiting, and which therefore cannot carry ordered participants. Verified\n * against the installed build:\n *\n * - `llm/stream` — `dsh-llm` dispatches it as\n * `return this.ctx.waterfall(this, \"llm/stream\", options, …)` (no await), and\n * `dsh-session-title-llm` iterates the result with\n * `for await (const chunk of ctx.llm.stream(options))`. A Promise there throws\n * \"not async iterable\". It is a genuine multi-contributor hook (dsh-agent-loop,\n * dsh-llm's own invariant, dsh-session-checkpoint-policy, dsh-session-title),\n * so it is a real loss — but ordering it requires dsh to await the dispatch,\n * not a plugin-side workaround.\n * - `session-telemetry/record` — `dsh-session-telemetry` returns the record\n * straight out of the waterfall and hands it to the backend, so a Promise\n * would be emitted as a record: silent corruption, no error anywhere.\n * - `compaction/summary-error` — `dsh-compaction-basic` dispatches it as\n * `recover: (…) => this.ctx.waterfall(this, \"compaction/summary-error\", …, () => false)`\n * (no await) and consumes the boolean synchronously in\n * `if (!dependencies.recover(error, agent, prepared.shadowedSeqs, signal)) throw error`.\n * A Promise is always truthy, so `!recover(…)` is always false and every\n * summarizer failure is swallowed instead of rethrown — the compaction then\n * proceeds as if recovery had succeeded.\n *\n * Override per profile with `syncReturnHooks` once the host awaits one of them.\n */\nexport const DEFAULT_SYNC_RETURN_HOOKS: readonly string[] = [\n 'llm/stream',\n 'session-telemetry/record',\n 'compaction/summary-error',\n]\n\n/** The dsh serial hook controlled by default. */\nexport const DEFAULT_SERIAL_HOOKS: readonly string[] = ['agent/turn-stopping']\n\n/**\n * This plugin's Cordis Config schema.\n *\n * Since DSH 0.1.7 this *is* the Settings page: the fields are `.volatile()`, so\n * the settings service projects exactly them into an editable form and commits\n * an edit into the live reference rather than remounting the plugin. The\n * defaults are the real hook sets below, so an entry whose `config:` key is\n * empty resolves to them.\n */\nexport const Config = hooksOrderingConfig(DEFAULT_WATERFALL_HOOKS, DEFAULT_SERIAL_HOOKS)\n\n/** The same schema under its pre-0.1.7 name. */\nexport const HooksOrderingConfig = Config\n\n/**\n * Plugin config as a plain value.\n *\n * A loader-resolved config carries the volatile fields as references instead of\n * plain arrays, so this describes the *plain* shape callers and tests construct.\n */\nexport interface ConfigShape {\n /**\n * Waterfall hooks to control. Defaults to {@link DEFAULT_WATERFALL_HOOKS}.\n * Pass `[]` to disable the waterfall service entirely.\n */\n hooks?: readonly string[]\n /**\n * Serial hooks to control. Defaults to {@link DEFAULT_SERIAL_HOOKS}.\n * Pass `[]` to disable the serial service entirely.\n */\n serialHooks?: readonly string[]\n /**\n * Hooks whose return value the host consumes without awaiting, and which must\n * therefore refuse participants. Defaults to {@link DEFAULT_SYNC_RETURN_HOOKS}.\n * Pass `[]` — or a set without a given hook — to opt in to ordering it, once\n * the host awaits that dispatch.\n *\n * Composition-only: this describes the HOST's dispatch, not a user\n * preference, so it is intentionally not a field of {@link Config} and never\n * appears on the Settings page.\n */\n syncReturnHooks?: readonly string[]\n /** When set, the constraint DAG (JSON) is logged to this file on every change. */\n log?: string\n /** Overrides the loader entry id; only non-loader harnesses need it. */\n entryId?: string\n}\n\n/**\n * Mount {@link HookOrdering} and/or {@link SerialHookOrdering} and control the\n * configured hooks once the services are active.\n *\n * `config` may be `null`, and that is the ordinary case rather than an edge\n * case: a loader row whose `config:` key is followed only by comments parses as\n * YAML null, and a default parameter covers `undefined` but not `null`. This\n * package's own `cordis.patch.yml` is written exactly that way — every option\n * commented out — so a null config must mean \"all defaults\", not a crash.\n *\n * Since DSH 0.1.7 there is no second \"user layer\": the row's `config` IS the\n * editable settings, because Cordis validates it against {@link Config} and the\n * settings service projects that schema. A volatile field arrives here as a\n * reference rather than a plain array, which is why every read goes through\n * {@link readSetting} — that also keeps a plain Cordis mount (and an older\n * harness) working, where the fields are plain values.\n *\n * `hooks`/`serialHooks` are read once, at apply time. An edit from the Settings\n * page therefore needs a plugin reload to take effect — the same restart\n * semantics this namespace declared before 0.1.7 (`applies: 'restart'`), since\n * changing the controlled set means adding or removing live hook brackets and\n * the coordinator has no public \"release one hook\" operation.\n *\n * @param ctx - the Cordis context.\n * @param config - which hooks to control and an optional DAG `log` file; null means all defaults.\n */\nexport function apply(ctx: Context, config: ConfigShape | null = {}): void {\n const row = config ?? {}\n const settings: HooksOrderingSettings = {\n hooks: readSetting(row, 'hooks', DEFAULT_WATERFALL_HOOKS),\n serialHooks: readSetting(row, 'serialHooks', DEFAULT_SERIAL_HOOKS),\n log: readSetting(row, 'log', ''),\n }\n const { hooks, serialHooks, log } = settings\n const serviceConfig = log === '' ? {} : { log }\n // Not part of the settings form: the sync-return list describes the HOST's\n // dispatch, not a user preference, so it is composition (row) only.\n const syncReturnHooks = row.syncReturnHooks ?? DEFAULT_SYNC_RETURN_HOOKS\n\n const deps: string[] = []\n if (hooks.length > 0) {\n ctx.plugin(HookOrdering, { ...serviceConfig, syncReturnHooks })\n deps.push('hooksOrdering')\n }\n if (serialHooks.length > 0) {\n ctx.plugin(SerialHookOrdering, serviceConfig)\n deps.push('serialHooksOrdering')\n }\n if (deps.length === 0) return\n\n // The services activate asynchronously; control the hooks once they exist.\n ctx.inject(deps, (ready) => {\n for (const hook of hooks) ready.hooksOrdering.control(hook)\n for (const hook of serialHooks) ready.serialHooksOrdering.control(hook)\n })\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA2DA,SAAgB,oBACd,uBACA,oBACQ;AACR,QAAO,OAAO,OAAO;EACnB,OAAO,OAAO,MAAM,OAAO,QAAQ,CAAC,CAAC,QAAQ,CAAC,GAAG,sBAAsB,CAAC,CAAC,UAAU;EACnF,aAAa,OAAO,MAAM,OAAO,QAAQ,CAAC,CAAC,QAAQ,CAAC,GAAG,mBAAmB,CAAC,CAAC,UAAU;EACtF,KAAK,OAAO,QAAQ,CAAC,QAAQ,GAAG,CAAC,UAAU;EAC5C,CAAC;;;;;;;;;;;;;;;AAgBJ,SAAgB,YACd,QACA,OACA,UACG;CACH,MAAM,QAAS,SAAwD;AACvE,KAAI,UAAU,UAAa,UAAU,KAAM,QAAO;AAClD,KAAI,OAAQ,MAA4B,QAAQ,WAE9C,QADiB,MAA6B,KAAK,IAChC;AAErB,QAAO;;;;;;ACvET,MAAa,OAAO;;;;;;;;;;;;;;;;;;AAmBpB,MAAa,SAAS,CAAC,WAAW;;;;;;;;;;;;;;;;;;;AAoBlC,MAAaA,0BAA6C;CACxD;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BD,MAAaC,4BAA+C;CAC1D;CACA;CACA;CACD;;AAGD,MAAaC,uBAA0C,CAAC,sBAAsB;;;;;;;;;;AAW9E,MAAa,SAAS,oBAAoB,yBAAyB,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiExF,SAAgB,MAAM,KAAc,SAA6B,EAAE,EAAQ;CACzE,MAAM,MAAM,UAAU,EAAE;CAMxB,MAAM,EAAE,OAAO,aAAa,QALY;EACtC,OAAO,YAAY,KAAK,SAAS,wBAAwB;EACzD,aAAa,YAAY,KAAK,eAAe,qBAAqB;EAClE,KAAK,YAAY,KAAK,OAAO,GAAG;EACjC;CAED,MAAM,gBAAgB,QAAQ,KAAK,EAAE,GAAG,EAAE,KAAK;CAG/C,MAAM,kBAAkB,IAAI,mBAAmB;CAE/C,MAAMC,OAAiB,EAAE;AACzB,KAAI,MAAM,SAAS,GAAG;AACpB,MAAI,OAAOC,mBAAc;GAAE,GAAG;GAAe;GAAiB,CAAC;AAC/D,OAAK,KAAK,gBAAgB;;AAE5B,KAAI,YAAY,SAAS,GAAG;AAC1B,MAAI,OAAO,oBAAoB,cAAc;AAC7C,OAAK,KAAK,sBAAsB;;AAElC,KAAI,KAAK,WAAW,EAAG;AAGvB,KAAI,OAAO,OAAO,UAAU;AAC1B,OAAK,MAAM,QAAQ,MAAO,OAAM,cAAc,QAAQ,KAAK;AAC3D,OAAK,MAAM,QAAQ,YAAa,OAAM,oBAAoB,QAAQ,KAAK;GACvE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zfdx123/dsh-hooks-ordering",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "钩子排序:为 Cordis 的 waterfall / serial 钩子提供确定性的 before/after 排序,让互相独立的插件能声明彼此的先后关系(拓扑排序、环检测、可配置控制集与设置页)。",
5
5
  "keywords": [
6
6
  "cordis",
@@ -61,11 +61,11 @@
61
61
  ],
62
62
  "engines": {
63
63
  "node": "^22.19.0 || >=24.0.0",
64
- "dsh": "^0.1.6-alpha.1"
64
+ "dsh": "^0.1.7-rc.2"
65
65
  },
66
66
  "peerDependencies": {
67
- "@deepseek-ai/cordis": "^4.0.2",
68
- "@deepseek-ai/dsh-settings": "^0.1.6-alpha.1"
67
+ "@deepseek-ai/cordis": "^4.0.4",
68
+ "@deepseek-ai/dsh-settings": "^0.1.7-rc.2"
69
69
  },
70
70
  "peerDependenciesMeta": {
71
71
  "@deepseek-ai/dsh-settings": {
@@ -73,10 +73,10 @@
73
73
  }
74
74
  },
75
75
  "dependencies": {
76
- "@deepseek-ai/schemastery": "^3.18.1"
76
+ "@deepseek-ai/schemastery": "^3.18.4"
77
77
  },
78
78
  "devDependencies": {
79
- "@deepseek-ai/cordis": "^4.0.2",
79
+ "@deepseek-ai/cordis": "^4.0.4",
80
80
  "@types/node": "^22.20.1",
81
81
  "@vitest/coverage-v8": "^3.2.4",
82
82
  "oxlint": "^1.0.0",
package/src/dsh.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  import type { Context } from '@deepseek-ai/cordis'
19
19
  import HookOrdering from './waterfall.ts'
20
20
  import { SerialHookOrdering } from './serial.ts'
21
- import { type HooksOrderingSettings, resolveSettings } from './settings.ts'
21
+ import { type HooksOrderingSettings, hooksOrderingConfig, readSetting } from './settings.ts'
22
22
 
23
23
  /** Cordis plugin name used by loader diagnostics. */
24
24
  export const name = 'hooks-ordering'
@@ -108,8 +108,27 @@ export const DEFAULT_SYNC_RETURN_HOOKS: readonly string[] = [
108
108
  /** The dsh serial hook controlled by default. */
109
109
  export const DEFAULT_SERIAL_HOOKS: readonly string[] = ['agent/turn-stopping']
110
110
 
111
- /** Plugin config. */
112
- export interface Config {
111
+ /**
112
+ * This plugin's Cordis Config schema.
113
+ *
114
+ * Since DSH 0.1.7 this *is* the Settings page: the fields are `.volatile()`, so
115
+ * the settings service projects exactly them into an editable form and commits
116
+ * an edit into the live reference rather than remounting the plugin. The
117
+ * defaults are the real hook sets below, so an entry whose `config:` key is
118
+ * empty resolves to them.
119
+ */
120
+ export const Config = hooksOrderingConfig(DEFAULT_WATERFALL_HOOKS, DEFAULT_SERIAL_HOOKS)
121
+
122
+ /** The same schema under its pre-0.1.7 name. */
123
+ export const HooksOrderingConfig = Config
124
+
125
+ /**
126
+ * Plugin config as a plain value.
127
+ *
128
+ * A loader-resolved config carries the volatile fields as references instead of
129
+ * plain arrays, so this describes the *plain* shape callers and tests construct.
130
+ */
131
+ export interface ConfigShape {
113
132
  /**
114
133
  * Waterfall hooks to control. Defaults to {@link DEFAULT_WATERFALL_HOOKS}.
115
134
  * Pass `[]` to disable the waterfall service entirely.
@@ -125,10 +144,16 @@ export interface Config {
125
144
  * therefore refuse participants. Defaults to {@link DEFAULT_SYNC_RETURN_HOOKS}.
126
145
  * Pass `[]` — or a set without a given hook — to opt in to ordering it, once
127
146
  * the host awaits that dispatch.
147
+ *
148
+ * Composition-only: this describes the HOST's dispatch, not a user
149
+ * preference, so it is intentionally not a field of {@link Config} and never
150
+ * appears on the Settings page.
128
151
  */
129
152
  syncReturnHooks?: readonly string[]
130
153
  /** When set, the constraint DAG (JSON) is logged to this file on every change. */
131
154
  log?: string
155
+ /** Overrides the loader entry id; only non-loader harnesses need it. */
156
+ entryId?: string
132
157
  }
133
158
 
134
159
  /**
@@ -141,25 +166,33 @@ export interface Config {
141
166
  * package's own `cordis.patch.yml` is written exactly that way — every option
142
167
  * commented out — so a null config must mean "all defaults", not a crash.
143
168
  *
144
- * The row is the *composition* layer of the settings namespace: the Settings
145
- * page edits a user layer over it, and a namespace the user has not touched
146
- * resolves back to this row (or to the built-in defaults). Without a settings
147
- * provider — plain Cordis — the row is the whole answer.
169
+ * Since DSH 0.1.7 there is no second "user layer": the row's `config` IS the
170
+ * editable settings, because Cordis validates it against {@link Config} and the
171
+ * settings service projects that schema. A volatile field arrives here as a
172
+ * reference rather than a plain array, which is why every read goes through
173
+ * {@link readSetting} — that also keeps a plain Cordis mount (and an older
174
+ * harness) working, where the fields are plain values.
175
+ *
176
+ * `hooks`/`serialHooks` are read once, at apply time. An edit from the Settings
177
+ * page therefore needs a plugin reload to take effect — the same restart
178
+ * semantics this namespace declared before 0.1.7 (`applies: 'restart'`), since
179
+ * changing the controlled set means adding or removing live hook brackets and
180
+ * the coordinator has no public "release one hook" operation.
148
181
  *
149
182
  * @param ctx - the Cordis context.
150
183
  * @param config - which hooks to control and an optional DAG `log` file; null means all defaults.
151
184
  */
152
- export function apply(ctx: Context, config: Config | null = {}): void {
185
+ export function apply(ctx: Context, config: ConfigShape | null = {}): void {
153
186
  const row = config ?? {}
154
- const base: HooksOrderingSettings = {
155
- hooks: row.hooks ?? DEFAULT_WATERFALL_HOOKS,
156
- serialHooks: row.serialHooks ?? DEFAULT_SERIAL_HOOKS,
157
- log: row.log ?? '',
187
+ const settings: HooksOrderingSettings = {
188
+ hooks: readSetting(row, 'hooks', DEFAULT_WATERFALL_HOOKS),
189
+ serialHooks: readSetting(row, 'serialHooks', DEFAULT_SERIAL_HOOKS),
190
+ log: readSetting(row, 'log', ''),
158
191
  }
159
- const { hooks, serialHooks, log } = resolveSettings(ctx, base) ?? base
192
+ const { hooks, serialHooks, log } = settings
160
193
  const serviceConfig = log === '' ? {} : { log }
161
- // Not part of the settings namespace: the sync-return list describes the
162
- // HOST's dispatch, not a user preference, so it is composition (row) only.
194
+ // Not part of the settings form: the sync-return list describes the HOST's
195
+ // dispatch, not a user preference, so it is composition (row) only.
163
196
  const syncReturnHooks = row.syncReturnHooks ?? DEFAULT_SYNC_RETURN_HOOKS
164
197
 
165
198
  const deps: string[] = []
package/src/index.ts CHANGED
@@ -45,7 +45,8 @@ export { type SerialHookEntry, type SerialHookOrderingConfig, SerialHookOrdering
45
45
  // it as its default.
46
46
  export {
47
47
  apply,
48
- type Config,
48
+ Config,
49
+ type ConfigShape,
49
50
  DEFAULT_SERIAL_HOOKS,
50
51
  DEFAULT_SYNC_RETURN_HOOKS,
51
52
  DEFAULT_WATERFALL_HOOKS,
package/src/settings.ts CHANGED
@@ -1,24 +1,33 @@
1
1
  /**
2
- * The dsh settings namespace for this plugin: the three fields a user can edit
3
- * from the Settings page instead of a `cordis.patch.yml` row.
2
+ * This plugin's Cordis Config — and, since DSH 0.1.7, its Settings page.
4
3
  *
5
- * Two constraints are load-bearing and easy to get wrong:
4
+ * 0.1.7 removed `ctx.settings.register(ns, schema)`. Settings are now *projected
5
+ * from each entry's own Config*: the loader validates the row's `config` against
6
+ * this schema, resolves the defaults, and the settings service renders exactly
7
+ * the nodes marked `.volatile()` as an editable form. Three consequences are
8
+ * load-bearing here:
6
9
  *
7
- * - The schema handed to `settings.register` must be a **callable** schemastery
8
- * schema. dsh resolves a namespace by calling `schema(merged)` and reads
9
- * `schema.toJSON()` for the form, so a plain object throws
10
- * `schema is not a function` — during plugin assembly, which takes the whole
11
- * profile down rather than failing one form.
12
- * - That is also why registration is wrapped in `try/catch` here: an optional
13
- * settings form must never be able to stop the harness from booting.
10
+ * - The defaults below are the *real* defaults, not "off" placeholders. Nothing
11
+ * supplies a second `base` layer any more, so an entry whose `config:` key is
12
+ * empty must resolve to the hook set this plugin controls by default.
13
+ * - `.volatile()` belongs on the individual fields, never on the root object:
14
+ * a volatile root collapses the whole Config into a single reference and
15
+ * discards every nested default (verified against schemastery 3.18.4), and
16
+ * schemastery rejects a volatile node nested inside another volatile node.
17
+ * - The schema is built lazily by {@link hooksOrderingConfig}. `dsh.ts` imports
18
+ * this module and this module needs `dsh.ts`'s default hook lists, so building
19
+ * the schema eagerly would read those bindings before they are initialized.
20
+ *
21
+ * `syncReturnHooks` is deliberately absent: it describes the HOST's dispatch
22
+ * (which hooks are consumed without awaiting), not a user preference, so it
23
+ * stays composition-only in `apply()`.
14
24
  *
15
25
  * @module dsh-hooks-ordering/settings
16
26
  */
17
27
 
18
- import type { Context } from '@deepseek-ai/cordis'
19
28
  import Schema from '@deepseek-ai/schemastery'
20
29
 
21
- /** The three fields this plugin exposes for configuration. */
30
+ /** The fields this plugin exposes as editable settings. */
22
31
  export interface HooksOrderingSettings {
23
32
  /** Waterfall hooks to control; `[]` disables the waterfall service entirely. */
24
33
  readonly hooks: readonly string[]
@@ -28,70 +37,60 @@ export interface HooksOrderingSettings {
28
37
  readonly log: string
29
38
  }
30
39
 
31
- /** Namespace name — dsh requires a lowercase hyphenated identifier. */
32
- export const SETTINGS_NS = 'hooks-ordering'
33
-
34
40
  /**
35
- * Schema for {@link SETTINGS_NS}.
41
+ * Entry id used by `cordis.patch.yml` and, in 0.1.7, by the settings form.
36
42
  *
37
- * Every field defaults to its "off" value. The *meaningful* defaults — the
38
- * hooks this plugin controls when nothing overrides them — are supplied by the
39
- * caller as the composition `base` layer (see {@link resolveSettings}), so a
40
- * namespace the user has never touched still resolves to a complete object.
43
+ * The form is keyed by the **loader entry id**; this constant is the shipped
44
+ * row's id, and it is also the client half's settings-section id.
41
45
  */
42
- export const HooksOrderingSettingsSchema = Schema.object({
43
- hooks: Schema.array(Schema.string()).default([]),
44
- serialHooks: Schema.array(Schema.string()).default([]),
45
- log: Schema.string().default(''),
46
- })
46
+ export const SETTINGS_NS = 'hooks-ordering'
47
47
 
48
48
  /**
49
- * The slice of dsh's `settings` service this plugin uses.
49
+ * Build the Config schema from the caller's default hook lists.
50
+ *
51
+ * Every field is `.volatile()`, so every field is user-editable from the
52
+ * Settings page and an edit is committed into the live reference instead of
53
+ * remounting the plugin. `log` is volatile too, because a non-volatile field
54
+ * would not appear in the form at all.
50
55
  *
51
- * Structural rather than imported from `@deepseek-ai/dsh-settings`: it describes
52
- * exactly the contract this plugin relies on without adding a build-time
53
- * dependency on a dsh package, whose internals are still 0.1.x-rc.
56
+ * @param defaultWaterfallHooks - waterfall hooks controlled when unconfigured.
57
+ * @param defaultSerialHooks - serial hooks controlled when unconfigured.
58
+ * @returns a callable schemastery schema, which is what Cordis resolves through.
54
59
  */
55
- export interface SettingsServiceLike {
56
- /**
57
- * @param ns - the namespace to register; must be a lowercase hyphenated identifier.
58
- * @param schema - a callable schemastery schema, not a plain object.
59
- * @param options - `base` is the composition layer, `applies` says whether an
60
- * edit takes effect live or needs a restart.
61
- * @returns a scope whose `get()` is the resolved value (schema defaults, then `base`, then the user layer).
62
- */
63
- register(
64
- ns: string,
65
- schema: unknown,
66
- options?: { readonly base?: unknown; readonly applies?: 'live' | 'restart' },
67
- ): { get(): HooksOrderingSettings }
60
+ export function hooksOrderingConfig(
61
+ defaultWaterfallHooks: readonly string[],
62
+ defaultSerialHooks: readonly string[],
63
+ ): Schema {
64
+ return Schema.object({
65
+ hooks: Schema.array(Schema.string()).default([...defaultWaterfallHooks]).volatile(),
66
+ serialHooks: Schema.array(Schema.string()).default([...defaultSerialHooks]).volatile(),
67
+ log: Schema.string().default('').volatile(),
68
+ })
68
69
  }
69
70
 
70
71
  /**
71
- * Register the settings namespace and read its resolved value.
72
- *
73
- * `base` is the composition layer — what the row (or the built-in defaults)
74
- * asks for — so the user layer edits *over* it and a reset returns to it rather
75
- * than to an empty form.
72
+ * Read one resolved field, unwrapping a volatile reference when the loader
73
+ * supplied one and falling back to a default when the field is absent.
76
74
  *
77
- * `applies: 'restart'` is deliberate. Changing `hooks`/`serialHooks` means
78
- * installing or removing bracket listeners on live hooks, and the coordinator
79
- * has no public "release one hook" operation; a restart applies the change
80
- * cleanly instead of re-wiring the dispatch chain mid-flight.
75
+ * A loader-provided Config carries volatile fields as references (`.get()`);
76
+ * a plain Cordis mount, or an older harness, passes plain values. Both must
77
+ * work, and an absent field must not be confused with an explicit empty one.
81
78
  *
82
- * @param ctx - the Cordis context to look the service up on.
83
- * @param base - the composition layer to register as the base value.
84
- * @returns the resolved settings, or `undefined` when the provider is missing
85
- * (which `inject: ['settings']` rules out in dsh — the guard is defensive) or
86
- * registration failed — the caller then uses `base`.
79
+ * @param config - the resolved plugin config (possibly `null`).
80
+ * @param field - the field to read.
81
+ * @param fallback - value to use when the field is absent.
82
+ * @returns the field's current value, or `fallback`.
87
83
  */
88
- export function resolveSettings(ctx: Context, base: HooksOrderingSettings): HooksOrderingSettings | undefined {
89
- const settings = ctx.get('settings') as SettingsServiceLike | undefined
90
- if (settings === undefined) return undefined
91
- try {
92
- return settings.register(SETTINGS_NS, HooksOrderingSettingsSchema, { base, applies: 'restart' }).get()
93
- } catch (error) {
94
- console.warn('hooks-ordering: settings registration failed; falling back to the composition config:', error)
95
- return undefined
84
+ export function readSetting<T>(
85
+ config: object | null | undefined,
86
+ field: string,
87
+ fallback: T,
88
+ ): T {
89
+ const value = (config as Record<string, unknown> | null | undefined)?.[field]
90
+ if (value === undefined || value === null) return fallback
91
+ if (typeof (value as { get?: unknown }).get === 'function') {
92
+ const current = (value as { get(): unknown }).get()
93
+ return (current ?? fallback) as T
96
94
  }
95
+ return value as T
97
96
  }