dsh-bettertaskgraphs 0.0.0-stage → 0.2.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.
package/journal.mjs ADDED
@@ -0,0 +1,131 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ import { FileGraphStore } from './store.mjs'
3
+ import { canonical, copy, digest, GraphError, validateDocument } from './core.mjs'
4
+ export const MAX_CLAIMS = 1024
5
+ const fail = code => { throw new GraphError(code) }
6
+ const integer = value => Number.isSafeInteger(value) && value >= 0
7
+ const hex = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
8
+ const fields = (value, keys) => value && typeof value === 'object' && !Array.isArray(value)
9
+ && Object.keys(value).every(key => keys.includes(key)) && keys.every(key => Object.hasOwn(value, key))
10
+ export function seal(value) { const body = copy(value); delete body.checksum; return { ...body, checksum: digest(body) } }
11
+ export function claimDigest(value) { const body = copy(value); delete body.claimDigest; delete body.intentSeq; return digest(body) }
12
+ export function initialJournal(binding) {
13
+ return seal({ schema: 4, revision: 0, binding: copy(binding), document: null, activeProof: null, pending: null, claims: [] })
14
+ }
15
+ export function validateJournal(value, binding) {
16
+ if (!fields(value, ['schema', 'revision', 'binding', 'document', 'activeProof', 'pending', 'claims', 'checksum'])
17
+ || value.schema !== 4 || !integer(value.revision) || canonical(value.binding) !== canonical(binding)
18
+ || !hex(value.checksum) || seal(value).checksum !== value.checksum || !Array.isArray(value.claims) || value.claims.length > MAX_CLAIMS)
19
+ fail('JOURNAL_SCOPE_CHECKSUM_OR_LIMIT')
20
+ if (value.document) validateDocument(value.document)
21
+ const operations = new Set()
22
+ for (const row of value.claims) {
23
+ if (!fields(row, ['operationId', 'payloadHash', 'status', 'graphRevision', 'graphChecksum', 'resultSeq', 'reason', 'evidenceDigests',
24
+ 'claimDigest', 'intentSeq', 'candidateChecksum', 'expectedGraphRevision', 'operation'])
25
+ || typeof row.operationId !== 'string' || operations.has(row.operationId) || !hex(row.payloadHash)
26
+ || !['COMMITTED', 'ABORTED'].includes(row.status) || !integer(row.graphRevision)
27
+ || (row.graphChecksum !== null && !hex(row.graphChecksum)) || !integer(row.resultSeq)
28
+ || (row.reason !== null && (typeof row.reason !== 'string' || row.reason.length > 1200))
29
+ || !hex(row.claimDigest) || !hex(row.candidateChecksum) || !integer(row.intentSeq) || !integer(row.expectedGraphRevision)
30
+ || !['create', 'begin', 'finish', 'skip', 'reconcile'].includes(row.operation)
31
+ || !Array.isArray(row.evidenceDigests) || row.evidenceDigests.length > 32 || row.evidenceDigests.some(x => !hex(x)))
32
+ fail('JOURNAL_CLAIM_INVALID')
33
+ operations.add(row.operationId)
34
+ if ((value.document?.state.operations.includes(row.operationId) ?? false) !== (row.status === 'COMMITTED')) fail('JOURNAL_COMMIT_OPERATION_MISMATCH')
35
+ }
36
+ if (value.activeProof) {
37
+ if (!fields(value.activeProof, ['attemptId', 'beginResultSeq', 'beginTurn', 'workStartedAtBegin', 'workExecutedAtBegin']) || !integer(value.activeProof.beginResultSeq)
38
+ || !integer(value.activeProof.beginTurn) || !integer(value.activeProof.workStartedAtBegin) || !integer(value.activeProof.workExecutedAtBegin)
39
+ || value.document?.state.active?.attemptId !== value.activeProof.attemptId)
40
+ fail('JOURNAL_ACTIVE_PROOF_MISMATCH')
41
+ } else if (value.document?.state.active) fail('JOURNAL_ACTIVE_PROOF_REQUIRED')
42
+ const p = value.pending
43
+ if (p) {
44
+ if (!fields(p, ['operationId', 'payloadHash', 'operation', 'arguments', 'callId', 'rootCallId', 'turn', 'sessionId',
45
+ 'owner', 'bindingDigest', 'expectedGraphRevision', 'candidate', 'resolvedEvidence', 'intentSeq', 'claimDigest', 'clearedWork'])
46
+ || operations.has(p.operationId) || !hex(p.payloadHash) || !hex(p.bindingDigest) || !hex(p.claimDigest)
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)
49
+ || p.expectedGraphRevision !== (value.document?.revision ?? 0) || (p.intentSeq !== null && !integer(p.intentSeq))
50
+ || !Array.isArray(p.resolvedEvidence) || p.resolvedEvidence.length > 32 || !Array.isArray(p.clearedWork) || p.clearedWork.length > 1024
51
+ || p.clearedWork.some(row => !fields(row, ['rootCallId', 'callSeq', 'rootResultSeq', 'eventDigest'])
52
+ || typeof row.rootCallId !== 'string' || !row.rootCallId || row.rootCallId.length > 512 || !integer(row.callSeq)
53
+ || row.rootResultSeq !== null && !integer(row.rootResultSeq) || !hex(row.eventDigest))) fail('JOURNAL_PENDING_INVALID')
54
+ validateDocument(p.candidate)
55
+ if (p.candidate.revision !== p.expectedGraphRevision + 1 || p.candidate.events.at(-1).operationId !== p.operationId
56
+ || canonical(p.candidate.events.slice(0, -1)) !== canonical(value.document?.events ?? [])) fail('JOURNAL_CANDIDATE_NOT_SINGLE_APPEND')
57
+ }
58
+ return true
59
+ }
60
+
61
+ export class HostJournal {
62
+ constructor({ projectRoot, directory, binding, store, readOnly = false }) {
63
+ this.binding = copy(binding)
64
+ const validate = value => validateJournal(value, this.binding)
65
+ this.store = store ?? new FileGraphStore({ projectRoot, directory, fileName: 'transaction-host-journal.json', maximumBytes: 12 * 1024 * 1024, readOnly,
66
+ validator: validate, updateValidator: (previous, next) => {
67
+ if (next.revision !== (previous?.revision ?? 0) + 1 || previous && canonical(previous.binding) !== canonical(next.binding)) fail('JOURNAL_CAS_REVISION_OR_SCOPE')
68
+ const oldClaims = previous?.claims ?? []
69
+ if (canonical(next.claims.slice(0, oldClaims.length)) !== canonical(oldClaims)) fail('JOURNAL_CLAIM_HISTORY_IMMUTABLE')
70
+ } })
71
+ }
72
+ read() {
73
+ const value = this.store.read() ?? initialJournal(this.binding)
74
+ validateJournal(value, this.binding)
75
+ return value
76
+ }
77
+ change(expected, update) {
78
+ return this.store.compareAndSwap(expected, previous => {
79
+ const before = previous ?? initialJournal(this.binding)
80
+ validateJournal(before, this.binding)
81
+ const after = seal({ ...update(copy(before)), schema: 4, binding: copy(this.binding), revision: before.revision + 1 })
82
+ validateJournal(after, this.binding)
83
+ return after
84
+ })
85
+ }
86
+ reserve(claim) {
87
+ const before = this.read()
88
+ return this.change(before.revision, state => {
89
+ if (state.pending) fail('PENDING_INTENT_REQUIRES_RECONCILIATION')
90
+ if (state.claims.some(row => row.operationId === claim.operationId)) fail('HOST_TOOL_CALL_REPLAY')
91
+ if (state.claims.length >= MAX_CLAIMS) fail('HOST_CLAIM_LIMIT')
92
+ return { ...state, pending: copy(claim) }
93
+ })
94
+ }
95
+ bindIntent(operationId, intentSeq) {
96
+ const before = this.read()
97
+ return this.change(before.revision, state => {
98
+ if (state.pending?.operationId !== operationId || state.pending.intentSeq !== null && state.pending.intentSeq !== intentSeq) fail('HOST_INTENT_CHANGED')
99
+ return { ...state, pending: { ...state.pending, intentSeq } }
100
+ })
101
+ }
102
+ resolve(projected, { rejectReason = null } = {}) {
103
+ const before = this.read(), pending = before.pending
104
+ if (!pending) return before
105
+ if (projected.data.operationId !== pending.operationId || projected.data.claimDigest !== pending.claimDigest
106
+ || projected.data.bindingDigest !== pending.bindingDigest || projected.intentSeq !== pending.intentSeq
107
+ || projected.data.operation !== pending.operation || projected.data.expectedGraphRevision !== pending.expectedGraphRevision
108
+ || projected.data.candidateChecksum !== pending.candidate.checksum || projected.data.callId !== pending.callId
109
+ || projected.data.rootCallId !== pending.rootCallId || projected.data.turn !== pending.turn || projected.data.sessionId !== pending.sessionId
110
+ || canonical(projected.data.clearedWork) !== canonical(pending.clearedWork))
111
+ fail('HOST_PERSISTED_INTENT_PROOF_MISMATCH')
112
+ if (!['SUCCESS', 'FAILED'].includes(projected.status) || !integer(projected.rootResultSeq)) fail('HOST_RESULT_UNKNOWN_NO_COMMIT')
113
+ return this.change(before.revision, state => {
114
+ if (canonical(state.pending) !== canonical(pending)) fail('HOST_PENDING_CHANGED')
115
+ if (rejectReason !== null && (typeof rejectReason !== 'string' || !rejectReason || rejectReason.length > 1200)) fail('INVALID_CANDIDATE_REJECTION')
116
+ const committed = projected.status === 'SUCCESS' && rejectReason === null, document = committed ? pending.candidate : state.document
117
+ let activeProof = state.activeProof
118
+ if (committed && pending.operation === 'begin') activeProof = { attemptId: document.state.active.attemptId,
119
+ beginResultSeq: projected.rootResultSeq, beginTurn: pending.turn, workStartedAtBegin: projected.workStartedAtRoot,
120
+ workExecutedAtBegin: projected.workExecutedAtRoot }
121
+ if (committed && ['finish', 'reconcile'].includes(pending.operation)) activeProof = null
122
+ const row = { operationId: pending.operationId, payloadHash: pending.payloadHash, status: committed ? 'COMMITTED' : 'ABORTED',
123
+ graphRevision: document?.revision ?? 0, graphChecksum: document?.checksum ?? null, resultSeq: projected.rootResultSeq,
124
+ reason: committed ? null : rejectReason ?? 'Root/ancestor execution or typed-result qualification failed; candidate not applied. Original tool/result unchanged.',
125
+ claimDigest: pending.claimDigest, intentSeq: pending.intentSeq, candidateChecksum: pending.candidate.checksum,
126
+ expectedGraphRevision: pending.expectedGraphRevision, operation: pending.operation,
127
+ evidenceDigests: pending.resolvedEvidence.map(digest) }
128
+ return { ...state, document, activeProof, pending: null, claims: [...state.claims, row] }
129
+ })
130
+ }
131
+ }
package/package.json CHANGED
@@ -1,6 +1,80 @@
1
1
  {
2
2
  "name": "dsh-bettertaskgraphs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.3",
4
+ "description": "Transactional task graphs with a conversation panel for DeepSeek Harness",
5
+ "license": "GPL-3.0-only",
6
+ "keywords": ["dsh", "deepseek-harness", "cordis", "task-graph"],
7
+ "homepage": "https://github.com/oldcitynight/dsh-bettertaskgraphs#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/oldcitynight/dsh-bettertaskgraphs.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/oldcitynight/dsh-bettertaskgraphs/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "./index.mjs",
17
+ "exports": {
18
+ ".": "./index.mjs",
19
+ "./client": "./client.js"
20
+ },
21
+ "files": [
22
+ "client.js",
23
+ "client.source.mjs",
24
+ "cordis.patch.yml",
25
+ "core.mjs",
26
+ "host.mjs",
27
+ "index.mjs",
28
+ "journal.mjs",
29
+ "projection.mjs",
30
+ "remote.mjs",
31
+ "scripts/build-client.mjs",
32
+ "store.mjs",
33
+ "tools.mjs",
34
+ "view-model.mjs",
35
+ "work-policy.mjs"
36
+ ],
37
+ "engines": {
38
+ "node": "^22.19.0 || >=24.0.0",
39
+ "dsh": "0.2.1-rc.5"
40
+ },
41
+ "scripts": {
42
+ "build": "node scripts/build-client.mjs",
43
+ "check": "node scripts/build-client.mjs --check",
44
+ "test": "npm run check && node --test",
45
+ "prepack": "npm run build",
46
+ "prepublishOnly": "npm test",
47
+ "pack:bundle": "node -e \"require('node:fs').mkdirSync('dist', { recursive: true })\" && npm pack --pack-destination dist"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public",
51
+ "registry": "https://registry.npmjs.org/"
52
+ },
53
+ "dependencies": {
54
+ "zod": "4.6.7"
55
+ },
56
+ "peerDependencies": {
57
+ "@deepseek-ai/dsh-session": "0.2.1-rc.5",
58
+ "@deepseek-ai/dsh-tools": "0.2.1-rc.5",
59
+ "@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.5"
60
+ },
61
+ "devDependencies": {
62
+ "@deepseek-ai/cordis": "4.0.5",
63
+ "@deepseek-ai/dsh-session": "0.2.1-rc.5",
64
+ "@deepseek-ai/dsh-session-projection": "0.2.1-rc.5",
65
+ "@deepseek-ai/dsh-tools": "0.2.1-rc.5",
66
+ "@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.5"
67
+ },
68
+ "dsh": {
69
+ "bundle": {
70
+ "patch": "./cordis.patch.yml"
71
+ },
72
+ "client": {
73
+ "platform": "web",
74
+ "inject": [
75
+ "@deepseek-ai/dsh-client-ui-conversation",
76
+ "@deepseek-ai/dsh-api-remotes"
77
+ ]
78
+ }
79
+ }
80
+ }
package/projection.mjs ADDED
@@ -0,0 +1,225 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ import { z } from 'zod'
3
+ import { TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
4
+ import { canonical, copy, digest, GraphError } from './core.mjs'
5
+ import { administrative, workKey } from './work-policy.mjs'
6
+ export const KEY = 'projectTransactionGraphV4'
7
+ export const INTENT = 'plugin:project-transaction-graph/intent-v4'
8
+ export const RECEIPT = 'plugin:project-transaction-graph/receipt-v4'
9
+ export const RECOVERY = 'plugin:project-transaction-graph/operator-recovery-v4'
10
+ export const MAX_PROOFS = 1024
11
+ const bounded = n => z.string().min(1).max(n)
12
+ const hex = z.string().regex(/^[a-f0-9]{64}$/)
13
+ const seq = z.number().int().nonnegative()
14
+ const workSchema = z.object({ rootCallId: bounded(512), callSeq: seq, rootResultSeq: seq.nullable(), eventDigest: hex }).strict()
15
+ const bashFailureSchema = z.object({ rootCallId: bounded(512), callId: bounded(512) }).strict()
16
+ const factSchema = z.object({ seq, callId: bounded(512), tool: bounded(300), isError: z.boolean(),
17
+ outcome: z.enum(['UNVERIFIED', 'FAIL', 'NOT_RUN', 'UNKNOWN']) }).strict()
18
+ const intentSchema = z.object({ version: z.literal(4), operationId: bounded(80), callId: bounded(512),
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,
21
+ clearedWork: z.array(workSchema).max(1024) }).strict()
22
+ const dispatchSchema = z.object({ seq, subCallId: bounded(512), parentCallId: bounded(512), rootCallId: bounded(512),
23
+ tool: bounded(300), isError: z.boolean(), outcome: z.enum(['UNVERIFIED', 'FAIL', 'NOT_RUN', 'UNKNOWN']), eventDigest: hex, turn: seq }).strict()
24
+ const proofSchema = z.object({ seq, callId: bounded(512), rootCallId: bounded(512), rootCallSeq: seq, rootResultSeq: seq,
25
+ tool: bounded(300), successful: z.boolean(), qualification: z.enum(['SUCCESS', 'FAILED', 'UNKNOWN']), isError: z.boolean(),
26
+ outcome: z.enum(['UNVERIFIED', 'FAIL', 'NOT_RUN', 'UNKNOWN']), ownOutcome: z.enum(['UNVERIFIED', 'FAIL', 'NOT_RUN', 'UNKNOWN']),
27
+ ancestorOutcomes: z.array(factSchema).max(33), eventDigest: hex, turn: seq }).strict()
28
+ const pendingSchema = z.object({ intentSeq: seq, data: intentSchema,
29
+ intentDigest: hex, status: z.enum(['PENDING', 'SUCCESS', 'FAILED', 'UNKNOWN']),
30
+ rootResultSeq: seq.nullable(), rootResultDigest: hex.nullable(), workStartedAtRoot: seq.nullable(), workExecutedAtRoot: seq.nullable() }).strict()
31
+ export const stateSchema = z.object({ sessionId: bounded(512), ownFrom: seq, turn: seq, lastSeq: z.number().int().min(-1),
32
+ workStarted: seq, workExecuted: seq, uncertainties: z.array(workSchema).max(1024), acknowledgedWork: z.array(workSchema).max(1024),
33
+ calls: z.record(z.string(), z.object({ tool: bounded(300), turn: seq, seq, eventDigest: hex }).strict()),
34
+ bashFailures: z.array(bashFailureSchema).max(256),
35
+ dispatches: z.array(dispatchSchema).max(256), intents: z.array(pendingSchema).max(1024),
36
+ proofs: z.array(proofSchema).max(MAX_PROOFS), receipts: z.array(z.object({ operationId: bounded(80), seq,
37
+ status: z.enum(['COMMITTED', 'ABORTED']), graphRevision: seq, graphChecksum: hex.nullable(), eventDigest: hex }).strict()).max(1024), failure: z.string().nullable() }).strict()
38
+ export function initialState(header, inheritedEventCount = 0) {
39
+ return { sessionId: header.id, ownFrom: inheritedEventCount, turn: 0, lastSeq: -1, workStarted: 0, workExecuted: 0,
40
+ uncertainties: [], acknowledgedWork: [],
41
+ calls: {}, bashFailures: [], dispatches: [], intents: [], proofs: [], receipts: [], failure: null }
42
+ }
43
+ export function outcomeOfContent(content = []) {
44
+ const outcomes = new Set()
45
+ for (const block of content) {
46
+ if (block.type !== 'text' || typeof block.text !== 'string') continue
47
+ if (block.text.length > 32768) return 'UNKNOWN' // No bounded parser means no positive completion qualification.
48
+ let value
49
+ try { value = JSON.parse(block.text) } catch { continue }
50
+ if (!value || typeof value !== 'object' || Array.isArray(value)) continue
51
+ if (value.passed === false || value.ok === false || value.success === false) outcomes.add('FAIL')
52
+ for (const status of [value.goalVerification?.status, value.verification?.status, value.status, value.state]) if (typeof status === 'string') {
53
+ if (['FAIL', 'FAILED', 'ERROR'].includes(status.toUpperCase())) outcomes.add('FAIL')
54
+ if (['NOT_RUN', 'UNKNOWN'].includes(status.toUpperCase())) outcomes.add(status.toUpperCase())
55
+ }
56
+ }
57
+ // A first FAIL/NOT_RUN block may not hide a later UNKNOWN/contradiction.
58
+ return outcomes.has('UNKNOWN') || outcomes.size > 1 ? 'UNKNOWN' : [...outcomes][0] ?? 'UNVERIFIED'
59
+ }
60
+ function toolOutcome(state, tool, rootCallId, callId, content) {
61
+ const outcome = outcomeOfContent(content)
62
+ if (tool !== 'bash' || !state.bashFailures.some(row => row.rootCallId === rootCallId && row.callId === callId)) return outcome
63
+ return outcome === 'UNKNOWN' || outcome === 'NOT_RUN' ? 'UNKNOWN' : 'FAIL'
64
+ }
65
+ export function chainQualification(start, root, dispatches) {
66
+ let child = start
67
+ const visited = new Set()
68
+ let failed = false, unknown = false
69
+ while (child !== root) {
70
+ if (visited.has(child) || visited.size >= 32) return 'UNKNOWN'
71
+ visited.add(child)
72
+ const matches = dispatches.filter(row => row.subCallId === child && row.rootCallId === root)
73
+ if (matches.length !== 1) return 'UNKNOWN'
74
+ const row = matches[0]
75
+ if (row.isError || ['FAIL', 'NOT_RUN'].includes(row.outcome)) failed = true
76
+ if (row.outcome === 'UNKNOWN' || !['UNVERIFIED', 'FAIL', 'NOT_RUN'].includes(row.outcome)) unknown = true
77
+ child = row.parentCallId
78
+ }
79
+ return unknown ? 'UNKNOWN' : failed ? 'FAILED' : 'SUCCESS'
80
+ }
81
+ export const chainSucceeded = (start, root, dispatches) => chainQualification(start, root, dispatches) === 'SUCCESS'
82
+ function ancestryFacts(start, root, dispatches) {
83
+ const facts = [], visited = new Set()
84
+ let cursor = start
85
+ while (cursor !== root && !visited.has(cursor) && facts.length < 32) {
86
+ visited.add(cursor)
87
+ const matches = dispatches.filter(row => row.subCallId === cursor && row.rootCallId === root)
88
+ if (matches.length !== 1) break
89
+ const row = matches[0]
90
+ if (cursor !== start) facts.push({ seq: row.seq, callId: row.subCallId, tool: row.tool, isError: row.isError, outcome: row.outcome })
91
+ cursor = row.parentCallId
92
+ }
93
+ return facts
94
+ }
95
+ const qualifiedStatus = (root, chain) => root === 'UNKNOWN' || chain === 'UNKNOWN' ? 'UNKNOWN'
96
+ : root === 'FAILED' || chain === 'FAILED' ? 'FAILED' : 'SUCCESS'
97
+ const internal = administrative
98
+ export function applyEvent(state, event) {
99
+ if (state.failure) return state
100
+ try {
101
+ if (event.seq < state.ownFrom) return state // Fork parents cannot authorize a child's graph.
102
+ if (event.type.startsWith('plugin:project-transaction-graph/') && ![INTENT, RECEIPT, RECOVERY].includes(event.type))
103
+ throw new GraphError('LEGACY_GRAPH_REQUIRES_EXPLICIT_MIGRATION')
104
+ if (!['turn/start', 'turn/end', 'tool/call', 'tool/ptc-dispatch', 'tool/result', 'job/settled', INTENT, RECEIPT, RECOVERY].includes(event.type)) return state
105
+ const d = event.data
106
+ let next = { ...state, lastSeq: event.seq }
107
+ if (event.type === 'turn/start') return { ...next, turn: d.turn }
108
+ if (event.type === 'tool/call') {
109
+ if (Object.hasOwn(state.calls, d.callId) || Object.keys(state.calls).length >= 256) throw new GraphError('CALL_ID_REPLAY_OR_LIMIT')
110
+ return { ...next, workStarted: state.workStarted + (!internal(d.name) && d.name !== 'run_code' ? 1 : 0),
111
+ calls: { ...state.calls, [d.callId]: { tool: d.name, turn: state.turn, seq: event.seq, eventDigest: digest(event) } } }
112
+ }
113
+ if (event.type === INTENT || event.type === RECOVERY) {
114
+ const data = intentSchema.parse(d)
115
+ if (data.sessionId !== state.sessionId || data.turn !== state.turn || (event.type === INTENT && !state.turn)
116
+ || state.intents.some(row => row.data.operationId === data.operationId) || state.intents.length >= 1024) throw new GraphError('INTENT_SCOPE_REPLAY_OR_LIMIT')
117
+ if (event.type === INTENT && data.clearedWork.length) throw new GraphError('MODEL_INTENT_CANNOT_ACKNOWLEDGE_WORK')
118
+ let uncertainties = state.uncertainties, acknowledgedWork = state.acknowledgedWork
119
+ if (event.type === RECOVERY) {
120
+ for (const work of data.clearedWork) {
121
+ const unknown = uncertainties.find(row => canonical(row) === canonical(work))
122
+ const open = state.calls[work.rootCallId]
123
+ if (!unknown && !(work.rootResultSeq === null && open?.seq === work.callSeq && open.eventDigest === work.eventDigest))
124
+ throw new GraphError('OPERATOR_WORK_ACK_NOT_OWN_RECORDED_FACT')
125
+ }
126
+ const keys = new Set(data.clearedWork.map(workKey))
127
+ uncertainties = uncertainties.filter(row => !keys.has(workKey(row)))
128
+ acknowledgedWork = [...acknowledgedWork, ...data.clearedWork.filter(row => !acknowledgedWork.some(old => workKey(old) === workKey(row)))]
129
+ if (acknowledgedWork.length > 1024) throw new GraphError('ACKNOWLEDGED_WORK_LIMIT')
130
+ }
131
+ return { ...next, uncertainties, acknowledgedWork, intents: [...state.intents, { intentSeq: event.seq, data,
132
+ intentDigest: digest(event), status: event.type === RECOVERY ? 'SUCCESS' : 'PENDING',
133
+ rootResultSeq: event.type === RECOVERY ? event.seq : null, rootResultDigest: event.type === RECOVERY ? digest(event) : null,
134
+ workStartedAtRoot: event.type === RECOVERY ? state.workStarted : null, workExecutedAtRoot: event.type === RECOVERY ? state.workExecuted : null }] }
135
+ }
136
+ if (event.type === 'job/settled') {
137
+ const origin = d.toolCall
138
+ if (d.kind !== 'bash' || d.owner !== state.sessionId || !origin || !Object.hasOwn(state.calls, origin.rootCallId)
139
+ || d.status !== 'completed' || d.producerReported !== true) return next
140
+ // Native Bash reports a nonzero exit in the owned job log even when its tool message is not an error.
141
+ const exit = /^exit code: (-?\d+)(?:;|$)/.exec(d.detail ?? '')
142
+ if (!exit || Number(exit[1]) === 0) return next
143
+ const failure = bashFailureSchema.parse({ rootCallId: origin.rootCallId, callId: origin.callId })
144
+ if (state.bashFailures.some(row => row.rootCallId === failure.rootCallId && row.callId === failure.callId)) return next
145
+ if (state.bashFailures.length >= 256) throw new GraphError('BASH_FAILURE_RECORD_LIMIT')
146
+ return { ...next, bashFailures: [...state.bashFailures, failure] }
147
+ }
148
+ if (event.type === 'tool/ptc-dispatch') {
149
+ const rootCallId = d.rootCallId ?? d.parentCallId
150
+ if (!Object.hasOwn(state.calls, rootCallId)) {
151
+ // Only a child of an open, owned root can become evidence. Once a
152
+ // graph has history, an unexplained dispatch could hide actual work.
153
+ if (state.intents.length || state.receipts.length) throw new GraphError('UNATTRIBUTED_PTC_DISPATCH_IN_GRAPH_SESSION')
154
+ return next
155
+ }
156
+ const dispatch = dispatchSchema.parse({ seq: event.seq, subCallId: d.subCallId, parentCallId: d.parentCallId,
157
+ rootCallId, tool: d.name, isError: d.isError,
158
+ outcome: toolOutcome(state, d.name, rootCallId, d.subCallId, d.content), eventDigest: digest(event), turn: state.turn })
159
+ if (state.dispatches.some(row => row.subCallId === dispatch.subCallId && row.rootCallId === dispatch.rootCallId)
160
+ || state.dispatches.length >= 256) throw new GraphError('PTC_DISPATCH_REPLAY_OR_LIMIT')
161
+ return { ...next, workStarted: state.workStarted + (!internal(d.name) && d.name !== 'run_code' ? 1 : 0),
162
+ dispatches: [...state.dispatches, dispatch] }
163
+ }
164
+ if (event.type === 'tool/result') {
165
+ const root = d.message.source.callId, success = d.message.isError !== true // Optional false in the published ToolResultMessage API.
166
+ const syntheticUnknown = [TOOL_OUTCOME_UNKNOWN, TOOL_NOT_STARTED].includes(d.error?.code)
167
+ const sources = state.dispatches.filter(row => row.rootCallId === root)
168
+ const call = state.calls[root]
169
+ if (!call) {
170
+ if (state.intents.some(row => row.data.rootCallId === root && ['PENDING', 'UNKNOWN'].includes(row.status)))
171
+ throw new GraphError('OWN_INTENT_RESULT_WITHOUT_OWN_TOOL_CALL')
172
+ // Fork/restore seeds may synthesize child-owned closers for an inherited
173
+ // call. They are not child work, evidence, or authority for a candidate.
174
+ return next
175
+ }
176
+ const standaloneCode = call.tool === 'run_code' && !sources.length
177
+ const rootOutcome = toolOutcome(state, call.tool, root, root, d.message.content)
178
+ const rootQualification = syntheticUnknown || rootOutcome === 'UNKNOWN' ? 'UNKNOWN'
179
+ : !success || ['FAIL', 'NOT_RUN'].includes(rootOutcome) ? 'FAILED' : 'SUCCESS'
180
+ const leafCode = sources.filter(row => row.tool === 'run_code' && !sources.some(child => child.parentCallId === row.subCallId))
181
+ const workStarted = state.workStarted + (standaloneCode ? 1 : 0) + leafCode.length
182
+ const executed = sources.filter(row => !internal(row.tool) && (row.tool !== 'run_code' || leafCode.includes(row)) && !['NOT_RUN', 'UNKNOWN'].includes(row.outcome)).length
183
+ + (!internal(call.tool) && (call.tool !== 'run_code' || standaloneCode) && !['NOT_RUN', 'UNKNOWN'].includes(rootOutcome) && !syntheticUnknown ? 1 : 0)
184
+ const workExecuted = state.workExecuted + executed
185
+ const intents = state.intents.map(row => row.data.rootCallId !== root ? row : {
186
+ ...row, status: qualifiedStatus(rootQualification, chainQualification(row.data.callId, root, sources)),
187
+ rootResultSeq: event.seq, rootResultDigest: digest(event), workStartedAtRoot: workStarted, workExecutedAtRoot: workExecuted })
188
+ const rootFact = { seq: event.seq, callId: root, tool: call.tool, isError: !success, outcome: syntheticUnknown ? 'UNKNOWN' : rootOutcome }
189
+ const proofs = sources.filter(row => !internal(row.tool) && (row.tool !== 'run_code' || leafCode.includes(row))).map(row => {
190
+ const qualification = qualifiedStatus(rootQualification, chainQualification(row.subCallId, root, sources))
191
+ return { seq: row.seq, callId: row.subCallId, rootCallId: root, rootResultSeq: event.seq, rootCallSeq: call.seq,
192
+ tool: row.tool, successful: qualification === 'SUCCESS', qualification, isError: row.isError,
193
+ outcome: row.outcome, ownOutcome: row.outcome, ancestorOutcomes: [...ancestryFacts(row.subCallId, root, sources), rootFact],
194
+ eventDigest: row.eventDigest, turn: row.turn }
195
+ })
196
+ if (!internal(call.tool) && (call.tool !== 'run_code' || !sources.length)) proofs.push({ seq: event.seq,
197
+ callId: root, rootCallId: root, rootResultSeq: event.seq, tool: call.tool,
198
+ rootCallSeq: call.seq,
199
+ successful: rootQualification === 'SUCCESS', qualification: rootQualification, isError: !success,
200
+ outcome: syntheticUnknown ? 'UNKNOWN' : rootOutcome, ownOutcome: syntheticUnknown ? 'UNKNOWN' : rootOutcome, ancestorOutcomes: [],
201
+ eventDigest: digest(event), turn: call.turn })
202
+ const calls = { ...state.calls }; delete calls[root]
203
+ let uncertainties = state.uncertainties
204
+ if (rootQualification === 'UNKNOWN' || sources.some(row => row.outcome === 'UNKNOWN') || proofs.some(row => row.qualification === 'UNKNOWN')) {
205
+ const work = { rootCallId: root, callSeq: call.seq, rootResultSeq: event.seq, eventDigest: digest(event) }
206
+ if (!state.acknowledgedWork.some(row => workKey(row) === workKey(work)) && !uncertainties.some(row => workKey(row) === workKey(work)))
207
+ uncertainties = [...uncertainties, work]
208
+ if (uncertainties.length > 1024) throw new GraphError('UNRESOLVED_WORK_LIMIT')
209
+ }
210
+ return { ...next, calls, intents, workStarted, workExecuted, uncertainties, dispatches: state.dispatches.filter(row => row.rootCallId !== root),
211
+ bashFailures: state.bashFailures.filter(row => row.rootCallId !== root),
212
+ proofs: [...state.proofs, ...proofs].slice(-MAX_PROOFS) }
213
+ }
214
+ if (event.type === RECEIPT) {
215
+ const row = z.object({ operationId: bounded(80), status: z.enum(['COMMITTED', 'ABORTED']),
216
+ graphRevision: seq, graphChecksum: hex.nullable() }).strict().parse(d)
217
+ const previous = state.receipts.find(old => old.operationId === row.operationId)
218
+ if (previous && ['status', 'graphRevision', 'graphChecksum'].some(key => previous[key] !== row[key])) throw new GraphError('RECEIPT_CONFLICT')
219
+ return previous ? next : { ...next, receipts: [...state.receipts, { ...row, seq: event.seq, eventDigest: digest(event) }].slice(-1024) }
220
+ }
221
+ if (event.type === 'turn/end') return { ...next, turn: 0, intents: state.intents.map(row => row.status === 'PENDING' ? { ...row, status: 'UNKNOWN' } : row) }
222
+ return next
223
+ } catch (error) { return { ...state, failure: `Transaction projection failed at ${event.seq}: ${error.message}` } }
224
+ }
225
+ export const projection = { key: KEY, stateVersion: 6, stateSchema, init: initialState, apply: applyEvent }
package/remote.mjs ADDED
@@ -0,0 +1,51 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Shared by the Host registration and the lazy browser module.
3
+ export function createRemoteContribution(z) {
4
+ const packageName = 'dsh-bettertaskgraphs'
5
+ const id = z.string().min(1).max(128)
6
+ const input = z.object({ sessionId: id }).strict()
7
+ const node = z.object({
8
+ id, label: z.string().min(1).max(512), kind: z.enum(['container', 'leaf']),
9
+ parent: id.nullable(),
10
+ status: z.enum(['COMPLETE', 'CONTAINER_INCOMPLETE', 'ACTIVE', 'INACTIVE', 'INCOMPLETE', 'PENDING', 'SKIPPED']),
11
+ blocked: z.literal(true).optional(),
12
+ disposition: z.enum(['COMPLETED', 'SKIPPED_NOT_RUN', 'UNSATISFIED', 'FAILED']).optional(),
13
+ branchState: z.enum(['SELECTED', 'WAITING', 'NOT_SELECTED']).optional(),
14
+ selectedBranch: z.object({
15
+ outcome: z.enum(['complete', 'incomplete']), case: z.string().min(1).max(64).optional(), targetId: id.optional(),
16
+ }).strict().optional(),
17
+ }).strict()
18
+ const edge = z.discriminatedUnion('kind', [
19
+ z.object({ from: id, to: id, kind: z.literal('prerequisite') }).strict(),
20
+ z.object({ from: id, to: id, kind: z.literal('route'), outcome: z.enum(['complete', 'incomplete']) }).strict(),
21
+ z.object({ from: id, to: id, kind: z.literal('case'), case: z.string().min(1).max(64) }).strict(),
22
+ ])
23
+ const choice = z.object({ id, choice: z.number().int().min(0).max(127) }).strict()
24
+ const graph = z.object({
25
+ revision: z.number().int().min(1).max(1024), title: z.string().min(1).max(512),
26
+ nodes: z.array(node).min(2).max(128), edges: z.array(edge).max(512),
27
+ active: z.object({ nodeId: id, recoveryRequired: z.boolean() }).strict().nullable(),
28
+ pending: z.object({ operation: z.string().min(1).max(64), qualification: z.string().min(1).max(128) }).strict().nullable(),
29
+ legalNext: z.array(choice).max(128), skipChoices: z.array(choice).max(128),
30
+ }).strict()
31
+ const output = z.discriminatedUnion('status', [
32
+ z.object({ sessionId: id, status: z.literal('ready'), graph }).strict(),
33
+ z.object({ sessionId: id, status: z.literal('empty'), graph: z.null() }).strict(),
34
+ z.object({ sessionId: id, status: z.literal('unavailable'), graph: z.null() }).strict(),
35
+ ])
36
+ const codec = (key, schema) => ({ mode: 'strict', typeSymbol: `${packageName}#${key}`, create: () => schema })
37
+ return {
38
+ package: packageName,
39
+ descriptors: [{
40
+ id: `${packageName}#transactionGraphView/status`,
41
+ service: 'transactionGraph', namespace: 'transactionGraphView', method: 'status', invocation: { kind: 'direct' },
42
+ parameters: [{
43
+ name: 'request', wire: 'request', source: 'json',
44
+ codec: codec('TransactionGraphStatusRequest', input),
45
+ strictCodec: codec('TransactionGraphStatusStrictRequest', input),
46
+ }],
47
+ cancellation: { parameter: 'signal' },
48
+ result: codec('TransactionGraphStatusResult', output),
49
+ }],
50
+ }
51
+ }
@@ -0,0 +1,26 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ import { readFileSync, writeFileSync } from 'node:fs'
3
+
4
+ const root = new URL('../', import.meta.url)
5
+ const { name: packageName, version, license, repository } = JSON.parse(readFileSync(new URL('package.json', root), 'utf8'))
6
+ const sourceUrl = repository.url.replace(/^git\+/, '').replace(/\.git$/, '')
7
+ const sources = ['remote.mjs', 'view-model.mjs', 'client.source.mjs'].map(name => {
8
+ const source = readFileSync(new URL(name, root), 'utf8')
9
+ if (/^import\s/m.test(source)) throw new Error(`${name} must be self-contained for the DSH lazy client factory`)
10
+ return source.replace(/^export\s+(?=(?:function|const|class)\b)/gm, '')
11
+ })
12
+ const output = `// SPDX-License-Identifier: ${license}
13
+ // Corresponding source: ${sourceUrl}/tree/v${version}
14
+ // Generated by scripts/build-client.mjs.
15
+ window.__ModuleLoader__.load({id:${JSON.stringify(packageName)},factory(require){
16
+ ${sources.join('\n')}
17
+ return createClientPlugin(require('react'),require('zod').z,createRemoteContribution,graphPresentation,branchLayout);
18
+ }});
19
+ `
20
+ if (process.argv.slice(2).join(' ') === '--check') {
21
+ if (readFileSync(new URL('client.js', root), 'utf8') !== output) throw new Error('Transaction graph client bundle is stale')
22
+ } else if (process.argv.length === 2) {
23
+ writeFileSync(new URL('client.js', root), output)
24
+ } else {
25
+ throw new Error('Use no arguments to build, or --check to verify')
26
+ }