dsh-ptc-plus 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.en.md +3 -1
  2. package/README.md +3 -1
  3. package/client.cjs +165 -29
  4. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -2
  5. package/docs/adr/0019-plugin-settings-and-kill-switch.md +2 -2
  6. package/docs/adr/0023-global-user-bindings.md +12 -0
  7. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
  8. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
  9. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
  10. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +1 -1
  11. package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
  12. package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
  13. package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
  14. package/docs/adr/README.md +7 -1
  15. package/docs/architecture.md +1 -1
  16. package/docs/durability-design.md +9 -2
  17. package/docs/installation.md +9 -9
  18. package/docs/releases/v0.5.2.md +26 -14
  19. package/docs/releases/v0.5.3.md +77 -0
  20. package/docs/runtime-reference.md +7 -1
  21. package/docs/user-bindings.en.md +15 -0
  22. package/docs/user-bindings.md +15 -0
  23. package/docs/v0.5.3-goals.md +117 -0
  24. package/docs/verification.md +4 -4
  25. package/index.js +67 -19
  26. package/internal/binding-adoption.js +136 -0
  27. package/internal/capability-command-receipt.js +18 -4
  28. package/internal/capability-delivery-proof.js +70 -0
  29. package/internal/capability-environment.js +38 -1
  30. package/internal/config-spec.js +14 -5
  31. package/internal/cordis-tools-owner.js +2 -1
  32. package/internal/direct-surface-owner.js +27 -13
  33. package/internal/edit-transport-owner.js +6 -5
  34. package/internal/execution-provider-owner.js +6 -0
  35. package/internal/execution-seam-compat.js +25 -0
  36. package/internal/host-tool-sdk.js +308 -0
  37. package/internal/official-repl-priority.js +31 -10
  38. package/internal/program-bindings.js +12 -1
  39. package/internal/repl-memory-projection.js +21 -2
  40. package/internal/runtime-bridge-owner.js +113 -8
  41. package/internal/runtime-messages.js +5 -0
  42. package/internal/session-journal-recovery.js +210 -32
  43. package/internal/session-journal-schema.js +3 -0
  44. package/internal/session-journal.js +46 -9
  45. package/internal/session-log-view.js +22 -2
  46. package/internal/session-runtime.js +106 -39
  47. package/internal/user-bindings-owner.js +109 -6
  48. package/package.json +15 -7
  49. package/src/client-capabilities-view.js +45 -3
  50. package/src/client-copy.js +24 -0
  51. package/src/client-indicator-view.js +2 -1
  52. package/src/client-repl-view.js +35 -16
  53. package/src/client.js +3 -1
@@ -180,6 +180,34 @@ export function vanishedBindingDiagnostic(names) {
180
180
  })
181
181
  }
182
182
 
183
+ /**
184
+ * Notice for bindings a recorded revision superseded.
185
+ *
186
+ * A revision that rewrites its target puts the target off the recorded path, so
187
+ * the target's declarations stop building the environment. They are not missing:
188
+ * the log says which revision replaced them, and reporting that relation keeps
189
+ * the model from reading a later `x is not defined` as its own mistake.
190
+ */
191
+ export function supersededDiagnostic(bindings, supersessions = []) {
192
+ const names = bindings.length === 0 ? '' : ` (${bindings.join(', ')})`
193
+ const relations = supersessions.map(item =>
194
+ `cell ${item.supersededCallSeq} by cell ${item.supersedingCallSeq}`).join('; ')
195
+ return diagnostic({
196
+ code: 'PTC-R007',
197
+ severity: 'warning',
198
+ phase: 'recover',
199
+ message: (bindings.length === 0
200
+ ? 'A recorded revision replaced the cell it edited'
201
+ : `Superseded ${bindings.length} binding(s)${names} because a recorded revision replaced the cell that declared them`)
202
+ + `${relations === '' ? '' : ` (${relations})`}. The replaced cell stays in the session log but no longer builds this environment.`,
203
+ stateEffect: 'rolled-back',
204
+ help: [
205
+ 'this is not a recovery failure: an accepted edit rewrote the cell that declared these bindings, and the revision does not declare them',
206
+ 'redeclare a superseded name in a new cell before using it again',
207
+ ],
208
+ })
209
+ }
210
+
183
211
  function unverifiedCompletionDiagnostic(count) {
184
212
  return diagnostic({
185
213
  code: 'PTC-R003',
@@ -329,19 +357,21 @@ class SessionKernel {
329
357
  this.liveCallSeqs = new Set()
330
358
  this.unverifiedCompletions = []
331
359
  this.restoredFromRecord = []
332
- this.recoveryNotice = disposedCells > 0
360
+ this.recoveryNotices = disposedCells > 0
333
361
  // A kernel whose owning agent or session was disposed must not resurrect
334
362
  // the disposed environment: the history is gone by contract, and a later
335
363
  // cell starts from an empty REPL with the loss reported once.
336
- ? recoveryDiagnostic([], disposedCells)
364
+ ? [recoveryDiagnostic([], disposedCells)]
337
365
  : history.volatileSuffix.length === 0
338
- ? undefined
339
- : recoveryDiagnostic(droppedBindingNames(history), history.volatileSuffix.length)
340
- this.recoveryNoticeShown = false
366
+ ? []
367
+ : [recoveryDiagnostic(droppedBindingNames(history), history.volatileSuffix.length)]
341
368
  // Bindings a recovery told the model were live, and the last help pair that
342
369
  // explained a recovery. Both exist to keep one event from being reported
343
370
  // twice and to notice a binding that stops existing afterwards.
344
371
  this.announcedLive = new Set()
372
+ // A recorded supersession is a durable fact; announcing it once per runtime
373
+ // keeps later folds from repeating a relation the model already read.
374
+ this.announcedSupersessions = new Set()
345
375
  this.recoveryHelpSignature = undefined
346
376
  this.recoveryEventCount = 0
347
377
  this.active = undefined
@@ -558,27 +588,29 @@ class SessionKernel {
558
588
  if (worker !== undefined) await this.resetWorker(worker)
559
589
  this.rollbackToDurable()
560
590
  if (config.durableReplay) {
561
- this.recoveryNotice = undefined
591
+ this.recoveryNotices = []
562
592
  try {
563
593
  this.history = recoverJournal(observedSession, request.callSeq, {
564
594
  visibleCallSeqs,
595
+ editRewritesHistory: config.editRewritesHistory === true,
565
596
  })
566
597
  } catch (error) {
567
- /* c8 ignore start -- a fold that throws is not reachable from any supported session; cover or prove it in 0.5.3 */
598
+ /* c8 ignore start -- the fold reports damaged history as data, so no supported session reaches this catch */
568
599
  const boundary = recoveryBoundaryForHistory(this.history, undefined, { reset: true })
569
600
  if (boundary !== undefined) recoveryBoundaries.push(boundary)
570
601
  this.history = emptyHistory()
571
- this.recoveryNotice = recoveryDiagnostic([], 1)
602
+ this.recoveryNotices = [recoveryDiagnostic([], 1)]
572
603
  this.initialRecoveryBoundary = undefined
573
604
  /* c8 ignore stop */
574
605
  }
575
- /* c8 ignore start -- volatile suffix on the first fold; cover in 0.5.3 */
606
+ /* c8 ignore start -- volatile suffix on the first fold; no case settles a volatile suffix before the first replay */
576
607
  if (this.history.volatileSuffix.length > 0) {
577
- this.recoveryNotice = recoveryDiagnostic(droppedBindingNames(this.history), this.history.volatileSuffix.length)
608
+ this.recoveryNotices = [recoveryDiagnostic(
609
+ droppedBindingNames(this.history), this.history.volatileSuffix.length)]
578
610
  }
579
611
  /* c8 ignore stop */
580
612
  this.initialRecoveryBoundary = recoveryBoundaryForHistory(this.history)
581
- /* c8 ignore start -- first-fold recovery boundary; cover in 0.5.3 */
613
+ /* c8 ignore start -- first-fold recovery boundary; no case makes the first replay record one */
582
614
  if (this.initialRecoveryBoundary !== undefined) {
583
615
  recoveryBoundaries.push(this.initialRecoveryBoundary)
584
616
  this.initialRecoveryBoundary = undefined
@@ -587,7 +619,7 @@ class SessionKernel {
587
619
  } else {
588
620
  this.history = emptyHistory()
589
621
  this.replayed = true
590
- this.recoveryNotice = recoveryDiagnostic([], 1)
622
+ this.recoveryNotices = [recoveryDiagnostic([], 1)]
591
623
  }
592
624
  }
593
625
  }
@@ -680,6 +712,7 @@ class SessionKernel {
680
712
  const recovered = recoverJournal(observedSession, request.callSeq, {
681
713
  extraBoundaries: [boundary],
682
714
  visibleCallSeqs: visibleExecutableCallSeqs(observedSession),
715
+ editRewritesHistory: config.editRewritesHistory === true,
683
716
  })
684
717
  const nextPathLength = pathToHead(recovered).length
685
718
  if (nextPathLength >= previousPathLength) throw new Error('recovery did not contract the historical frontier')
@@ -698,10 +731,12 @@ class SessionKernel {
698
731
  this.durability = durabilityState()
699
732
  }
700
733
  }
701
- // One recovery event produces one notice. The four facts below describe the
702
- // same event, so they are composed once instead of overwriting each other:
703
- // an earlier overwrite dropped the restored-name list whenever a later fact
704
- // arrived, and a repeat entry re-emitted the same sentence.
734
+ // One recovery event produces one report. The facts below describe the same
735
+ // event, so each one that holds is collected instead of overwriting the ones
736
+ // already found: an earlier overwrite dropped the restored-name list whenever
737
+ // a later fact arrived, and a relation dropped that way was then cleared
738
+ // without ever reaching the model. The facts never name the same binding, so
739
+ // collecting them states the event once per fact rather than twice per name.
705
740
  const restoredCount = this.restoredFromRecord.length
706
741
  const allLive = [...new Set(this.restoredFromRecord.flatMap(item => item.bindings))]
707
742
  // Report only what this recovery added. A later recovery that installs
@@ -716,6 +751,8 @@ const failedNames = [...new Set(this.restoredFromRecord
716
751
  .filter(name => typeof name === 'string'))]
717
752
  const droppedNames = this.missingBindings === undefined ? [] : [...new Set(this.missingBindings)]
718
753
  const endedNames = this.rewindEndedBindings === undefined ? [] : [...new Set(this.rewindEndedBindings)]
754
+ const supersededNames = this.supersededEndedBindings === undefined
755
+ ? [] : [...new Set(this.supersededEndedBindings)]
719
756
  const alreadyReported = this.recoveryEventCount > 0
720
757
  // A complete restore is invisible to the model. Every recorded binding was
721
758
  // installed, nothing was dropped or ended, and the next cell behaves exactly
@@ -726,58 +763,78 @@ const failedNames = [...new Set(this.restoredFromRecord
726
763
  const completeRestore = failedNames.length === 0
727
764
  && droppedNames.length === 0
728
765
  && endedNames.length === 0
766
+ && supersededNames.length === 0
729
767
  if (restoredCount > 0 && completeRestore) {
730
768
  this.restoredFromRecord = []
731
- /* c8 ignore start -- needs a rewind that ended an announced name; cover in 0.5.3 */
769
+ /* c8 ignore start -- needs a rewind that ended an announced name after a reported recovery; no case does */
732
770
  } else if (restoredCount > 0 && liveNames.length === 0 && failedNames.length === 0 && alreadyReported) {
733
771
  this.restoredFromRecord = []
734
772
  /* c8 ignore stop */
735
773
  } else if (restoredCount > 0) {
736
774
  this.recoveryEventCount += 1
737
- this.recoveryNotice = restoredFromRecordDiagnostic(
775
+ this.recoveryNotices.push(restoredFromRecordDiagnostic(
738
776
  restoredCount,
739
777
  liveNames,
740
778
  failedNames,
741
779
  alreadyReported,
742
- )
780
+ ))
743
781
  }
744
- /* c8 ignore start -- needs a proven name the replay never visits; cover in 0.5.3 */
782
+ /* c8 ignore start -- needs a proven name the replay never visits; no case records one */
745
783
  if (droppedNames.length > 0) {
746
- this.recoveryNotice = recoveryDiagnostic(droppedNames, droppedNames.length)
784
+ this.recoveryNotices.push(recoveryDiagnostic(droppedNames, droppedNames.length))
747
785
  /* c8 ignore stop */
748
786
  } else if (restoredCount === 0 && skipped > 0) {
749
- this.recoveryNotice = recoveryDiagnostic([], skipped)
787
+ this.recoveryNotices.push(recoveryDiagnostic([], skipped))
750
788
  }
751
789
  // A rewind that ended a binding is recorded, not a loss: report it only when
752
790
  // the replay installed nothing under that name anywhere (path, off-path or
753
- // catalog), so a correct value never carries a "dropped" notice.
754
- if (this.recoveryNotice === undefined && endedNames.length > 0) {
755
- this.recoveryNotice = rewindEndedDiagnostic(endedNames)
791
+ // catalog), so a correct value never carries a "dropped" notice. A revision
792
+ // that superseded a cell is the other recorded ending: the environment it
793
+ // built differs from the cells the log still lists, so the relation is
794
+ // reported once per runtime instead of on every later fold.
795
+ const freshSupersessions = (this.supersessions ?? [])
796
+ .filter(item => !this.announcedSupersessions.has(item.supersedingCallSeq))
797
+ // A restored cell and a superseded one are two facts about the same recovery,
798
+ // not alternatives: the names this relation withdrew are excluded from the
799
+ // missing and rewind lists, so a report that chose the restore notice over it
800
+ // left a withdrawn name reported nowhere.
801
+ if (freshSupersessions.length > 0 || supersededNames.length > 0) {
802
+ this.recoveryNotices.push(supersededDiagnostic(supersededNames, freshSupersessions))
803
+ for (const item of freshSupersessions) this.announcedSupersessions.add(item.supersedingCallSeq)
756
804
  }
757
- if (this.recoveryNotice === undefined && this.unverifiedCompletions.length > 0) {
758
- this.recoveryNotice = unverifiedCompletionDiagnostic(this.unverifiedCompletions.length)
805
+ if (endedNames.length > 0) {
806
+ this.recoveryNotices.push(rewindEndedDiagnostic(endedNames))
759
807
  }
760
808
  // A completion the replay could not certify is not a lost binding: the
761
809
  // replay established them, and only the recorded payload stays unproved.
762
- if (this.recoveryNotice !== undefined) {
810
+ if (this.unverifiedCompletions.length > 0) {
811
+ this.recoveryNotices.push(unverifiedCompletionDiagnostic(this.unverifiedCompletions.length))
812
+ }
813
+ if (this.recoveryNotices.length > 0) {
763
814
  // Remember what was promised as live, so a binding that disappears later
764
815
  // is reported by name instead of surfacing as an unexplained ReferenceError.
765
816
  for (const name of liveNames) this.announcedLive.add(name)
766
817
  // The help pair explains the recovery once. Repeating it on every later
767
818
  // recovery in the same session is noise the model cannot act on twice.
768
- const signature = this.recoveryNotice.message
769
- if (this.recoveryHelpSignature === signature) this.recoveryNotice = { ...this.recoveryNotice, help: [] }
770
- else this.recoveryHelpSignature = signature
819
+ const signature = this.recoveryNotices.map(item => item.message).join('\n')
820
+ if (this.recoveryHelpSignature === signature) {
821
+ this.recoveryNotices = this.recoveryNotices.map(item => ({ ...item, help: [] }))
822
+ } else {
823
+ this.recoveryHelpSignature = signature
824
+ }
771
825
  }
772
826
  this.restoredFromRecord = []
773
827
  this.missingBindings = undefined
774
828
  this.rewindEndedBindings = undefined
829
+ this.supersededEndedBindings = undefined
830
+ this.supersessions = undefined
775
831
  }
776
- const leadingDiagnostics = [
777
- ...(this.recoveryNotice === undefined || this.recoveryNoticeShown ? [] : [this.recoveryNotice]),
778
- ]
779
- this.recoveryNoticeShown = this.recoveryNotice !== undefined
780
- this.recoveryNotice = undefined
832
+ // Every recovery reports the facts it established. Suppressing a later recovery's
833
+ // notice hid a relation the runtime had already marked as announced, so a name the
834
+ // revision withdrew was reported nowhere; what is deduplicated is the explanation
835
+ // (below) and each name or relation this runtime already stated.
836
+ const leadingDiagnostics = this.recoveryNotices
837
+ this.recoveryNotices = []
781
838
  if (request.journal !== undefined) request.journal.diagnostics.push(...leadingDiagnostics)
782
839
  let result
783
840
  try {
@@ -938,9 +995,17 @@ const failedNames = [...new Set(this.restoredFromRecord
938
995
  // correct.
939
996
  const installed = new Set(replayedNames)
940
997
  for (const name of this.bindingCatalog.inputs().knownBindings) installed.add(name)
941
- this.rewindEndedBindings = [...endedBindings].filter(name => !installed.has(name))
998
+ // A superseded name is ended by the record that replaced its cell, so it is
999
+ // reported apart from a rewind and never as a binding recovery failed to
1000
+ // install: the log says which revision replaced it.
1001
+ const supersededEnded = new Set((this.history.supersededBindings ?? [])
1002
+ .filter(name => !installed.has(name)))
1003
+ this.rewindEndedBindings = [...endedBindings]
1004
+ .filter(name => !installed.has(name) && !supersededEnded.has(name))
1005
+ this.supersededEndedBindings = [...supersededEnded]
1006
+ this.supersessions = this.history.supersessions ?? []
942
1007
  this.missingBindings = (this.history.provenBindings ?? [])
943
- .filter(name => !installed.has(name) && !endedBindings.has(name))
1008
+ .filter(name => !installed.has(name) && !endedBindings.has(name) && !supersededEnded.has(name))
944
1009
  }
945
1010
 
946
1011
  completeJournal(journal, status, result, volatileReason, diagnostics = [], completion = undefined, values = undefined) {
@@ -1372,7 +1437,8 @@ export class SessionRuntime {
1372
1437
  kernel.surfaceGeneration = surfaceGeneration(session)
1373
1438
  } else {
1374
1439
  const timeline = foldSessionTimeline(observed.events)
1375
- const history = recoverJournal(observed, undefined, { visibleCallSeqs: visible })
1440
+ const history = recoverJournal(observed, undefined, { visibleCallSeqs: visible,
1441
+ editRewritesHistory: this.config.editRewritesHistory === true })
1376
1442
  const head = history.nodes[history.head]
1377
1443
  if (head === undefined) return unavailableReplMemorySnapshot()
1378
1444
  // A cold inventory can describe a proved recorded frontier without
@@ -1468,6 +1534,7 @@ export class SessionRuntime {
1468
1534
  ? emptyHistory()
1469
1535
  : recoverJournal(sessionLog, callSeq, {
1470
1536
  visibleCallSeqs: visibleExecutableCallSeqs(sessionLog),
1537
+ editRewritesHistory: cellConfig.editRewritesHistory === true,
1471
1538
  ...(liveOnlyLost ? { liveOnlyLost: true } : {}),
1472
1539
  })
1473
1540
  } catch (error) {
@@ -1,5 +1,12 @@
1
1
  import { PTC_MESSAGE_SOURCE_KIND } from './message-sources.js'
2
+ import { sessionExecutionAvailable } from './host-tool-sdk.js'
2
3
  import { bindingToggleMessage } from './binding-toggle-receipt.js'
4
+ import {
5
+ bindingAdoptionMessage,
6
+ clearedBindingAdoption,
7
+ declinedBindingEntries,
8
+ withBindingAdoption,
9
+ } from './binding-adoption.js'
3
10
  import { RPC_CONTRACTS } from './rpc-contract.js'
4
11
  import { randomUUID } from 'node:crypto'
5
12
  import { dirname } from 'node:path'
@@ -26,6 +33,7 @@ import {
26
33
  createUserBindingsSnapshot,
27
34
  normalizeUserBindingEntry,
28
35
  normalizeUserBindingsDocument,
36
+ selectUserBindingsSnapshot,
29
37
  storedUserBindingsDocument,
30
38
  } from './user-bindings.js'
31
39
 
@@ -78,6 +86,40 @@ function parseAuthoringCommand(rawInput) {
78
86
  return { kind: 'help' }
79
87
  }
80
88
 
89
+ /**
90
+ * The adoption subcommands of `/binding`. They state which supplied entries this
91
+ * session uses; authoring and adoption share one command because both answer
92
+ * questions about the same global catalog, and a second command would suggest
93
+ * they were separate features.
94
+ */
95
+ function parseAdoptionCommand(rawInput) {
96
+ const input = typeof rawInput === 'string' ? rawInput.trim() : ''
97
+ if (input === 'status') return { kind: 'status' }
98
+ if (input === 'reset') return { kind: 'reset' }
99
+ const decision = /^(use|skip)\s+(\S+)$/.exec(input)
100
+ if (decision === null) return undefined
101
+ return { kind: decision[1] === 'use' ? 'use' : 'skip', id: decision[2] }
102
+ }
103
+
104
+ function adoptionStatusText(catalog, adoption) {
105
+ const declined = declinedBindingEntries(adoption)
106
+ const supplied = catalog.entries
107
+ const listed = supplied.map(entry => `${entry.id} (${entry.name}): ${declined.has(entry.id)
108
+ ? 'not used by this session' : 'used by this session'}`)
109
+ const unavailable = [...declined].filter(id => !supplied.some(entry => entry.id === id))
110
+ return [
111
+ 'Global binding catalog supply and this session\'s adoption of it. Adoption decides which supplied entries this session uses; it does not report whether the worker installed them.',
112
+ ...(listed.length === 0 ? ['The global catalog supplies no enabled entries.'] : listed),
113
+ ...(unavailable.length === 0 ? [] : [`Declined but not supplied by the global catalog, therefore not used and not revivable here: ${JSON.stringify(unavailable)}`]),
114
+ ].join('\n')
115
+ }
116
+
117
+ function adoptionReceipt(adoption) {
118
+ return `Session binding adoption recorded. This session declines ${JSON.stringify([...declinedBindingEntries(adoption)])}. `
119
+ + 'The global catalog and other sessions are unchanged, and adoption can only withdraw a supplied entry. '
120
+ + 'Whether the worker installed the entries this session still uses is reported with the next accepted cell, not by this receipt.'
121
+ }
122
+
81
123
  function authoringTask(command, current) {
82
124
  if (command.kind === 'new') {
83
125
  return `Create a Global User Binding draft for this requirement:\n\n${command.requirement}`
@@ -265,6 +307,48 @@ export function createUserBindingsOwner(ctx, options = {}) {
265
307
  const requireEnabled = () => {
266
308
  if (!enabled) throw new Error('Global User Bindings are disabled')
267
309
  }
310
+ // Adoption is read from the session's own recorded decision, never from a
311
+ // second in-memory map: the log is the owner, so a restart reconstructs the
312
+ // same environment without a migration step.
313
+ const adoptionForAgent = (agent) => {
314
+ if (typeof options.bindingAdoptionForAgent !== 'function' || agent === undefined || agent === null) return undefined
315
+ return options.bindingAdoptionForAgent(agent)
316
+ }
317
+ /**
318
+ * Supply minus this session's declines. The catalog decides which entries
319
+ * exist; adoption only withdraws one, so no entry can appear here that the
320
+ * global configuration did not supply.
321
+ */
322
+ const adoptedCatalog = (snapshot, adoption) => {
323
+ const declined = declinedBindingEntries(adoption)
324
+ if (declined.size === 0) return snapshot
325
+ const kept = snapshot.entries.filter(entry => !declined.has(entry.id))
326
+ if (kept.length === snapshot.entries.length) return snapshot
327
+ return selectUserBindingsSnapshot(snapshot, kept.map(entry => entry.id))
328
+ }
329
+ const recordAdoption = (agent, adoption) => {
330
+ const session = agent?.session
331
+ if (session === undefined || typeof session.append !== 'function') {
332
+ throw new Error('binding adoption requires a live session')
333
+ }
334
+ if (session.append('user/message', bindingAdoptionMessage(adoption), { surfaceOp: 'append' }) === undefined) {
335
+ throw new Error('the session did not record the binding adoption decision')
336
+ }
337
+ return { kind: 'success', text: adoptionReceipt(adoption) }
338
+ }
339
+ const stateAdoption = async (agent, command) => {
340
+ requireEnabled()
341
+ // Status and validation read the full supplied catalog: an adopted subset
342
+ // cannot prove what it was derived from.
343
+ const catalog = await store.snapshot()
344
+ const current = adoptionForAgent(agent)
345
+ if (command.kind === 'status') return { kind: 'success', text: adoptionStatusText(catalog, current) }
346
+ if (command.kind === 'reset') return recordAdoption(agent, clearedBindingAdoption())
347
+ if (!catalog.entries.some(entry => entry.id === command.id)) {
348
+ throw new Error(`the global binding catalog does not supply entry ${JSON.stringify(command.id)}; this session can decline a supplied entry but cannot add or revive one`)
349
+ }
350
+ return recordAdoption(agent, withBindingAdoption(current, command.id, command.kind === 'use'))
351
+ }
268
352
  const draftFor = (capability) => {
269
353
  if (typeof capability !== 'string' || capability === '') {
270
354
  throw new TypeError('draft capability must be a non-empty string')
@@ -482,7 +566,7 @@ export function createUserBindingsOwner(ctx, options = {}) {
482
566
  const requireCurrentAgent = () => {
483
567
  if (!agentIsCurrent(agent, sessionId, agentGeneration)
484
568
  || commandRegistrations.get(sessionId)?.agent !== agent
485
- || ctx.tools.get('run_code', agent) === undefined) {
569
+ || !sessionExecutionAvailable(ctx.tools, agent)) {
486
570
  throw new Error('this binding authoring Agent is no longer active')
487
571
  }
488
572
  }
@@ -617,6 +701,14 @@ export function createUserBindingsOwner(ctx, options = {}) {
617
701
 
618
702
  const commandHandler = async ({ agent, rawInput, signal, commandId }) => {
619
703
  signal?.throwIfAborted?.()
704
+ const adoption = parseAdoptionCommand(rawInput)
705
+ if (adoption !== undefined) {
706
+ try {
707
+ return await stateAdoption(agent, adoption)
708
+ } catch (error) {
709
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
710
+ }
711
+ }
620
712
  const command = parseAuthoringCommand(rawInput)
621
713
  if (command.kind === 'help') return { kind: 'error', text: `Usage: ${COMMAND_USAGE}` }
622
714
  try {
@@ -660,14 +752,14 @@ export function createUserBindingsOwner(ctx, options = {}) {
660
752
  // still claiming one.
661
753
  if (disposed || !enabled || lifecycleGeneration !== registration.generation
662
754
  || !agentIsCurrent(agent, sessionId, agentGeneration)
663
- || ctx.tools.get('run_code', agent) === undefined
755
+ || !sessionExecutionAvailable(ctx.tools, agent)
664
756
  || commandRegistrations.get(sessionId) !== registration) {
665
757
  throw new Error('Global User Bindings binding command registration is no longer eligible')
666
758
  }
667
759
  registerOwned(commandCtx, commandCtx.commands, {
668
760
  name: 'binding',
669
- description: 'Create or revise a Global User Binding draft with the current agent',
670
- input: { hint: 'new <requirement> | edit <id> <requirement>' },
761
+ description: 'Author a Global User Binding draft, or state which supplied entries this session uses',
762
+ input: { hint: 'new <requirement> | edit <id> <requirement> | status | use <id> | skip <id> | reset' },
671
763
  handler: commandHandler,
672
764
  }, 'binding command')
673
765
  })
@@ -712,13 +804,13 @@ export function createUserBindingsOwner(ctx, options = {}) {
712
804
  }
713
805
  if (!agentIsCurrent(agent, sessionId, agentGeneration)) return
714
806
  const eligible = enabled && draftProjectionAvailable && !disposed
715
- && ctx.tools.get('run_code', agent) !== undefined
807
+ && sessionExecutionAvailable(ctx.tools, agent)
716
808
  const current = commandRegistrations.get(sessionId)
717
809
  if (eligible && current?.agent === agent) return
718
810
  if (current !== undefined) await removeCommandFor(sessionId, current)
719
811
  if (!agentIsCurrent(agent, sessionId, agentGeneration)) return
720
812
  const stillEligible = enabled && draftProjectionAvailable && !disposed
721
- && ctx.tools.get('run_code', agent) !== undefined
813
+ && sessionExecutionAvailable(ctx.tools, agent)
722
814
  if (stillEligible) {
723
815
  registerCommandFor(agent)
724
816
  return
@@ -918,6 +1010,17 @@ export function createUserBindingsOwner(ctx, options = {}) {
918
1010
  async snapshot() {
919
1011
  return enabled ? store.snapshot() : undefined
920
1012
  },
1013
+ /**
1014
+ * The catalog entries one session's worker should receive. Without a stated
1015
+ * adoption this is exactly the supplied catalog, so 0.5.2 behavior is
1016
+ * unchanged for every session that never declines an entry.
1017
+ */
1018
+ async snapshotForAgent(agent) {
1019
+ if (!enabled) return undefined
1020
+ const snapshot = await store.snapshot()
1021
+ const adoption = adoptionForAgent(agent)
1022
+ return adoption === undefined ? snapshot : adoptedCatalog(snapshot, adoption)
1023
+ },
921
1024
  async list() {
922
1025
  requireEnabled()
923
1026
  return store.list()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ptc-plus",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "icon": "assets/ptc-plus-icon.svg",
5
5
  "description": "A session-bound agent-native REPL for DeepSeek Harness PTC mode.",
6
6
  "type": "module",
@@ -69,9 +69,15 @@
69
69
  "@deepseek-ai/dsh-skill-filesystem": "*",
70
70
  "@deepseek-ai/dsh-spill-policy": "*",
71
71
  "@deepseek-ai/dsh-tool-cordis": "*",
72
+ "@deepseek-ai/dsh-tool-executor-ptc": "*",
72
73
  "@deepseek-ai/dsh-tools": "*",
73
74
  "@deepseek-ai/dsh-typert-protocol": "*"
74
75
  },
76
+ "peerDependenciesMeta": {
77
+ "@deepseek-ai/dsh-tool-executor-ptc": {
78
+ "optional": true
79
+ }
80
+ },
75
81
  "scripts": {
76
82
  "build": "node scripts/client-bundle.mjs build && node scripts/compiler-bundle.mjs build",
77
83
  "build:check": "node scripts/client-bundle.mjs check && node scripts/compiler-bundle.mjs check",
@@ -88,8 +94,9 @@
88
94
  "test:client:fallback": "node scripts/client-fallback-smoke.mjs",
89
95
  "test:client:layout": "node scripts/client-binding-layout.mjs",
90
96
  "readme:screenshots": "node scripts/readme-screenshots.mjs",
91
- "host:baseline:update": "node scripts/dsh-dependency-baseline.mjs update",
92
- "host:baseline:verify": "node scripts/dsh-dependency-baseline.mjs verify",
97
+ "host:channels:update": "node scripts/host-channel-manifest.mjs update",
98
+ "host:channels:graph": "node scripts/host-channel-manifest.mjs graph",
99
+ "host:channels:verify": "node scripts/host-channel-manifest.mjs verify",
93
100
  "test:host-contract": "node scripts/dsh-host-contract-suite.mjs",
94
101
  "prepack": "npm run build:check",
95
102
  "hooks:install": "node scripts/review-findings.mjs install-hook",
@@ -109,14 +116,15 @@
109
116
  "test:backend": "node scripts/test-suite-files.mjs",
110
117
  "test:backend:fast": "node scripts/test-suite-files.mjs --fast",
111
118
  "test:ci:runtime": "node --test --test-concurrency=1 test/package-contract.test.js test/compiler-platform.test.js test/direct-surface-policy.test.js test/isolated-entrance.test.js test/plugin-native-call-transport.test.js",
112
- "verify": "npm run review:validate && npm run host:baseline:verify && npm run semantic:check && npm run build:check && node --check index.js && node --check internal/kernel-worker.js && node --check scripts/migrate-session-log.mjs && node --check scripts/expensive-headless-acceptance.mjs && node --check scripts/ab-headless-trajectories.mjs && node scripts/deterministic-test-groups.mjs",
113
- "verify:check": "npm run review:validate && npm run host:baseline:verify && npm run semantic:check && npm run build:check && node --check index.js && node --check internal/kernel-worker.js && node scripts/deterministic-test-groups.mjs --check",
114
- "verify:ci": "npm run review:validate && npm run host:baseline:verify && npm run semantic:check && npm run build:check && node --check index.js && node --check internal/kernel-worker.js && node --check scripts/migrate-session-log.mjs && node scripts/deterministic-test-groups.mjs --ci",
119
+ "verify": "npm run review:validate && npm run host:channels:verify && npm run host:shape-check && npm run semantic:check && npm run build:check && node --check index.js && node --check internal/kernel-worker.js && node --check scripts/migrate-session-log.mjs && node --check scripts/expensive-headless-acceptance.mjs && node --check scripts/ab-headless-trajectories.mjs && node scripts/deterministic-test-groups.mjs",
120
+ "verify:check": "npm run review:validate && npm run host:channels:verify && npm run host:shape-check && npm run semantic:check && npm run build:check && node --check index.js && node --check internal/kernel-worker.js && node scripts/deterministic-test-groups.mjs --check",
121
+ "verify:ci": "npm run review:validate && npm run host:channels:verify && npm run host:shape-check && npm run semantic:check && npm run build:check && node --check index.js && node --check internal/kernel-worker.js && node --check scripts/migrate-session-log.mjs && node scripts/deterministic-test-groups.mjs --ci",
115
122
  "check": "node scripts/review-findings.mjs check",
116
123
  "verify:docs": "node scripts/review-findings.mjs docs",
117
124
  "test:expensive": "node scripts/expensive-headless-acceptance.mjs",
118
125
  "test:ab": "node scripts/ab-headless-trajectories.mjs",
119
- "test:package:consumer": "node scripts/npm-consumer-smoke.mjs"
126
+ "test:package:consumer": "node scripts/npm-consumer-smoke.mjs",
127
+ "host:shape-check": "node scripts/check-host-version-literals.mjs"
120
128
  },
121
129
  "keywords": [
122
130
  "deepseek-harness",
@@ -9,10 +9,28 @@ import {
9
9
  accountPromptText, parsePromptTextDocument, resolvePromptText,
10
10
  } from '../internal/prompt-text.js'
11
11
 
12
+ /**
13
+ * One capability, three separate facts, in the reader's language. Saved,
14
+ * delivered and usable are different questions, so a reader must be able to see
15
+ * which one failed instead of reading one blanket "applied".
16
+ */
17
+ export function capabilityProofCopy(t, proof) {
18
+ const delivery = ['delivered', 'pending', 'absent'].includes(proof?.delivery) ? proof.delivery : undefined
19
+ const usability = ['proven', 'unproven'].includes(proof?.usability) ? proof.usability : undefined
20
+ if (delivery === undefined || usability === undefined) return t('capabilities.quick.unknown')
21
+ return t('capabilities.proof.line', {
22
+ saved: t(proof.saved === true ? 'capabilities.proof.saved.yes' : 'capabilities.proof.saved.no'),
23
+ delivery: t(`capabilities.proof.delivery.${delivery}`),
24
+ usability: t(`capabilities.proof.usability.${usability}`),
25
+ })
26
+ }
27
+
12
28
  export function capabilityCommandText(t, text) {
13
29
  if (text?.key) return t(text.key, text.params)
14
30
  const receipt = readCapabilityCommandReceipt(text)
15
- return receipt ? t(`capabilities.receipt.${receipt.state}`, { session: receipt.sessionId, error: receipt.error ?? '' }) : text
31
+ if (!receipt) return text
32
+ const line = t(`capabilities.receipt.${receipt.state}`, { session: receipt.sessionId, error: receipt.error ?? '' })
33
+ return receipt.proof === undefined ? line : `${line} ${capabilityProofCopy(t, receipt.proof)}`
16
34
  }
17
35
 
18
36
  // Host-owned context the model sees in PTC mode. The plugin never receives the host's
@@ -115,9 +133,14 @@ export function createCapabilityCommandCard(React) {
115
133
  : node.outcome ? (typeof text === 'string' && !text.trim() ? undefined : capabilityCommandText(t, text))
116
134
  ?? t(node.outcome.kind === 'error' ? 'capabilities.commandFailed' : 'capabilities.refreshAcknowledged')
117
135
  : t('capabilities.refreshing')
136
+ // A command that names one capability carries that capability's three
137
+ // facts; the notice states them beside its session outcome so "saved"
138
+ // cannot be read as "delivered" or "usable".
139
+ const notice = receipt?.proof === undefined || outcome === undefined
140
+ ? outcome : `${outcome} ${capabilityProofCopy(t, receipt.proof)}`
118
141
  return h('p', { className: 'ptcPlusCapabilityCommandNotice',
119
142
  role: node.outcome?.kind === 'error' ? 'alert' : 'status',
120
- 'data-state': node.outcome?.kind ?? 'pending' }, outcome)
143
+ 'data-state': node.outcome?.kind ?? 'pending' }, notice)
121
144
  }
122
145
  let json
123
146
  if (typeof text === 'string') {
@@ -1427,6 +1450,10 @@ export function createCapabilityRefreshView(React, commands) {
1427
1450
  export function createSessionCapabilityControlsView(React, { commands, Modal, Select, inspectSessionCatalog }) {
1428
1451
  const h = React.createElement
1429
1452
  const emptyDrafts = () => ({})
1453
+ // The panel reports the three capability facts only where it holds evidence:
1454
+ // the receipts its own commands returned. A catalogue row says what the plan
1455
+ // will do; it cannot say a cell reached the capability.
1456
+ const emptyProofs = () => ({})
1430
1457
  return function SessionCapabilityControls({ sessionId, open, enabled, t, onClose }) {
1431
1458
  const source = React.useMemo(() => commands.source(sessionId), [sessionId])
1432
1459
  const available = React.useSyncExternalStore(source.subscribe, source.getSnapshot)
@@ -1448,6 +1475,7 @@ export function createSessionCapabilityControlsView(React, { commands, Modal, Se
1448
1475
  const [sort, setSort] = React.useState(null)
1449
1476
  const [focusDimension, setFocusDimension] = React.useState(null)
1450
1477
  const [status, setStatus] = React.useState(null)
1478
+ const [proofs, setProofs] = React.useState(emptyProofs)
1451
1479
  const [pending, setPending] = React.useState(false)
1452
1480
  const request = React.useRef(null)
1453
1481
  const applying = React.useRef(false)
@@ -1480,6 +1508,7 @@ export function createSessionCapabilityControlsView(React, { commands, Modal, Se
1480
1508
  setPane('list')
1481
1509
  setFocusDimension(null)
1482
1510
  setStatus(null)
1511
+ setProofs(emptyProofs())
1483
1512
  setPending(false)
1484
1513
  setError(null)
1485
1514
  }, [sessionId])
@@ -1646,15 +1675,20 @@ export function createSessionCapabilityControlsView(React, { commands, Modal, Se
1646
1675
  let pendingCount = 0
1647
1676
  let failure = null
1648
1677
  let savedOnly = null
1678
+ const observed = {}
1649
1679
  for (const [id, selection, dimension] of staged) {
1650
1680
  if (controller.signal.aborted) break
1681
+ const line = `set ${targetScope} ${encodeURIComponent(id)} ${dimension ? dimension + ' ' : ''}${selection}`
1651
1682
  try {
1652
- const result = await commands.run(sessionId, `set ${targetScope} ${encodeURIComponent(id)} ${dimension ? dimension + ' ' : ''}${selection}`, controller.signal)
1683
+ const result = await commands.run(sessionId, line, controller.signal)
1653
1684
  const receipt = readCapabilityCommandReceipt(result.text)
1654
1685
  if (!receipt || receipt.sessionId !== sessionId || !['recorded', 'pending'].includes(receipt.state)) {
1655
1686
  throw new Error(receipt ? capabilityCommandText(t, result.text) : t('capabilities.quick.unknownReceipt'))
1656
1687
  }
1657
1688
  if (receipt?.state === 'pending') pendingCount += 1
1689
+ // A receipt that names the capability carries the only proof this
1690
+ // panel can hold: what the save did and did not establish.
1691
+ if (receipt.proof !== undefined) observed[receipt.proof.id] = { proof: receipt.proof, command: `/capabilities ${line}` }
1658
1692
  completed += 1
1659
1693
  } catch (cause) {
1660
1694
  failure = String(cause?.message ?? cause)
@@ -1663,6 +1697,7 @@ export function createSessionCapabilityControlsView(React, { commands, Modal, Se
1663
1697
  break
1664
1698
  }
1665
1699
  }
1700
+ if (mounted.current && Object.keys(observed).length) setProofs(current => ({ ...current, ...observed }))
1666
1701
  if (request.current !== controller || controller.signal.aborted || !mounted.current) return
1667
1702
  const refreshed = await load(controller.signal)
1668
1703
  if (request.current !== controller || controller.signal.aborted || !mounted.current) return
@@ -1900,6 +1935,13 @@ export function createSessionCapabilityControlsView(React, { commands, Modal, Se
1900
1935
  h('dt', null, t('capabilities.quick.effective')), h('dd', null, statusLabel(selected.effective)),
1901
1936
  selected.sourceDimensions ? h(React.Fragment, null, h('dt', null, t('capabilities.policy.frozenSource')), h('dd', null, dimensionFacts(selected.sourceDimensions))) : null,
1902
1937
  selected.effectiveDimensions ? h(React.Fragment, null, h('dt', null, t('capabilities.policy.effectiveDimensions')), h('dd', null, dimensionFacts(selected.effectiveDimensions))) : null),
1938
+ h('div', { className: 'ptcPlusCapabilityProof', 'data-capability-proof': selected.id },
1939
+ h('h4', null, t('capabilities.proof.title')),
1940
+ proofs[selected.id]
1941
+ ? h(React.Fragment, null,
1942
+ h('p', { className: 'ptcPlusDetail', role: 'status' }, capabilityProofCopy(t, proofs[selected.id].proof)),
1943
+ h('small', null, t('capabilities.proof.observed', { command: proofs[selected.id].command })))
1944
+ : h('p', { className: 'ptcPlusDetail' }, t('capabilities.proof.unobserved'))),
1903
1945
  selected.source?.kind === 'mcp' && selected.category !== 'server'
1904
1946
  ? (() => {
1905
1947
  const server = catalog.find(entry => entry.category === 'server' && entry.source?.server === selected.source.server)