@try-works/dsh-recursive-mode 0.3.0 → 0.4.0

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 (120) hide show
  1. package/README.md +959 -0
  2. package/lib/client.js +9 -2
  3. package/lib/closeout-report.d.ts +113 -0
  4. package/lib/closeout-standards.d.ts +35 -0
  5. package/lib/closeout.d.ts +12 -0
  6. package/lib/commands.d.ts +1 -1
  7. package/lib/config.d.ts +202 -0
  8. package/lib/delegation.d.ts +123 -3
  9. package/lib/enforcement.d.ts +90 -1
  10. package/lib/errors.d.ts +168 -0
  11. package/lib/git-context.d.ts +17 -0
  12. package/lib/guard-log.d.ts +39 -0
  13. package/lib/handoff.d.ts +29 -0
  14. package/lib/hooks.d.ts +103 -0
  15. package/lib/identity.d.ts +61 -0
  16. package/lib/index.d.ts +33 -12
  17. package/lib/index.js +10017 -3969
  18. package/lib/job-log.d.ts +34 -0
  19. package/lib/jobs-runner.d.ts +105 -0
  20. package/lib/json-safe.d.ts +33 -0
  21. package/lib/lock.d.ts +42 -0
  22. package/lib/memory-feedback.d.ts +52 -0
  23. package/lib/memory-select.d.ts +78 -0
  24. package/lib/memory.d.ts +137 -0
  25. package/lib/model-inventory.d.ts +106 -0
  26. package/lib/phase-graph.d.ts +111 -0
  27. package/lib/phase-rules.d.ts +67 -8
  28. package/lib/plan-gate.d.ts +68 -0
  29. package/lib/policy-globs.d.ts +222 -0
  30. package/lib/policy-write.d.ts +42 -0
  31. package/lib/policy.d.ts +39 -0
  32. package/lib/recursive_ask.tool.d.ts +88 -0
  33. package/lib/recursive_closeout.tool.d.ts +1 -1
  34. package/lib/recursive_delegate.tool.d.ts +22 -0
  35. package/lib/recursive_preview.tool.d.ts +48 -0
  36. package/lib/recursive_review.tool.d.ts +28 -0
  37. package/lib/result-cap.d.ts +70 -0
  38. package/lib/review-round.d.ts +82 -0
  39. package/lib/review.d.ts +9 -0
  40. package/lib/role-route.d.ts +122 -0
  41. package/lib/router.d.ts +90 -5
  42. package/lib/runtime.d.ts +252 -12
  43. package/lib/settlement.d.ts +132 -0
  44. package/lib/skills-phase.d.ts +71 -0
  45. package/lib/skills.d.ts +70 -0
  46. package/lib/status.d.ts +53 -1
  47. package/lib/teams-loop.d.ts +91 -2
  48. package/lib/training.d.ts +211 -0
  49. package/lib/ts-lint.d.ts +15 -0
  50. package/lib/types.d.ts +48 -0
  51. package/lib/workflow-audit.d.ts +207 -0
  52. package/package.json +31 -31
  53. package/preset/recursive.patch.yml +312 -0
  54. package/scripts/e2e-run.mjs +51 -0
  55. package/scripts/link-dsh.mjs +233 -0
  56. package/scripts/live/fake-llm.mjs +150 -0
  57. package/scripts/live-session-plugin.mjs +179 -0
  58. package/scripts/live-session-stock.mjs +106 -0
  59. package/scripts/live-session.mjs +139 -0
  60. package/skills/recursive-mode/SKILL.md +66 -0
  61. package/src/client/derive.ts +18 -2
  62. package/src/closeout-report.ts +274 -0
  63. package/src/closeout-standards.ts +102 -0
  64. package/src/closeout.ts +39 -2
  65. package/src/commands.ts +116 -4
  66. package/src/config.ts +113 -0
  67. package/src/delegation.ts +336 -18
  68. package/src/enforcement.ts +262 -72
  69. package/src/errors.ts +197 -0
  70. package/src/git-context.ts +33 -2
  71. package/src/guard-log.ts +134 -0
  72. package/src/handoff.ts +62 -0
  73. package/src/hooks.ts +316 -0
  74. package/src/identity.ts +230 -0
  75. package/src/index.ts +394 -20
  76. package/src/job-log.ts +112 -0
  77. package/src/jobs-runner.ts +222 -0
  78. package/src/json-safe.ts +75 -0
  79. package/src/lock.ts +153 -16
  80. package/src/memory-feedback.ts +185 -0
  81. package/src/memory-select.ts +187 -0
  82. package/src/memory.ts +309 -0
  83. package/src/model-inventory.ts +196 -0
  84. package/src/phase-graph.ts +191 -0
  85. package/src/phase-rules.ts +236 -0
  86. package/src/plan-gate.ts +111 -0
  87. package/src/policy-globs.ts +636 -0
  88. package/src/policy-write.ts +210 -0
  89. package/src/policy.ts +70 -5
  90. package/src/recursive_ask.tool.ts +276 -0
  91. package/src/recursive_audit_team.tool.ts +7 -3
  92. package/src/recursive_closeout.tool.ts +36 -35
  93. package/src/recursive_delegate.tool.ts +194 -0
  94. package/src/recursive_init.tool.ts +4 -3
  95. package/src/recursive_lint.tool.ts +81 -6
  96. package/src/recursive_lock.tool.ts +21 -4
  97. package/src/recursive_phase.tool.ts +3 -2
  98. package/src/recursive_preview.tool.ts +142 -0
  99. package/src/recursive_review.tool.ts +190 -0
  100. package/src/recursive_scratch.tool.ts +5 -4
  101. package/src/recursive_status.tool.ts +3 -2
  102. package/src/recursive_worktree.tool.ts +6 -5
  103. package/src/result-cap.ts +130 -0
  104. package/src/review-round.ts +335 -0
  105. package/src/review.ts +17 -3
  106. package/src/role-route.ts +230 -0
  107. package/src/router.ts +128 -2
  108. package/src/runtime.ts +968 -39
  109. package/src/settlement.ts +355 -0
  110. package/src/skills-phase.ts +143 -0
  111. package/src/skills.ts +151 -0
  112. package/src/snapshot.ts +39 -8
  113. package/src/status.ts +209 -4
  114. package/src/teams-loop.ts +223 -9
  115. package/src/training.ts +565 -0
  116. package/src/ts-lint.ts +38 -4
  117. package/src/types.ts +51 -0
  118. package/src/workflow-audit.ts +288 -0
  119. package/scripts/install-preset.cmd +0 -7
  120. package/scripts/install-preset.js +0 -101
@@ -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
+ }