dsh-bettertaskgraphs 0.2.3 → 0.3.0-rc.11

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/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # dsh-bettertaskgraphs
2
2
 
3
+ 当前候选版本为 `0.3.0-rc.11`。独立运行保留动态上下文开发前 `0.2.3` 的五个工具、初始指引与事务语义;通用扩展接口供其他插件登记工作流和会话策略。动态上下文装配、修改审批及其提示词由扩展插件实现。
4
+
5
+ 配套扩展:[dsh-dynamic-context](https://github.com/oldcitynight/dsh-dynamic-context)。本候选的验证范围与复现方式见 [VALIDATION.md](VALIDATION.md)。
6
+
3
7
  DeepSeek Harness 的事务型任务图 Bundle,包含模型侧的任务图工具和会话输入区的任务图面板。支持嵌套容器、条件分支、单一活动叶节点,以及基于 DSH 会话证据的提交与对账。
4
8
 
5
9
  面板的事务关系区列出进入条件及对应任务状态。点击「全屏」后,沿连线从左往右阅读。可沿同一条前置链推出的重复条件会合并显示。独立的跨列前置留在目标卡片的进入条件中,并在卡片上标记数量。点击节点可切换所选事务,按 Esc 返回会话。
@@ -18,7 +22,7 @@ npm test
18
22
  npm run pack:bundle
19
23
  ```
20
24
 
21
- `build` 从前端源码生成可由 DSH Web 加载的 `client.js`。`check` 校验生成物与源码一致,`pack:bundle` 会重新构建前端并生成 `dist/dsh-bettertaskgraphs-0.2.3.tgz`。包内包含服务端模块、Web 构建产物、对应源码和构建脚本,以及 Bundle 配置补丁。
25
+ `build` 从前端源码生成可由 DSH Web 加载的 `client.js`。`check` 校验生成物与源码一致,`pack:bundle` 会重新构建前端并生成 `dist/dsh-bettertaskgraphs-0.3.0-rc.11.tgz`。包内包含服务端模块、Web 构建产物、对应源码和构建脚本,以及 Bundle 配置补丁。
22
26
 
23
27
  ## 安装到独立 Web profile
24
28
 
@@ -26,7 +30,7 @@ npm run pack:bundle
26
30
 
27
31
  ```sh
28
32
  dsh --profile bettertaskgraphs --from-default-profile web --dump-config
29
- dsh plugin --profile bettertaskgraphs add "$PWD/dist/dsh-bettertaskgraphs-0.2.3.tgz"
33
+ dsh plugin --profile bettertaskgraphs add "$PWD/dist/dsh-bettertaskgraphs-0.3.0-rc.11.tgz"
30
34
  dsh --profile bettertaskgraphs --dump-config
31
35
  dsh --profile bettertaskgraphs
32
36
  ```
@@ -49,6 +53,45 @@ Bundle 默认将会话创建时的工作目录作为项目根,账本位于根
49
53
 
50
54
  [DSH 官方 Bundle 打包文档](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.2.1-rc.5/docs/user/develop/basic/publish.zh.md)
51
55
 
56
+ ## 插件间联动 API v1
57
+
58
+ 公共模块导出为 `dsh-bettertaskgraphs/core`、`/store`、`/host`、`/interop`。消费者无需再访问包内私有路径。`ctx.transactionGraph.workflowContract` 固定为 `dsh-taskgraph-workflow-v1`。
59
+
60
+ 宿主插件通过 `registerWorkflowProvider(adapter)` 登记一个联动工作流,返回取消登记函数。adapter 必须给出 contract、version:1、kind、workflowId、真实 projectRoot、operations、authorize、read、execute;subscribe 可发布已提交后的变更通知。重复标识拒绝覆盖,取消登记会取消该入口发起的在途执行。
61
+
62
+ 控制器端接口:
63
+
64
+ ```js
65
+ const graph = ctx.transactionGraph
66
+ const ref = { kind: 'dynamic-context', workflowId: 'project-workflow' }
67
+ const workflows = graph.listWorkflows(controllerAgent)
68
+ const snapshot = graph.readWorkflow(controllerAgent, ref)
69
+ const result = await graph.dispatchWorkflow(controllerAgent, ref, 'run_next', {
70
+ expectedRevision: snapshot.revision,
71
+ }, signal)
72
+ const unsubscribe = graph.subscribeWorkflows(notice => {
73
+ // notice 只含 contract、kind、workflowId、type、revision;重新读取提交后的状态。
74
+ })
75
+ ```
76
+
77
+ 动态上下文提供 `submit`、`status`、`run_next`;durable 模式另提供 `pause`、`resume`、`reconcile`、`maintenance`。submit 的 request 为 `{proposal:{taskGraph,contextGraph}}`,两张图仍由动态上下文的一个 CAS 文档原子提交。read 返回同一真实任务图、contextVersion、报告引用和执行状态;不返回原始上下文和报告正文。恢复/对账仍须精确 revision、attempt 与检查点票据,不能通过联动接口绕过。
78
+
79
+ 入口只接受当前注册的真实控制器、原始与当前工作目录均在本项目内;worker 不能提升成控制器。参数由对应 provider 严格校验,来源 Agent 和取消信号由宿主绑定,模型参数不能替换。通知监听器异常不会回滚已提交状态。
80
+
81
+ 联动工作流和原生事务图有明确的存储域。现有 `transaction_graph_*` 工具及 Web 面板仍读取其原有会话账本,不镜像动态图,不把模型报告转成正式事务成功。联动 API 也不会自动完成 Goal 或接管 Web 聊天入口。
82
+
83
+ ## 通用会话扩展接口
84
+
85
+ `extensionContract` 为 `dsh-taskgraph-session-extension-v1`,公共入口为 `/extensions`。
86
+
87
+ - `registerSessionPolicy({id, allowClassic(agent)})` 返回注销函数。无策略时原有工具与指引保持启用;扩展可对单个会话禁止经典图写入与提示,工具可见性由扩展的会话作用域控制。
88
+ - `createExtensionStore(options)` 创建通用 CAS 存储,不实现扩展业务。默认容量保持原值,调用方可以显式选择容量。
89
+ - `inspectGraphPresence(agent)` 根据原生持久化日志和提交收据,区分从未创建、创建待提交、已存在、提交后丢失/损坏以及不确定的创建记录。缺失历史图不能当作空白会话重建。
90
+
91
+ 独立图依然由五个原有工具推进。rc.11 移除了早期候选中的 `workflowOnly`、`revision` 配置、`transaction_graph_revise` 和 `/revision*` 导出;使用这些早期候选接口的宿主需要调整为扩展插件。结构性 `REVISED` 事件仍可由受信扩展使用,旧已提交 V4 事件可读;遗留待提交修订不会跳过原扩展审批自动落盘。
92
+
93
+ `artifactProof` 默认上限恢复为 16 MiB;扩展可通过第四参数 `{maxBytes}` 明确扩大自身证据范围,不改变独立任务图默认值。
94
+
52
95
  ## 许可证
53
96
 
54
97
  Copyright (C) 2026 oldcitynight and contributors.
package/VALIDATION.md ADDED
@@ -0,0 +1,22 @@
1
+ # Candidate validation: 0.3.0-rc.11
2
+
3
+ Validated on 2026-11-13 with Node.js 24.22.2 and DSH 0.2.1-rc.5.
4
+
5
+ - 103 task-graph tests passed with dynamic-context imports forbidden.
6
+ - A real DSH service mounted independently, exposing the original five tools and guidance.
7
+ - Tool definitions and work policy match the 0.2.3 baseline except for the file header; initial guidance is byte-identical.
8
+ - Client source/build parity passed.
9
+ - The packaged service mounted after an isolated offline installation.
10
+ - The companion dynamic-context candidate passed 348 source tests and 347 installed tests. One upstream-source comparison was skipped only in the installed package; it passed with the upstream source present.
11
+
12
+ These checks establish the tested program boundaries and fake-backend behavior. They do not certify real-model planning quality, browser interaction, or npm publication. The prior experiment records and frozen artifacts were retained unchanged.
13
+
14
+ To reproduce this repository's checks:
15
+
16
+ ```sh
17
+ npm ci
18
+ npm test
19
+ npm pack --dry-run
20
+ ```
21
+
22
+ The base package has no dependency on dynamic context. Optional extension logic must register through the public workflow/session interfaces and retain its own model prompts and approval policy.
package/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-only
2
- // Corresponding source: https://github.com/oldcitynight/dsh-bettertaskgraphs/tree/v0.2.3
2
+ // Corresponding source: https://github.com/oldcitynight/dsh-bettertaskgraphs/tree/v0.3.0-rc.11
3
3
  // Generated by scripts/build-client.mjs.
4
4
  window.__ModuleLoader__.load({id:"dsh-bettertaskgraphs",factory(require){
5
5
  // SPDX-License-Identifier: GPL-3.0-only
package/core.mjs CHANGED
@@ -266,6 +266,62 @@ function finishLeaf(state, data) {
266
266
  activate(state, active.nodeId, data.outcome, data.selectedCase)
267
267
  }
268
268
 
269
+ /** Normalize a new plan while retaining durable identities and all started work. */
270
+ export function revisedPlan(document, definition) {
271
+ validateDocument(document)
272
+ const previous = document.state.plan, plan = normalizePlan(definition, { enableSwitch: previous.enableSwitch })
273
+ const old = new Map(previous.nodes.map(node => [node.key, node]))
274
+ const historical = new Set(document.events.flatMap(event => event.data.plan?.nodes.map(node => node.key) ?? []))
275
+ for (const node of plan.nodes) if (!old.has(node.key) && historical.has(node.key)) fail('RETIRED_TASK_KEY_REUSE')
276
+ const ids = new Map(plan.nodes.map(node => [node.id, old.get(node.key)?.id ?? node.id]))
277
+ plan.id = previous.id
278
+ plan.nodes = plan.nodes.map(node => ({ ...node, id: ids.get(node.id), parent: ids.get(node.parent) ?? null }))
279
+ plan.edges = plan.edges.map(edge => {
280
+ const result = { ...edge, from: ids.get(edge.from), to: ids.get(edge.to) }
281
+ const match = previous.edges.find(row => canonical({ ...row, id: null }) === canonical({ ...result, id: null }))
282
+ return match ? copy(match) : result
283
+ })
284
+ validateRevisionTransition(document.state, plan)
285
+ return plan
286
+ }
287
+ export function validateRevisionTransition(state, plan) {
288
+ if (state.active) fail('REVISION_REQUIRES_NO_ACTIVE_ATTEMPT')
289
+ validatePlan(plan)
290
+ if (state.plan.id !== plan.id || state.plan.enableSwitch !== plan.enableSwitch) fail('REVISION_GRAPH_IDENTITY_CHANGED')
291
+ if (canonical(state.plan.edges.filter(edge => edge.kind !== 'prerequisite')) !== canonical(plan.edges.filter(edge => edge.kind !== 'prerequisite')))
292
+ fail('REVISION_BRANCH_POLICY_IMMUTABLE')
293
+ const next = new Map(plan.nodes.map(node => [node.id, node]))
294
+ for (const node of state.plan.nodes) {
295
+ const leaf = state.leaves[node.id]
296
+ if (node.parent === null && (!next.has(node.id)
297
+ || canonical({ ...next.get(node.id), label: node.label }) !== canonical(node))) fail('REVISION_ROOT_IMMUTABLE')
298
+ if (leaf && !['PENDING', 'INACTIVE'].includes(leaf.status)) {
299
+ if (canonical(next.get(node.id)) !== canonical(node)) fail('STARTED_TASK_IMMUTABLE')
300
+ const incoming = p => p.edges.filter(edge => edge.to === node.id)
301
+ if (canonical(incoming(state.plan)) !== canonical(incoming(plan))) fail('STARTED_TASK_PREREQUISITES_IMMUTABLE')
302
+ }
303
+ if (next.has(node.id) && (next.get(node.id).key !== node.key || next.get(node.id).kind !== node.kind)) fail('REVISION_TASK_IDENTITY_CHANGED')
304
+ }
305
+ }
306
+ function reviseState(state, data) {
307
+ object(data, ['plan', 'review'], 'revision event')
308
+ validateRevisionTransition(state, data.plan)
309
+ const review = data.review
310
+ if (!review || review.verdict !== 'APPROVE' || review.baseRevision !== state.revision
311
+ || review.planChecksum !== digest(data.plan) || !/^[a-f0-9]{64}$/.test(review.proposalHash ?? '')
312
+ || typeof review.updaterRunId !== 'string' || typeof review.reviewerRunId !== 'string'
313
+ || !review.updaterRunId || !review.reviewerRunId || review.updaterRunId === review.reviewerRunId) fail('INDEPENDENT_REVISION_REVIEW_REQUIRED')
314
+ const incoming = new Set(data.plan.edges.filter(edge => edge.kind !== 'prerequisite').map(edge => edge.to))
315
+ state.leaves = Object.fromEntries(data.plan.nodes.filter(node => node.kind === 'leaf').map(node => [node.id,
316
+ state.leaves[node.id] ?? { status: incoming.has(node.id) ? 'INACTIVE' : 'PENDING', evidence: [], acceptance: 'UNVERIFIED_REFERENCE' }]))
317
+ state.plan = copy(data.plan)
318
+ // A newly added predeclared route may originate in an already finished leaf.
319
+ for (const [id, leaf] of Object.entries(state.leaves)) {
320
+ if (leaf.status === 'COMPLETE' || leaf.status === 'INCOMPLETE') activate(state, id,
321
+ leaf.status === 'COMPLETE' ? 'complete' : 'incomplete', leaf.selectedCase ?? null)
322
+ }
323
+ }
324
+
269
325
  // Deterministic replay is authoritative; snapshots are checked against it.
270
326
  export function replay(events) {
271
327
  if (!Array.isArray(events) || !events.length || events.length > LIMITS.events) fail('EVENT_LIMIT')
@@ -285,6 +341,8 @@ export function replay(events) {
285
341
  const leaves = Object.fromEntries(plan.nodes.filter(node => node.kind === 'leaf').map(node => [node.id,
286
342
  { status: incoming.has(node.id) ? 'INACTIVE' : 'PENDING', evidence: [], acceptance: 'UNVERIFIED_REFERENCE' }]))
287
343
  state = { schema: 1, revision: 0, plan, leaves, active: null, operations: [] }
344
+ } else if (event.type === 'REVISED') {
345
+ reviseState(state, event.data)
288
346
  } else if (event.type === 'BEGUN') {
289
347
  object(event.data, ['nodeId', 'attemptId', 'owner'], 'begin event')
290
348
  if (state.active || !legalNext(state).some(node => node.id === event.data.nodeId)
@@ -381,6 +439,13 @@ export class TransactionGraph {
381
439
  return { plan: normalizePlan(definition, { enableSwitch: this.enableSwitch, id: this.id }) }
382
440
  }); return view(result, this.owner)
383
441
  }
442
+ revise({ expectedRevision, operationId, plan, review }) {
443
+ const result = this.mutate(expectedRevision, operationId, 'REVISED', state => {
444
+ if (!state) fail('NO_PLAN')
445
+ validateRevisionTransition(state, plan)
446
+ return { plan: copy(plan), review: copy(review) }
447
+ }); return view(result, this.owner)
448
+ }
384
449
  begin({ expectedRevision, operationId, choice }) {
385
450
  const result = this.mutate(expectedRevision, operationId, 'BEGUN', state => {
386
451
  if (!state) fail('NO_PLAN')
package/extensions.mjs ADDED
@@ -0,0 +1,24 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ import { GraphError } from './core.mjs'
3
+
4
+ export const EXTENSION_CONTRACT = 'dsh-taskgraph-session-extension-v1'
5
+
6
+ /** Optional host extensions may suppress the classic interface per session.
7
+ * Policy, interaction and extension data belong to the registering plugin.
8
+ * With no provider, the original classic interface is unchanged.
9
+ */
10
+ export class SessionPolicies {
11
+ constructor() { this.providers = new Map(); this.closed = false }
12
+ register({ id, allowClassic }) {
13
+ if (this.closed || typeof id !== 'string' || !/^[a-z][a-z0-9_-]{0,79}$/.test(id)
14
+ || typeof allowClassic !== 'function' || this.providers.has(id)) throw new GraphError('SESSION_EXTENSION_CONTRACT_REQUIRED')
15
+ const entry = { allowClassic }; this.providers.set(id, entry)
16
+ return () => { if (this.providers.get(id) === entry) this.providers.delete(id) }
17
+ }
18
+ allowClassic(agent) {
19
+ if (this.closed) return false
20
+ for (const provider of this.providers.values()) if (provider.allowClassic(agent) !== true) return false
21
+ return true
22
+ }
23
+ close() { this.closed = true; this.providers.clear() }
24
+ }
package/host.mjs CHANGED
@@ -9,6 +9,8 @@ import { KEY, INTENT, RECEIPT, RECOVERY } from './projection.mjs'
9
9
  import { administrative, incompleteDisposition, ownedExecutionChain, unresolvedWork, workKey } from './work-policy.mjs'
10
10
 
11
11
  const fail = (code, detail = code) => { throw new GraphError(code, detail) }
12
+ // Preserve the standalone pre-extension default. Callers may opt into a policy.
13
+ export const ARTIFACT_MAX_BYTES = 16 * 1024 * 1024
12
14
  const under = (root, target) => { const relative = path.relative(root, target); return !path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep) }
13
15
  export function realProjectPath(root, requested, { directory = true } = {}) {
14
16
  if (typeof requested !== 'string' || !path.isAbsolute(requested)) fail('ABSOLUTE_PROJECT_PATH_REQUIRED')
@@ -38,15 +40,33 @@ function referenceSeq(reference, sessionId) {
38
40
  if (!Number.isSafeInteger(value) || value < 0) fail('EVIDENCE_BAD_SEQ')
39
41
  return value
40
42
  }
41
- export function artifactProof(projectRoot, ref, signal) {
43
+ export function artifactProof(projectRoot, ref, signal, { maxBytes = ARTIFACT_MAX_BYTES } = {}) {
42
44
  if (!ref.sha256) fail('ARTIFACT_SHA_REQUIRED')
45
+ const identity = readArtifactIdentity(projectRoot, ref, signal, { maxBytes })
46
+ if (identity.sha256 !== ref.sha256) fail('ARTIFACT_CHANGED_OR_HASH_MISMATCH')
47
+ return identity
48
+ }
49
+ /** Observation of a historically verified deliverable, never current acceptance. */
50
+ export function artifactCurrentIdentity(projectRoot, ref, signal, options) {
51
+ if (!ref.sha256) fail('ARTIFACT_SHA_REQUIRED')
52
+ try {
53
+ const identity = readArtifactIdentity(projectRoot, ref, signal, options)
54
+ return { ref: identity.path, acceptedSha256: ref.sha256, currentSha256: identity.sha256,
55
+ status: identity.sha256 === ref.sha256 ? 'UNCHANGED' : 'CHANGED', stat: identity.stat }
56
+ } catch (error) {
57
+ if (error.code !== 'ENOENT') throw error
58
+ return { ref: ref.ref, acceptedSha256: ref.sha256, currentSha256: null, status: 'MISSING' }
59
+ }
60
+ }
61
+ function readArtifactIdentity(projectRoot, ref, signal, { maxBytes = ARTIFACT_MAX_BYTES } = {}) {
62
+ if (maxBytes !== null && (!Number.isSafeInteger(maxBytes) || maxBytes < 1)) fail('ARTIFACT_BYTE_POLICY_INVALID')
43
63
  const requested = path.isAbsolute(ref.ref) ? ref.ref : path.resolve(projectRoot, ref.ref)
44
64
  const file = realProjectPath(projectRoot, requested, { directory: false })
45
65
  if (file.toLowerCase().endsWith('.gguf')) fail('MODEL_CONTENT_IS_NOT_TRANSACTION_ARTIFACT')
46
66
  const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
47
67
  try {
48
68
  const before = fs.fstatSync(fd, { bigint: true })
49
- if (!before.isFile() || before.size > 16n * 1024n * 1024n) fail('ARTIFACT_BYTE_LIMIT')
69
+ if (!before.isFile() || maxBytes !== null && before.size > BigInt(maxBytes)) fail('ARTIFACT_BYTE_LIMIT')
50
70
  const hash = createHash('sha256'), buffer = Buffer.alloc(65536)
51
71
  let total = 0n
52
72
  while (true) {
@@ -59,8 +79,8 @@ export function artifactProof(projectRoot, ref, signal) {
59
79
  const after = fs.fstatSync(fd, { bigint: true }), outside = fs.lstatSync(file, { bigint: true })
60
80
  const keys = ['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs']
61
81
  if (total !== before.size || keys.some(key => before[key] !== after[key] || before[key] !== outside[key])
62
- || fs.realpathSync(file) !== file || hash.digest('hex') !== ref.sha256) fail('ARTIFACT_CHANGED_OR_HASH_MISMATCH')
63
- return { kind: 'artifact', refDigest: digest(ref), path: file, sha256: ref.sha256,
82
+ || fs.realpathSync(file) !== file) fail('ARTIFACT_CHANGED_OR_HASH_MISMATCH')
83
+ return { kind: 'artifact', refDigest: digest(ref), path: file, sha256: hash.digest('hex'),
64
84
  stat: Object.fromEntries(keys.map(key => [key, String(before[key])])) }
65
85
  } finally { fs.closeSync(fd) }
66
86
  }
@@ -182,6 +202,25 @@ export class DshTransactionHost {
182
202
  pending, legalNext: choices(display.legalNext), skipChoices: choices(display.skipChoices) } }
183
203
  } catch { return { ...base, status: 'unavailable' } }
184
204
  }
205
+ presence(session) {
206
+ if (!this.inProject(session)) return { state: 'OUTSIDE_SCOPE', previouslyCommitted: false }
207
+ const projected = this.projection(session)
208
+ const previouslyCommitted = projected.receipts.some(row => row.status === 'COMMITTED')
209
+ try {
210
+ const binding = this.binding(session)
211
+ const file = path.join(binding.projectRoot, this.storageRelative, digest(binding), 'transaction-host-journal.json')
212
+ if (!fs.existsSync(file)) {
213
+ if (projected.intents.length || projected.receipts.length) fail('HOST_JOURNAL_MISSING_FOR_RECORDED_SESSION')
214
+ return { state: 'NEVER_CREATED', previouslyCommitted: false, revision: 0 }
215
+ }
216
+ const { saved } = this.journal(session, { readOnly: true })
217
+ return { state: saved.document ? 'PRESENT' : previouslyCommitted ? 'LOST_OR_INVALID_AFTER_COMMIT' : saved.pending ? 'CREATION_PENDING' : 'NEVER_CREATED', previouslyCommitted,
218
+ revision: saved.document?.revision ?? 0 }
219
+ } catch (error) {
220
+ return { state: previouslyCommitted ? 'LOST_OR_INVALID_AFTER_COMMIT' : 'UNKNOWN_INTERRUPTED_CREATION',
221
+ previouslyCommitted, code: error.code ?? error.name }
222
+ }
223
+ }
185
224
  assertOwned(authority, exec) {
186
225
  const agent = exec.agent
187
226
  exec.signal?.throwIfAborted()
@@ -194,6 +233,7 @@ export class DshTransactionHost {
194
233
  if (!this.projection(agent.session).turn) fail('OPEN_MODEL_TURN_REQUIRED')
195
234
  }
196
235
  async authorize(exec) {
236
+ if (this.allowClassic && !this.allowClassic(exec.agent)) fail('TRANSACTION_GRAPH_SESSION_EXTENSION_DENIED')
197
237
  if (!exec.agent || this.ctx.agents.get(exec.agent.id) !== exec.agent || this.ctx.agents.currentInitiator() !== exec.agent) fail('EXACT_CALLING_AGENT_REQUIRED')
198
238
  await this.ctx.workingDirectory.ensure(exec.agent, exec.signal)
199
239
  const { binding, key } = this.journal(exec.agent.session)
@@ -276,6 +316,7 @@ export class DshTransactionHost {
276
316
  if (canonical(current) !== canonical(record)) fail('PENDING_RESULT_CHANGED_DURING_FLUSH')
277
317
  if (p.intentSeq === null) saved = journal.bindIntent(p.operationId, record.intentSeq)
278
318
  let rejectReason = null
319
+ if (p.operation === 'revise') rejectReason = 'This retired host revision entry requires its original extension; it cannot commit in standalone mode.'
279
320
  if (p.operation === 'finish' && record.status === 'SUCCESS') {
280
321
  try {
281
322
  const work = this.workState(session, saved, projected)
package/index.mjs CHANGED
@@ -7,6 +7,9 @@ import { createToolDefinitions } from './tools.mjs'
7
7
  import { DshTransactionHost } from './host.mjs'
8
8
  import { projection, KEY } from './projection.mjs'
9
9
  import { createRemoteContribution } from './remote.mjs'
10
+ import { WorkflowRegistry, WORKFLOW_CONTRACT } from './interop.mjs'
11
+ import { EXTENSION_CONTRACT, SessionPolicies } from './extensions.mjs'
12
+ import { FileGraphStore } from './store.mjs'
10
13
 
11
14
  export const name = 'dsh-bettertaskgraphs'
12
15
  let bindSessionResource
@@ -24,15 +27,20 @@ export default class TransactionGraphPlugin extends TypertRemoteService {
24
27
  || Object.keys(config).some(key => !['projectRoot', 'storageRelative', 'enableSwitch'].includes(key)))
25
28
  throw new Error('Invalid transaction graph configuration')
26
29
  this.host = new DshTransactionHost(ctx, { ...config, appendPluginRecord })
30
+ this.workflows = new WorkflowRegistry(ctx)
31
+ this.sessionPolicies = new SessionPolicies()
32
+ this.host.allowClassic = agent => this.sessionPolicies.allowClassic(agent)
33
+ Object.defineProperty(this, 'workflowContract', { value: WORKFLOW_CONTRACT })
34
+ Object.defineProperty(this, 'extensionContract', { value: EXTENSION_CONTRACT })
27
35
  ctx.sessionProjections.register(projection)
28
36
  for (const definition of createToolDefinitions({ defineTool, hostPort: this.host })) {
29
37
  this.host.definitions.set(definition.name, definition)
30
38
  ctx.tools.register(definition)
31
39
  }
32
- ctx.systemPrompt.section({ name: 'project:transaction-graph', order: 46,
33
- text: ({ agent }) => this.host.inProject(agent?.session) ? guidance : '' })
34
- ctx.systemPrompt.context({ name: 'project:transaction-graph-state', order: 46, required: true, interpolate: false,
35
- text: ({ agent }) => this.host.inProject(agent?.session) ? this.host.context(agent) : '' })
40
+ ctx.systemPrompt.section({ name: 'project:transaction-graph', order: 46,
41
+ text: ({ agent }) => this.host.inProject(agent?.session) && this.sessionPolicies.allowClassic(agent) ? guidance : '' })
42
+ ctx.systemPrompt.context({ name: 'project:transaction-graph-state', order: 46, required: true, interpolate: false,
43
+ text: ({ agent }) => this.host.inProject(agent?.session) && this.sessionPolicies.allowClassic(agent) ? this.host.context(agent) : '' })
36
44
  ctx.inject(['typert', 'sessionController'], remoteCtx => {
37
45
  const typert = remoteCtx.typert
38
46
  if (typert.resources.get('session')?.field !== 'sessionId') throw new Error('Transaction graph requires the native Session RPC resource')
@@ -54,7 +62,7 @@ export default class TransactionGraphPlugin extends TypertRemoteService {
54
62
  queueMicrotask(() => { if (!this.host.closed) this.host.schedule(session) })
55
63
  }
56
64
  })
57
- ctx.effect(() => () => this.host.close())
65
+ ctx.effect(() => () => { this.workflows.close(); this.sessionPolicies.close(); return this.host.close() })
58
66
  }
59
67
  async status({ sessionId }, signal) {
60
68
  signal?.throwIfAborted()
@@ -67,4 +75,15 @@ export default class TransactionGraphPlugin extends TypertRemoteService {
67
75
  }
68
76
  registerRecoveryVerifier(adapter) { return this.host.registerRecoveryVerifier(adapter) }
69
77
  recoverInterrupted(agent, request) { return this.host.recoverInterrupted(agent, request) }
78
+ registerWorkflowProvider(adapter) { return this.workflows.register(adapter) }
79
+ listWorkflows(agent) { return this.workflows.list(agent) }
80
+ readWorkflow(agent, reference) { return this.workflows.read(agent, reference) }
81
+ dispatchWorkflow(agent, reference, operation, request, signal) { return this.workflows.dispatch(agent, reference, operation, request, signal) }
82
+ subscribeWorkflows(callback) { return this.workflows.subscribe(callback) }
83
+ registerSessionPolicy(provider) { return this.sessionPolicies.register(provider) }
84
+ createExtensionStore(options) {
85
+ if (this.host.closed) throw new Error('TASK_GRAPH_SERVICE_CLOSED')
86
+ return new FileGraphStore(options)
87
+ }
88
+ inspectGraphPresence(agent) { return this.host.presence(agent.session) }
70
89
  }
package/interop.mjs ADDED
@@ -0,0 +1,91 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { copy, GraphError, validateDocument } from './core.mjs'
5
+
6
+ export const WORKFLOW_CONTRACT = 'dsh-taskgraph-workflow-v1'
7
+ const name = value => typeof value === 'string' && /^[a-z][a-z0-9_-]{0,79}$/.test(value)
8
+ const fail = code => { throw new GraphError(code) }
9
+ const within = (root, target) => { const relative = path.relative(root, target); return relative === '' || relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative) }
10
+
11
+ /** In-process plugin port. A provider's joint ledger remains the sole authority.
12
+ * No model-supplied callback, synthetic session proof or mirrored commit is used.
13
+ */
14
+ export class WorkflowRegistry {
15
+ constructor(ctx) { this.ctx = ctx; this.entries = new Map(); this.listeners = new Set(); this.closed = false }
16
+ register(adapter) {
17
+ if (this.closed || adapter?.contract !== WORKFLOW_CONTRACT || !name(adapter.kind) || !name(adapter.workflowId)
18
+ || !Number.isSafeInteger(adapter.version) || adapter.version !== 1 || typeof adapter.read !== 'function'
19
+ || typeof adapter.execute !== 'function' || typeof adapter.authorize !== 'function'
20
+ || !Array.isArray(adapter.operations) || adapter.operations.some(operation => !name(operation))
21
+ || new Set(adapter.operations).size !== adapter.operations.length) fail('WORKFLOW_ADAPTER_CONTRACT_REQUIRED')
22
+ if (typeof adapter.projectRoot !== 'string' || !path.isAbsolute(adapter.projectRoot)
23
+ || fs.realpathSync(adapter.projectRoot) !== adapter.projectRoot || !fs.statSync(adapter.projectRoot).isDirectory()) fail('WORKFLOW_REAL_PROJECT_REQUIRED')
24
+ const key = `${adapter.kind}:${adapter.workflowId}`
25
+ if (this.entries.has(key)) fail('WORKFLOW_PROVIDER_ALREADY_REGISTERED')
26
+ const entry = { ...adapter, operations: Object.freeze([...adapter.operations]), lifecycle: new AbortController() }
27
+ this.entries.set(key, entry)
28
+ const publish = event => {
29
+ if (this.entries.get(key) !== entry || this.closed) return
30
+ // Only post-commit revision metadata crosses the notification port.
31
+ const notice = Object.freeze({ contract: WORKFLOW_CONTRACT, kind: entry.kind, workflowId: entry.workflowId,
32
+ type: typeof event?.type === 'string' ? event.type : 'changed', revision: this.snapshot(entry).revision })
33
+ for (const callback of this.listeners) { try { callback(notice) } catch (error) { this.ctx.logger?.warn?.(`workflow listener failed: ${error.code ?? error.name}`) } }
34
+ }
35
+ let unsubscribe
36
+ try { unsubscribe = adapter.subscribe?.(publish); entry.unsubscribe = unsubscribe } catch (error) { this.entries.delete(key); throw error }
37
+ return () => {
38
+ if (this.entries.get(key) !== entry) return
39
+ this.entries.delete(key); entry.lifecycle.abort(new GraphError('WORKFLOW_PROVIDER_DISPOSED')); unsubscribe?.()
40
+ }
41
+ }
42
+ authorize(entry, agent) {
43
+ if (this.closed || !agent || this.ctx.agents.get(agent.id) !== agent || this.ctx.sessions.get(agent.session.id) !== agent.session)
44
+ fail('WORKFLOW_EXACT_CONTROLLER_REQUIRED')
45
+ const cwd = this.ctx.workingDirectory.get(agent.session)
46
+ if (typeof cwd !== 'string' || !within(entry.projectRoot, fs.realpathSync(cwd))
47
+ || !within(entry.projectRoot, fs.realpathSync(agent.session.header.cwd))) fail('WORKFLOW_CONTROLLER_OUTSIDE_PROJECT')
48
+ if (entry.authorize(agent) !== true) fail('WORKFLOW_CONTROLLER_NOT_AUTHORIZED')
49
+ }
50
+ entry({ kind, workflowId }, agent) {
51
+ const entry = this.entries.get(`${kind}:${workflowId}`)
52
+ if (!entry || this.closed) fail('WORKFLOW_PROVIDER_UNAVAILABLE')
53
+ this.authorize(entry, agent); return entry
54
+ }
55
+ snapshot(entry) {
56
+ const value = copy(entry.read())
57
+ if (!value || !Number.isSafeInteger(value.revision) || value.revision < 0 || (value.graph === null) !== (value.revision === 0)) fail('WORKFLOW_INVALID_SNAPSHOT')
58
+ if (value.graph !== null) { validateDocument(value.graph); if (value.graph.revision !== value.revision) fail('WORKFLOW_REVISION_MISMATCH') }
59
+ return { ...value, contract: WORKFLOW_CONTRACT, kind: entry.kind, workflowId: entry.workflowId,
60
+ operations: [...entry.operations], goalReached: false }
61
+ }
62
+ list(agent) {
63
+ const result = []
64
+ for (const entry of this.entries.values()) {
65
+ try { this.authorize(entry, agent) } catch { continue }
66
+ result.push({ contract: WORKFLOW_CONTRACT, kind: entry.kind, workflowId: entry.workflowId, revision: this.snapshot(entry).revision, operations: [...entry.operations] })
67
+ }
68
+ return result
69
+ }
70
+ read(agent, reference) { return this.snapshot(this.entry(reference, agent)) }
71
+ async dispatch(agent, reference, operation, request, signal) {
72
+ const entry = this.entry(reference, agent)
73
+ if (!entry.operations.includes(operation) || !request || typeof request !== 'object' || Array.isArray(request)) fail('WORKFLOW_OPERATION_NOT_SUPPORTED')
74
+ const input = copy(request), scope = AbortSignal.any([entry.lifecycle.signal, ...(signal ? [signal] : [])])
75
+ scope.throwIfAborted()
76
+ const result = await entry.execute(operation, input, { agent, signal: scope })
77
+ scope.throwIfAborted(); this.authorize(entry, agent)
78
+ if (this.entries.get(`${entry.kind}:${entry.workflowId}`) !== entry) fail('WORKFLOW_PROVIDER_DISPOSED')
79
+ return copy(result)
80
+ }
81
+ subscribe(callback) {
82
+ if (this.closed || typeof callback !== 'function') fail('WORKFLOW_LISTENER_REQUIRED')
83
+ this.listeners.add(callback); return () => this.listeners.delete(callback)
84
+ }
85
+ close() {
86
+ if (this.closed) return
87
+ this.closed = true
88
+ for (const entry of this.entries.values()) { entry.lifecycle.abort(new GraphError('WORKFLOW_PROVIDER_DISPOSED')); entry.unsubscribe?.() }
89
+ this.entries.clear(); this.listeners.clear()
90
+ }
91
+ }
package/journal.mjs CHANGED
@@ -27,7 +27,7 @@ export function validateJournal(value, binding) {
27
27
  || (row.graphChecksum !== null && !hex(row.graphChecksum)) || !integer(row.resultSeq)
28
28
  || (row.reason !== null && (typeof row.reason !== 'string' || row.reason.length > 1200))
29
29
  || !hex(row.claimDigest) || !hex(row.candidateChecksum) || !integer(row.intentSeq) || !integer(row.expectedGraphRevision)
30
- || !['create', 'begin', 'finish', 'skip', 'reconcile'].includes(row.operation)
30
+ || !['create', 'begin', 'finish', 'skip', 'reconcile', 'revise'].includes(row.operation)
31
31
  || !Array.isArray(row.evidenceDigests) || row.evidenceDigests.length > 32 || row.evidenceDigests.some(x => !hex(x)))
32
32
  fail('JOURNAL_CLAIM_INVALID')
33
33
  operations.add(row.operationId)
@@ -45,7 +45,7 @@ export function validateJournal(value, binding) {
45
45
  'owner', 'bindingDigest', 'expectedGraphRevision', 'candidate', 'resolvedEvidence', 'intentSeq', 'claimDigest', 'clearedWork'])
46
46
  || operations.has(p.operationId) || !hex(p.payloadHash) || !hex(p.bindingDigest) || !hex(p.claimDigest)
47
47
  || claimDigest(p) !== p.claimDigest || p.bindingDigest !== digest(binding) || p.sessionId !== binding.sessionId
48
- || !['create', 'begin', 'finish', 'skip', 'reconcile'].includes(p.operation) || !integer(p.turn)
48
+ || !['create', 'begin', 'finish', 'skip', 'reconcile', 'revise'].includes(p.operation) || !integer(p.turn)
49
49
  || p.expectedGraphRevision !== (value.document?.revision ?? 0) || (p.intentSeq !== null && !integer(p.intentSeq))
50
50
  || !Array.isArray(p.resolvedEvidence) || p.resolvedEvidence.length > 32 || !Array.isArray(p.clearedWork) || p.clearedWork.length > 1024
51
51
  || p.clearedWork.some(row => !fields(row, ['rootCallId', 'callSeq', 'rootResultSeq', 'eventDigest'])
package/package.json CHANGED
@@ -1,9 +1,14 @@
1
1
  {
2
2
  "name": "dsh-bettertaskgraphs",
3
- "version": "0.2.3",
3
+ "version": "0.3.0-rc.11",
4
4
  "description": "Transactional task graphs with a conversation panel for DeepSeek Harness",
5
5
  "license": "GPL-3.0-only",
6
- "keywords": ["dsh", "deepseek-harness", "cordis", "task-graph"],
6
+ "keywords": [
7
+ "dsh",
8
+ "deepseek-harness",
9
+ "cordis",
10
+ "task-graph"
11
+ ],
7
12
  "homepage": "https://github.com/oldcitynight/dsh-bettertaskgraphs#readme",
8
13
  "repository": {
9
14
  "type": "git",
@@ -16,7 +21,12 @@
16
21
  "main": "./index.mjs",
17
22
  "exports": {
18
23
  ".": "./index.mjs",
19
- "./client": "./client.js"
24
+ "./core": "./core.mjs",
25
+ "./store": "./store.mjs",
26
+ "./host": "./host.mjs",
27
+ "./interop": "./interop.mjs",
28
+ "./client": "./client.js",
29
+ "./extensions": "./extensions.mjs"
20
30
  },
21
31
  "files": [
22
32
  "client.js",
@@ -25,6 +35,7 @@
25
35
  "core.mjs",
26
36
  "host.mjs",
27
37
  "index.mjs",
38
+ "interop.mjs",
28
39
  "journal.mjs",
29
40
  "projection.mjs",
30
41
  "remote.mjs",
@@ -32,7 +43,9 @@
32
43
  "store.mjs",
33
44
  "tools.mjs",
34
45
  "view-model.mjs",
35
- "work-policy.mjs"
46
+ "work-policy.mjs",
47
+ "extensions.mjs",
48
+ "VALIDATION.md"
36
49
  ],
37
50
  "engines": {
38
51
  "node": "^22.19.0 || >=24.0.0",
@@ -48,6 +61,7 @@
48
61
  },
49
62
  "publishConfig": {
50
63
  "access": "public",
64
+ "tag": "next",
51
65
  "registry": "https://registry.npmjs.org/"
52
66
  },
53
67
  "dependencies": {
package/projection.mjs CHANGED
@@ -17,7 +17,7 @@ const factSchema = z.object({ seq, callId: bounded(512), tool: bounded(300), isE
17
17
  outcome: z.enum(['UNVERIFIED', 'FAIL', 'NOT_RUN', 'UNKNOWN']) }).strict()
18
18
  const intentSchema = z.object({ version: z.literal(4), operationId: bounded(80), callId: bounded(512),
19
19
  rootCallId: bounded(512), turn: seq, sessionId: bounded(512), bindingDigest: hex, claimDigest: hex,
20
- operation: z.enum(['create', 'begin', 'finish', 'skip', 'reconcile']), expectedGraphRevision: seq, candidateChecksum: hex,
20
+ operation: z.enum(['create', 'begin', 'finish', 'skip', 'reconcile', 'revise']), expectedGraphRevision: seq, candidateChecksum: hex,
21
21
  clearedWork: z.array(workSchema).max(1024) }).strict()
22
22
  const dispatchSchema = z.object({ seq, subCallId: bounded(512), parentCallId: bounded(512), rootCallId: bounded(512),
23
23
  tool: bounded(300), isError: z.boolean(), outcome: z.enum(['UNVERIFIED', 'FAIL', 'NOT_RUN', 'UNKNOWN']), eventDigest: hex, turn: seq }).strict()
package/store.mjs CHANGED
@@ -26,8 +26,8 @@ function regularBoundedJson(file, maximum = LIMITS.bytes) {
26
26
  catch (error) { if (error.code === 'ENOENT') return null; throw error }
27
27
  try {
28
28
  const before = fs.fstatSync(fd)
29
- if (!before.isFile() || before.size > maximum || before.size < 1) reject('INVALID_STORE_FILE')
30
- const buffer = Buffer.alloc(Math.min(before.size + 1, maximum + 1))
29
+ if (!before.isFile() || maximum !== null && before.size > maximum || before.size < 1) reject('INVALID_STORE_FILE')
30
+ const buffer = Buffer.alloc(maximum === null ? before.size + 1 : Math.min(before.size + 1, maximum + 1))
31
31
  let count = 0
32
32
  while (count < buffer.length) {
33
33
  const read = fs.readSync(fd, buffer, count, buffer.length - count, null)
@@ -60,8 +60,8 @@ export class MemoryGraphStore {
60
60
  export class FileGraphStore {
61
61
  constructor({ projectRoot, directory, fileName = 'transaction-graph.json', maximumBytes = LIMITS.bytes,
62
62
  validator = validateDocument, updateValidator = checkNext, readOnly = false }) {
63
- if (!/^[a-z][a-z0-9.-]{0,63}$/.test(fileName) || !Number.isSafeInteger(maximumBytes)
64
- || maximumBytes < 1 || maximumBytes > 12 * 1024 * 1024 || typeof validator !== 'function' || typeof updateValidator !== 'function') reject('INVALID_STORE_POLICY')
63
+ if (!/^[a-z][a-z0-9.-]{0,63}$/.test(fileName) || maximumBytes !== null && (!Number.isSafeInteger(maximumBytes)
64
+ || maximumBytes < 1 || maximumBytes > 12 * 1024 * 1024) || typeof validator !== 'function' || typeof updateValidator !== 'function') reject('INVALID_STORE_POLICY')
65
65
  this.maximumBytes = maximumBytes; this.validator = validator; this.updateValidator = updateValidator
66
66
  this.readOnly = readOnly
67
67
  if (typeof projectRoot !== 'string' || typeof directory !== 'string'
@@ -126,7 +126,7 @@ export class FileGraphStore {
126
126
  this.validator(next)
127
127
  this.updateValidator(previous, next)
128
128
  const content = JSON.stringify(next) + '\n'
129
- if (Buffer.byteLength(content) > this.maximumBytes) reject('STORE_BYTE_LIMIT')
129
+ if (this.maximumBytes !== null && Buffer.byteLength(content) > this.maximumBytes) reject('STORE_BYTE_LIMIT')
130
130
  temporary = path.join(this.directory, '.transaction-graph-' + randomUUID() + '.tmp')
131
131
  const fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600)
132
132
  try { temporaryIdentity = fs.fstatSync(fd); fs.writeFileSync(fd, content); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }