@try-works/dsh-recursive-mode 0.3.1 → 0.4.1
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 +959 -0
- package/lib/client.js +9 -2
- package/lib/closeout-report.d.ts +113 -0
- package/lib/closeout-standards.d.ts +35 -0
- package/lib/closeout.d.ts +12 -0
- package/lib/commands.d.ts +1 -1
- package/lib/config.d.ts +202 -0
- package/lib/delegation.d.ts +123 -3
- package/lib/enforcement.d.ts +90 -1
- package/lib/errors.d.ts +168 -0
- package/lib/git-context.d.ts +17 -0
- package/lib/guard-log.d.ts +39 -0
- package/lib/handoff.d.ts +29 -0
- package/lib/hooks.d.ts +103 -0
- package/lib/identity.d.ts +61 -0
- package/lib/index.d.ts +32 -12
- package/lib/index.js +9819 -3858
- package/lib/job-log.d.ts +34 -0
- package/lib/jobs-runner.d.ts +105 -0
- package/lib/json-safe.d.ts +33 -0
- package/lib/lock.d.ts +42 -0
- package/lib/memory-feedback.d.ts +52 -0
- package/lib/memory-select.d.ts +78 -0
- package/lib/memory.d.ts +137 -0
- package/lib/model-inventory.d.ts +106 -0
- package/lib/phase-graph.d.ts +111 -0
- package/lib/phase-rules.d.ts +67 -8
- package/lib/plan-gate.d.ts +68 -0
- package/lib/policy-globs.d.ts +222 -0
- package/lib/policy-write.d.ts +42 -0
- package/lib/policy.d.ts +39 -0
- package/lib/recursive_ask.tool.d.ts +88 -0
- package/lib/recursive_closeout.tool.d.ts +1 -1
- package/lib/recursive_delegate.tool.d.ts +22 -0
- package/lib/recursive_preview.tool.d.ts +48 -0
- package/lib/recursive_review.tool.d.ts +28 -0
- package/lib/result-cap.d.ts +70 -0
- package/lib/review-round.d.ts +82 -0
- package/lib/review.d.ts +9 -0
- package/lib/role-route.d.ts +122 -0
- package/lib/router.d.ts +90 -5
- package/lib/runtime.d.ts +252 -12
- package/lib/settlement.d.ts +132 -0
- package/lib/skills-phase.d.ts +71 -0
- package/lib/status.d.ts +53 -1
- package/lib/teams-loop.d.ts +91 -2
- package/lib/training.d.ts +211 -0
- package/lib/ts-lint.d.ts +15 -0
- package/lib/types.d.ts +48 -0
- package/lib/workflow-audit.d.ts +207 -0
- package/package.json +29 -30
- package/preset/recursive/agent.cordis.yml +2 -2
- package/preset/recursive.patch.yml +312 -0
- package/scripts/e2e-run.mjs +51 -0
- package/scripts/link-dsh.mjs +233 -0
- package/scripts/live/fake-llm.mjs +150 -0
- package/scripts/live-session-plugin.mjs +179 -0
- package/scripts/live-session-stock.mjs +106 -0
- package/scripts/live-session.mjs +139 -0
- package/src/client/derive.ts +18 -2
- package/src/closeout-report.ts +274 -0
- package/src/closeout-standards.ts +102 -0
- package/src/closeout.ts +39 -2
- package/src/commands.ts +116 -4
- package/src/config.ts +113 -0
- package/src/delegation.ts +336 -18
- package/src/enforcement.ts +262 -72
- package/src/errors.ts +197 -0
- package/src/git-context.ts +33 -2
- package/src/guard-log.ts +134 -0
- package/src/handoff.ts +62 -0
- package/src/hooks.ts +316 -0
- package/src/identity.ts +230 -0
- package/src/index.ts +385 -20
- package/src/job-log.ts +112 -0
- package/src/jobs-runner.ts +222 -0
- package/src/json-safe.ts +75 -0
- package/src/lock.ts +153 -16
- package/src/memory-feedback.ts +185 -0
- package/src/memory-select.ts +187 -0
- package/src/memory.ts +309 -0
- package/src/model-inventory.ts +196 -0
- package/src/phase-graph.ts +191 -0
- package/src/phase-rules.ts +236 -0
- package/src/plan-gate.ts +111 -0
- package/src/policy-globs.ts +636 -0
- package/src/policy-write.ts +210 -0
- package/src/policy.ts +70 -5
- package/src/recursive_ask.tool.ts +276 -0
- package/src/recursive_audit_team.tool.ts +7 -3
- package/src/recursive_closeout.tool.ts +36 -35
- package/src/recursive_delegate.tool.ts +194 -0
- package/src/recursive_init.tool.ts +4 -3
- package/src/recursive_lint.tool.ts +81 -6
- package/src/recursive_lock.tool.ts +21 -4
- package/src/recursive_phase.tool.ts +3 -2
- package/src/recursive_preview.tool.ts +142 -0
- package/src/recursive_review.tool.ts +190 -0
- package/src/recursive_scratch.tool.ts +5 -4
- package/src/recursive_status.tool.ts +3 -2
- package/src/recursive_worktree.tool.ts +6 -5
- package/src/result-cap.ts +130 -0
- package/src/review-round.ts +335 -0
- package/src/review.ts +17 -3
- package/src/role-route.ts +230 -0
- package/src/router.ts +128 -2
- package/src/runtime.ts +968 -39
- package/src/settlement.ts +355 -0
- package/src/skills-phase.ts +143 -0
- package/src/snapshot.ts +39 -8
- package/src/status.ts +209 -4
- package/src/teams-loop.ts +223 -9
- package/src/training.ts +565 -0
- package/src/ts-lint.ts +38 -4
- package/src/types.ts +51 -0
- package/src/workflow-audit.ts +288 -0
- package/scripts/install-preset.cmd +0 -7
- package/scripts/install-preset.js +0 -101
package/src/identity.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* identity.ts — T19: deterministic operation identity.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. `reopen` is the one genuinely destructive operation in the
|
|
5
|
+
* workflow, and a retry after a partial failure was indistinguishable from a fresh
|
|
6
|
+
* request: the same reopen could run twice, and an interrupted delegated round could
|
|
7
|
+
* be executed again. Identity makes a retry recognisable as the SAME operation.
|
|
8
|
+
*
|
|
9
|
+
* THE SHAPE IS NOT INVENTED HERE. Tardigrade's `InputDigest` canonicalises input
|
|
10
|
+
* (sorted keys, lone surrogates and non-finite numbers rejected) then hashes it with
|
|
11
|
+
* the byte length, inlining below a size limit and digesting above; Effect's
|
|
12
|
+
* `makeExecutionIdFromPayload` hashes `tag.length:tag:payload`. This module is the
|
|
13
|
+
* same rule, and the `tag.length:` prefix is not decoration: without the length, two
|
|
14
|
+
* different `(act, input)` pairs could concatenate to one string.
|
|
15
|
+
*
|
|
16
|
+
* WHY A FILE FOR THE INDEX, and not `ctx.storageDomain` (plan §4.0). The same reason
|
|
17
|
+
* `guard-log.ts` gives for its own store: the record must survive a restart and be
|
|
18
|
+
* inspectable out-of-band, and a file keeps the plugin free of a service that may be
|
|
19
|
+
* absent. The plan's actual requirement is that the index is PERSISTED rather than
|
|
20
|
+
* derived — no artifact on disk says "this operation was already attempted", which is
|
|
21
|
+
* exactly what makes the index worth keeping — and a bounded, git-ignored file meets
|
|
22
|
+
* it. The degradation the plan asks for becomes the trivial "no index file yet".
|
|
23
|
+
*
|
|
24
|
+
* Every function is BEST-EFFORT on the read/write path and never throws for a
|
|
25
|
+
* missing or corrupt index; only the canonicaliser throws, and only for input that
|
|
26
|
+
* has no honest canonical form.
|
|
27
|
+
*/
|
|
28
|
+
import { createHash } from 'node:crypto'
|
|
29
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
30
|
+
import { dirname, join } from 'node:path'
|
|
31
|
+
|
|
32
|
+
/** Canonical forms at or below this size are inlined into the key; larger ones are digested. */
|
|
33
|
+
export const INLINE_LIMIT_BYTES = 2048
|
|
34
|
+
|
|
35
|
+
/** How much of the digest becomes the id. 128 bits — ample for a local index. */
|
|
36
|
+
const ID_HEX_CHARS = 32
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reject a string a hash could not faithfully represent.
|
|
40
|
+
*
|
|
41
|
+
* A lone surrogate has no valid UTF-8 encoding, so hashing it would hash a
|
|
42
|
+
* replacement character: two genuinely different inputs would share one identity,
|
|
43
|
+
* which is worse than refusing.
|
|
44
|
+
*/
|
|
45
|
+
function assertEncodable(value: string, where: string): void {
|
|
46
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
47
|
+
const code = value.charCodeAt(i)
|
|
48
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
49
|
+
const next = value.charCodeAt(i + 1)
|
|
50
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) {
|
|
51
|
+
throw new Error('canonicalInput: lone surrogate at ' + where + ' (no valid UTF-8 encoding)')
|
|
52
|
+
}
|
|
53
|
+
i += 1
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if (code >= 0xdc00 && code <= 0xdfff) {
|
|
57
|
+
throw new Error('canonicalInput: lone surrogate at ' + where + ' (no valid UTF-8 encoding)')
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Serialize one value with object keys sorted at every depth; arrays keep their order. */
|
|
63
|
+
function serialize(value: unknown, where: string): string {
|
|
64
|
+
if (value === null) return 'null'
|
|
65
|
+
switch (typeof value) {
|
|
66
|
+
case 'boolean':
|
|
67
|
+
return value ? 'true' : 'false'
|
|
68
|
+
case 'number':
|
|
69
|
+
// `JSON.stringify(NaN)` is `null`, which would alias NaN onto a real null.
|
|
70
|
+
if (!Number.isFinite(value)) throw new Error('canonicalInput: non-finite number at ' + where)
|
|
71
|
+
return JSON.stringify(value)
|
|
72
|
+
case 'string':
|
|
73
|
+
assertEncodable(value, where)
|
|
74
|
+
return JSON.stringify(value)
|
|
75
|
+
case 'undefined':
|
|
76
|
+
throw new Error('canonicalInput: undefined at ' + where + ' has no canonical form')
|
|
77
|
+
case 'bigint':
|
|
78
|
+
throw new Error('canonicalInput: bigint at ' + where + ' has no canonical form')
|
|
79
|
+
case 'function':
|
|
80
|
+
case 'symbol':
|
|
81
|
+
throw new Error('canonicalInput: ' + typeof value + ' at ' + where + ' has no canonical form')
|
|
82
|
+
default:
|
|
83
|
+
break
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
return '[' + value.map((item, index) => serialize(item, where + '[' + index + ']')).join(',') + ']'
|
|
87
|
+
}
|
|
88
|
+
const record = value as Record<string, unknown>
|
|
89
|
+
const keys = Object.keys(record).sort()
|
|
90
|
+
const parts = keys.map((key) => {
|
|
91
|
+
assertEncodable(key, where)
|
|
92
|
+
return JSON.stringify(key) + ':' + serialize(record[key], where + '.' + key)
|
|
93
|
+
})
|
|
94
|
+
return '{' + parts.join(',') + '}'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Canonical JSON for an input: object keys sorted at every depth, arrays in order.
|
|
99
|
+
* Throws for input with no honest canonical form (a lone surrogate, a non-finite
|
|
100
|
+
* number, `undefined`) rather than silently substituting something hashable.
|
|
101
|
+
*/
|
|
102
|
+
export function canonicalInput(value: unknown): string {
|
|
103
|
+
return serialize(value, '$')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sha256(text: string): string {
|
|
107
|
+
return createHash('sha256').update(text, 'utf8').digest('hex')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The bounded key for one input: the canonical form inline when it is small, and a
|
|
112
|
+
* digest plus byte length when it is not. The length is kept so a truncated input
|
|
113
|
+
* cannot collide with a genuinely different one of the same digest.
|
|
114
|
+
*/
|
|
115
|
+
export function idempotencyKey(input: unknown): string {
|
|
116
|
+
const canonical = canonicalInput(input)
|
|
117
|
+
const bytes = Buffer.byteLength(canonical, 'utf8')
|
|
118
|
+
if (bytes <= INLINE_LIMIT_BYTES) return 'inline:' + canonical
|
|
119
|
+
return 'sha256:' + sha256(canonical) + ':' + bytes
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The deterministic id of an operation: same act and same input give the same id,
|
|
124
|
+
* whatever the key order, and a materially different operation never does.
|
|
125
|
+
*/
|
|
126
|
+
export function operationId(input: { act: string; input: unknown }): string {
|
|
127
|
+
const act = input.act
|
|
128
|
+
assertEncodable(act, 'act')
|
|
129
|
+
return sha256(act.length + ':' + act + ':' + idempotencyKey(input.input)).slice(0, ID_HEX_CHARS)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** One recorded attempt. The index holds only what makes a retry recognisable. */
|
|
133
|
+
export interface OperationRecord {
|
|
134
|
+
id: string
|
|
135
|
+
act: string
|
|
136
|
+
/** When the attempt was recorded (caller-supplied, so a test can be deterministic). */
|
|
137
|
+
at: string
|
|
138
|
+
/** What happened, when the caller knows: e.g. `applied`, `refused`, `interrupted`. */
|
|
139
|
+
outcome?: string
|
|
140
|
+
/**
|
|
141
|
+
* T28: the phase this operation belongs to, when it has one.
|
|
142
|
+
*
|
|
143
|
+
* Recorded so a budget can be counted FROM the index rather than tracked
|
|
144
|
+
* separately — the children-per-phase cap is `readOperations(...)` filtered by
|
|
145
|
+
* phase, which needs no new state and cannot drift from the operations it counts.
|
|
146
|
+
*/
|
|
147
|
+
phase?: string
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The run-scoped, append-only operation index. Bounded by use, git-ignored with the run. */
|
|
151
|
+
export function operationsPath(runDir: string): string {
|
|
152
|
+
return join(runDir, 'operations', 'operations.jsonl')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Every recorded attempt, oldest first. Never throws; a corrupt line is skipped. */
|
|
156
|
+
export function readOperations(runDir: string): OperationRecord[] {
|
|
157
|
+
let raw: string
|
|
158
|
+
try {
|
|
159
|
+
raw = readFileSync(operationsPath(runDir), 'utf8')
|
|
160
|
+
} catch {
|
|
161
|
+
return []
|
|
162
|
+
}
|
|
163
|
+
const out: OperationRecord[] = []
|
|
164
|
+
for (const line of raw.split('\n')) {
|
|
165
|
+
const trimmed = line.trim()
|
|
166
|
+
if (trimmed === '') continue
|
|
167
|
+
try {
|
|
168
|
+
const parsed = JSON.parse(trimmed) as Partial<OperationRecord> | null
|
|
169
|
+
if (parsed === null || typeof parsed !== 'object') continue
|
|
170
|
+
if (typeof parsed.id !== 'string' || typeof parsed.act !== 'string') continue
|
|
171
|
+
const record: OperationRecord = {
|
|
172
|
+
id: parsed.id,
|
|
173
|
+
act: parsed.act,
|
|
174
|
+
at: typeof parsed.at === 'string' ? parsed.at : '',
|
|
175
|
+
}
|
|
176
|
+
if (typeof parsed.outcome === 'string') record.outcome = parsed.outcome
|
|
177
|
+
if (typeof parsed.phase === 'string') record.phase = parsed.phase
|
|
178
|
+
out.push(record)
|
|
179
|
+
} catch {
|
|
180
|
+
// A partially written line must not make the run unreadable.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The LATEST record for an id, or null when the operation has never been attempted. */
|
|
187
|
+
export function findOperation(runDir: string, id: string): OperationRecord | null {
|
|
188
|
+
const mine = readOperations(runDir).filter((record) => record.id === id)
|
|
189
|
+
return mine.length === 0 ? null : mine[mine.length - 1]!
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Append one attempt. Best-effort: a failed write must never change an operation's outcome. */
|
|
193
|
+
export function recordOperation(runDir: string, record: OperationRecord): boolean {
|
|
194
|
+
const path = operationsPath(runDir)
|
|
195
|
+
try {
|
|
196
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
197
|
+
appendFileSync(path, JSON.stringify(record) + '\n', 'utf8')
|
|
198
|
+
return true
|
|
199
|
+
} catch {
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** True when this exact operation has already been attempted in this run. */
|
|
205
|
+
export function wasAttempted(runDir: string, id: string): boolean {
|
|
206
|
+
return findOperation(runDir, id) !== null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** True when the index exists at all — lets a caller distinguish "no retry" from "no index". */
|
|
210
|
+
export function hasIndex(runDir: string): boolean {
|
|
211
|
+
return existsSync(operationsPath(runDir))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* T28: how many DISTINCT operations of one act have been recorded for one phase.
|
|
216
|
+
*
|
|
217
|
+
* Counted from the index rather than tracked in parallel, so a budget cannot drift
|
|
218
|
+
* away from the operations it bounds. DISTINCT ids, not records, because a resumed
|
|
219
|
+
* turn re-records the SAME operation: counting records would make a long review look
|
|
220
|
+
* like many children and fire the cap on legitimate work, while a genuine new
|
|
221
|
+
* operation (a repaired artifact changes the body, hence the id) still counts.
|
|
222
|
+
*/
|
|
223
|
+
export function countOperations(runDir: string, act: string, phase: string): number {
|
|
224
|
+
const ids = new Set(
|
|
225
|
+
readOperations(runDir)
|
|
226
|
+
.filter((record) => record.act === act && record.phase === phase)
|
|
227
|
+
.map((record) => record.id),
|
|
228
|
+
)
|
|
229
|
+
return ids.size
|
|
230
|
+
}
|