@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,355 @@
1
+ /**
2
+ * T36 — observe a delegated child's settlement WITHOUT reading session history.
3
+ *
4
+ * THE CONSTRAINT, VERIFIED IN THE CHECKOUT RATHER THAN ASSUMED. DSH prohibits new
5
+ * production calls to `Session.snapshotEvents()`, `Session.eventAt()` and
6
+ * `Session.ownEvents()`: *"existing logic may remain unmigrated for now, but new
7
+ * calls are prohibited"*, the rule is enforced by an executable lint check, and
8
+ * *"copying a waiver to a new production call violates this policy"*. The obvious
9
+ * implementation of an observer — scan the parent's log for the `subagent-settled`
10
+ * notice — is therefore exactly the thing that is forbidden, and it would also be
11
+ * the wrong shape: history reads make ordinary logic depend on storage.
12
+ *
13
+ * THE PRESCRIBED REPLACEMENT is to *"process the delivered current event"* or read
14
+ * maintained projection state. So this module never looks back. It recognises the
15
+ * settlement **at delivery time** (the `session/event` listener passes it as it is
16
+ * committed) and records it in the run's own file state, which is precisely the
17
+ * plugin's zero-emission, file-derived architecture: nothing is appended to the
18
+ * session, and the durable fact lands where every other plugin fact lands.
19
+ *
20
+ * WHY THE NOTICE ARRIVES AT ALL. A continuable child that finishes produces a
21
+ * durable user message in the parent whose source is `{ kind: 'subagent-settled' }`
22
+ * — the parent-side settlement seam. There is no promise to await, which is why
23
+ * `delegateContinuable` takes an injected observer instead of awaiting one.
24
+ */
25
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'
26
+ import { dirname, join } from 'node:path'
27
+ import type { SubagentResultLike, ContinuableChildId, ContinuableMessageId } from './delegation.ts'
28
+
29
+ /** A settlement notice, as the plugin records it. */
30
+ export interface SettlementNotice {
31
+ /** The durable child this settles. */
32
+ childId: string
33
+ /** One line saying the child is finished and why, in the parent's vocabulary. */
34
+ summary: string
35
+ /** The child's own closing text, if it left any. */
36
+ closingText: string
37
+ /** The message id the notice arrived as, when the event carried one. */
38
+ messageId?: string
39
+ }
40
+
41
+ /** Minimal structural view of a delivered session event (no harness import). */
42
+ export interface SessionEventLike {
43
+ type?: string
44
+ data?: unknown
45
+ }
46
+
47
+ function asRecord(value: unknown): Record<string, unknown> | null {
48
+ return typeof value === 'object' && value !== null ? value as Record<string, unknown> : null
49
+ }
50
+
51
+ function textOf(block: unknown): string {
52
+ const record = asRecord(block)
53
+ return record !== null && record.type === 'text' && typeof record.text === 'string' ? record.text : ''
54
+ }
55
+
56
+ /**
57
+ * Recognise a settlement notice from a DELIVERED session event, or null when the
58
+ * event is not one. Pure: no I/O, no history read, safe on any event.
59
+ *
60
+ * The notice's shape is the harness's: a `user/message` whose `source.kind` is
61
+ * `subagent-settled` and whose `source.senderSessionId` names the child. The
62
+ * content is a summary line, then optionally `Its closing message:` followed by
63
+ * the child's own text — so the closing text is everything after that marker, and
64
+ * a child that left nothing yields the harness's own "It left no closing message."
65
+ */
66
+ export function settlementFromEvent(event: SessionEventLike): SettlementNotice | null {
67
+ if (event?.type !== 'user/message') return null
68
+ const message = asRecord(event.data)
69
+ if (message === null) return null
70
+ const source = asRecord(message.source)
71
+ if (source === null || source.kind !== 'subagent-settled') return null
72
+ const childId = typeof source.senderSessionId === 'string' ? source.senderSessionId : ''
73
+ if (childId === '') return null
74
+
75
+ const content = Array.isArray(message.content) ? message.content : []
76
+ const texts = content.map(textOf).filter((t) => t !== '')
77
+ const markerIndex = texts.findIndex((t) => t === 'Its closing message:')
78
+ const summary = typeof source.summary === 'string' && source.summary !== ''
79
+ ? source.summary
80
+ : (texts[0] ?? '')
81
+ const closingText = markerIndex >= 0 ? texts.slice(markerIndex + 1).join('\n\n') : ''
82
+
83
+ const notice: SettlementNotice = { childId, summary, closingText }
84
+ if (typeof message.id === 'string' && message.id !== '') notice.messageId = message.id
85
+ return notice
86
+ }
87
+
88
+ /** The run-scoped append-only settlement log. */
89
+ export function settlementLogPath(runDir: string): string {
90
+ return join(runDir, 'subagents', 'settlements.jsonl')
91
+ }
92
+
93
+ /**
94
+ * Which run owns this child, resolved from the FILESYSTEM alone.
95
+ *
96
+ * The delivered event names the child (`source.senderSessionId`) but not the run,
97
+ * and the plugin's whole design is to derive placement from what is on disk rather
98
+ * than to keep a registry that could go stale. A delegation writes its child
99
+ * directories as `subagents/<delegationId>/child-<childId>/`, so the run holding
100
+ * that child is the run the settlement belongs to.
101
+ *
102
+ * AMBIGUITY RETURNS NULL rather than guessing. Two runs claiming the same child
103
+ * would mean a copied tree, and filing a settlement into the wrong run is worse
104
+ * than not filing it: it would attach one run's evidence to another run's chain.
105
+ * A miss costs a settlement the loop will report as "no settlement yet", which is
106
+ * recoverable; mis-filing is not.
107
+ */
108
+ export function runDirForChild(root: string, childId: string): string | null { if (childId === '' || root === '') return null
109
+ const runsRoot = join(root, '.recursive', 'run')
110
+ let runs: string[]
111
+ try {
112
+ runs = readdirSync(runsRoot, { withFileTypes: true })
113
+ .filter((entry) => entry.isDirectory())
114
+ .map((entry) => entry.name)
115
+ .sort()
116
+ } catch {
117
+ return null
118
+ }
119
+
120
+ const matches: string[] = []
121
+ for (const runName of runs) {
122
+ const runDir = join(runsRoot, runName)
123
+ let delegations: string[]
124
+ try {
125
+ delegations = readdirSync(join(runDir, 'subagents'), { withFileTypes: true })
126
+ .filter((entry) => entry.isDirectory())
127
+ .map((entry) => entry.name)
128
+ } catch {
129
+ continue
130
+ }
131
+ for (const delegationId of delegations) {
132
+ if (existsSync(join(runDir, 'subagents', delegationId, 'child-' + childId))) {
133
+ matches.push(runDir)
134
+ break
135
+ }
136
+ }
137
+ }
138
+ return matches.length === 1 ? matches[0]! : null
139
+ }
140
+
141
+ /**
142
+ * Record one notice in the run's file state. APPEND-ONLY, matching the run's own
143
+ * evidence posture: a settlement that already happened is a fact, and rewriting
144
+ * the log would let a later write erase the record of an earlier round.
145
+ */
146
+ export function recordSettlement(runDir: string, notice: SettlementNotice): string {
147
+ const path = settlementLogPath(runDir)
148
+ mkdirSync(dirname(path), { recursive: true })
149
+ appendFileSync(path, JSON.stringify(notice) + '\n', 'utf8')
150
+ return path
151
+ }
152
+
153
+ /** Every recorded notice for a run, in arrival order. Never throws. */
154
+ /**
155
+ * ⚠ FU-17 — THE ADOPTION PATH, for a settlement whose child this plugin never filed.
156
+ *
157
+ * WHY IT EXISTS. `runDirForChild` attributes a child by the `subagents/<delegation>/child-<id>/` layout that a
158
+ * delegation writes, and it refuses to guess — its own comment: *"filing a settlement into the wrong run is worse
159
+ * than not filing it: it would attach one run's evidence to another run's chain. A miss costs a settlement the
160
+ * loop will report as 'no settlement yet', which is recoverable; mis-filing is not."*
161
+ *
162
+ * That refusal is right, and it has a cost this feature cannot accept: a child started through the harness's own
163
+ * tools (or any path that did not write a delegation directory) settles into nothing, so the run loses evidence
164
+ * of work that really happened — and a phase artifact that must cite an action record cannot cite one that was
165
+ * never filed.
166
+ *
167
+ * THE RULE IS PROGRESSIVE AND NEVER GUESSES BETWEEN RUNS:
168
+ * 1. exactly one run under the root → file into THAT run, under a clearly-marked `adopted-<child>/` directory,
169
+ * and say in the record that it was adopted and why;
170
+ * 2. more than one run → file into a ROOT-level `adopted-settlements.jsonl` instead, because
171
+ * choosing between runs is exactly the mis-filing the rule above forbids. The fact is still recorded, in a
172
+ * place that can attribute nothing to the wrong chain.
173
+ *
174
+ * Returns the path it wrote, or null when there was nothing to write. Never throws: this rides the same hot path
175
+ * as `captureSettlement` and must not break the session it observes.
176
+ */
177
+ export function adoptSettlement(root: string, notice: SettlementNotice): string | null {
178
+ if (notice.childId === '') return null
179
+ try {
180
+ const runsRoot = join(root, '.recursive', 'run')
181
+ let runs: string[] = []
182
+ try {
183
+ runs = readdirSync(runsRoot, { withFileTypes: true })
184
+ .filter((entry) => entry.isDirectory())
185
+ .map((entry) => entry.name)
186
+ .sort()
187
+ } catch {
188
+ return null
189
+ }
190
+
191
+ // Case 2 — ambiguous, so it is recorded OUTSIDE every run's evidence chain.
192
+ if (runs.length !== 1) {
193
+ const path = join(root, '.recursive', 'adopted-settlements.jsonl')
194
+ mkdirSync(dirname(path), { recursive: true })
195
+ appendFileSync(path, JSON.stringify({
196
+ adopted: true,
197
+ reason: 'no delegation directory for this child, and ' + runs.length + ' runs exist',
198
+ run: null,
199
+ childId: notice.childId,
200
+ summary: notice.summary,
201
+ closingText: notice.closingText,
202
+ }) + '\n', 'utf8')
203
+ return path
204
+ }
205
+
206
+ // Case 1 — one run, so the child can only belong to it. Still marked as adopted: the delegation directory
207
+ // the normal path would have used does not exist, and a reader must be able to tell the two apart.
208
+ const path = join(runsRoot, runs[0]!, 'subagents', 'adopted-' + notice.childId, 'settlement.jsonl')
209
+ mkdirSync(dirname(path), { recursive: true })
210
+ appendFileSync(path, JSON.stringify({
211
+ adopted: true,
212
+ reason: 'no delegation directory for this child; single-run workspace',
213
+ childId: notice.childId,
214
+ summary: notice.summary,
215
+ closingText: notice.closingText,
216
+ }) + '\n', 'utf8')
217
+ return path
218
+ } catch {
219
+ return null
220
+ }
221
+ }
222
+
223
+ export function readSettlements(runDir: string): SettlementNotice[] { let raw: string
224
+ try {
225
+ raw = readFileSync(settlementLogPath(runDir), 'utf8')
226
+ } catch {
227
+ return []
228
+ }
229
+ const out: SettlementNotice[] = []
230
+ for (const line of raw.split('\n')) {
231
+ const trimmed = line.trim()
232
+ if (trimmed === '') continue
233
+ try {
234
+ const parsed = asRecord(JSON.parse(trimmed))
235
+ if (parsed !== null && typeof parsed.childId === 'string' && typeof parsed.summary === 'string') {
236
+ const notice: SettlementNotice = {
237
+ childId: parsed.childId,
238
+ summary: parsed.summary,
239
+ closingText: typeof parsed.closingText === 'string' ? parsed.closingText : '',
240
+ }
241
+ if (typeof parsed.messageId === 'string') notice.messageId = parsed.messageId
242
+ out.push(notice)
243
+ }
244
+ } catch {
245
+ // A malformed line is skipped rather than failing the whole read: a log
246
+ // that a partial write corrupted must not make the run unobservable.
247
+ }
248
+ }
249
+ return out
250
+ }
251
+
252
+ /** The LATEST recorded settlement for one child, or null when none has landed. */
253
+ export function readSettlement(runDir: string, childId: string): SettlementNotice | null {
254
+ const mine = readSettlements(runDir).filter((n) => n.childId === childId)
255
+ return mine.length === 0 ? null : mine[mine.length - 1]!
256
+ }
257
+
258
+ /**
259
+ * Turn a recorded settlement into the result shape the delegation loop reads.
260
+ *
261
+ * `success` is deliberately NOT asserted from the summary: the notice reports how
262
+ * the child's turn ENDED, not whether its work is acceptable. Acceptance is the
263
+ * verdict's job (`readVerdict` + `evaluateDelegationResult`), and defaulting to
264
+ * success here would let a stopped child be treated as a passing review — the
265
+ * exact silent-approval failure `delegateContinuable` refuses to fabricate.
266
+ */
267
+ export function settlementResult(notice: SettlementNotice): SubagentResultLike {
268
+ const result: SubagentResultLike = {
269
+ output: notice.closingText,
270
+ stopReason: 'completed',
271
+ } as SubagentResultLike
272
+ // A child that reported structured JSON leaves it as the closing text.
273
+ const parsed = (() => {
274
+ try { return JSON.parse(notice.closingText) } catch { return undefined }
275
+ })()
276
+ if (parsed !== undefined) (result as { structured?: unknown }).structured = parsed
277
+ return result
278
+ }
279
+
280
+ /**
281
+ * Build the `awaitRoundResult` observer the continuable loop injects.
282
+ *
283
+ * ⚠ IT NOW WAITS, AND THAT IS THE FU-9 DECISION (option a, chosen by the user). It used to read the
284
+ * settlement log ONCE and return null immediately, which the loop reports as `parked` — a correct signal, but
285
+ * it meant the parent returned before the child had run at all. The control that shows why that matters: the
286
+ * harness's own team fixture, same recipe, completed two teammates while this plugin's delegated child never
287
+ * got a turn — and the engine's contract explains it, `submitAdmitted` *"crosses the final admission cutoff
288
+ * and submits without yielding"*, so a parent that returns lets the host go idle, and an idle one-shot session
289
+ * never pumps the child's inbox.
290
+ *
291
+ * ⚠ THE PARK IS KEPT, as the fallback it always was. The wait is bounded by `timeoutMs`; when the deadline
292
+ * passes the observer STILL returns null, so the caller still parks, still resumes on a later turn with the
293
+ * SAME child id, and still never treats an unobserved round as approval — the T36 rules are intact. What
294
+ * changed is that parking is now what happens AFTER a real wait rather than INSTEAD of one.
295
+ *
296
+ * `sleep` is injectable so a test asserts the waiting without spending wall-clock time, and `timeoutMs: 0`
297
+ * reproduces the old read-once behaviour exactly — which is what the tests that assert a park use.
298
+ */
299
+ export function settlementRoundObserver(
300
+ runDir: string,
301
+ options: { timeoutMs?: number; pollMs?: number; sleep?: (ms: number) => Promise<void> } = {},
302
+ ): (childId: ContinuableChildId, messageId: ContinuableMessageId) => Promise<SubagentResultLike | null> {
303
+ const timeoutMs = options.timeoutMs ?? 30_000
304
+ const pollMs = options.pollMs ?? 250
305
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
306
+ return async (childId: ContinuableChildId) => {
307
+ const deadline = Date.now() + timeoutMs
308
+ for (;;) {
309
+ const notice = readSettlement(runDir, String(childId))
310
+ if (notice !== null) return settlementResult(notice)
311
+ if (Date.now() >= deadline) return null
312
+ await sleep(pollMs)
313
+ }
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Handle one delivered session event: record it when it is a settlement, and
319
+ * report whether it was one. Wired to `ctx.on('session/event', …)`, which the
320
+ * harness documents as the delivery point for every committed event.
321
+ */
322
+ export function captureSettlement(runDir: string, event: SessionEventLike): SettlementNotice | null {
323
+ const notice = settlementFromEvent(event)
324
+ if (notice === null) return null
325
+ recordSettlement(runDir, notice)
326
+ return notice
327
+ }
328
+
329
+ /**
330
+ * FU-3 — the children a run has on its books, read from the layout the delegations already write.
331
+ *
332
+ * WHY FROM DISK AND NOT FROM MEMORY. A delegation records itself as `subagents/<delegationId>/child-<childId>/`
333
+ * (see {@link runDirForChild}), which is the same fact the settlement log is keyed on. Reading it back
334
+ * means a drain at closeout works **in a fresh process** — after a resume, a crash or a compaction — where
335
+ * an in-memory list of children would be empty and the run would silently leak every child it started.
336
+ *
337
+ * ⚠ AN EMPTY ANSWER IS A REAL ANSWER: a run that delegated nothing has no children, and the caller must
338
+ * be able to tell that from a failed lookup — hence a plain `[]` rather than `null`.
339
+ */
340
+ export function runChildIds(runDir: string): string[] {
341
+ const base = join(runDir, 'subagents')
342
+ if (!existsSync(base)) return []
343
+ const ids: string[] = []
344
+ for (const delegation of readdirSync(base, { withFileTypes: true })) {
345
+ if (!delegation.isDirectory()) continue
346
+ const delegationDir = join(base, delegation.name)
347
+ for (const child of readdirSync(delegationDir, { withFileTypes: true })) {
348
+ if (child.isDirectory() && child.name.startsWith('child-')) {
349
+ const id = child.name.slice('child-'.length)
350
+ if (id !== '' && !ids.includes(id)) ids.push(id)
351
+ }
352
+ }
353
+ }
354
+ return ids
355
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * T12 — register each phase's rules as a SKILL, so they are discoverable in the catalogue.
3
+ *
4
+ * WHY. Phase 8 writes skill memory to the filesystem, and a phase's rules otherwise live only in
5
+ * this plugin's `phase-rules.ts` — reachable by grepping a checkout, not by the agent or a child
6
+ * asking what a phase requires. The native `ctx.skills` registry is a *catalogue*: a skill it
7
+ * holds is discoverable by name, addressable by `get()`, and visible to a discovery consumer.
8
+ * Registering the phase rules there is what turns "the rules are in the source" into "the rules
9
+ * are answerable".
10
+ *
11
+ * ⚠ THE REGISTRATION IS A RUNTIME CONTRIBUTION, and the harness names that source explicitly
12
+ * (`SkillSource` includes `'runtime'`). Declaring `source: 'runtime'` is not decoration: it is how
13
+ * the registry can tell this skill apart from one a checkout or a user directory supplied, and it
14
+ * is what makes the plugin's contribution replaceable rather than tangled with the filesystem's.
15
+ *
16
+ * ⚠ AN ABSENT REGISTRY IS NOT A FAILURE. `ctx.skills` is an optional service; with none mounted
17
+ * the helper does nothing and SAYS it did nothing (`skipped: true`) — the plugin's convention for
18
+ * every optional seam, because a composition without a catalogue should still run the workflow.
19
+ */
20
+ import { phaseRulesFor, type PhaseRules } from './phase-rules.ts'
21
+
22
+ /** The prefix every phase skill carries, so the plugin's contributions are recognisable. */
23
+ export const PHASE_SKILL_PREFIX = 'recursive-phase'
24
+
25
+ /**
26
+ * One runtime skill contribution.
27
+ *
28
+ * Structural rather than imported from the harness: the plugin models every harness touchpoint as
29
+ * a minimal seam, and this keeps the shape testable with a fake registry.
30
+ */
31
+ export interface PhaseSkillRegistration {
32
+ name: string
33
+ description: string
34
+ whenToUse?: string
35
+ content: string
36
+ /** A RUNTIME contribution — see the module comment for why this is declared, not implied. */
37
+ source: 'runtime'
38
+ }
39
+
40
+ export interface SkillRegistryLike {
41
+ register(registration: PhaseSkillRegistration): () => void
42
+ }
43
+
44
+ /**
45
+ * The skill name for a phase file.
46
+ *
47
+ * Kebab-case, because the registry addresses skills by a kebab-case identifier — and STABLE,
48
+ * because a discoverable rule nobody can name is not discoverable. `01.5-root-cause.md` becomes
49
+ * `recursive-phase-01-5-root-cause`: the dot cannot survive, and dropping the sub-phase number
50
+ * would collide it with a whole phase.
51
+ */
52
+ export function phaseSkillName(fileName: string): string {
53
+ const slug = fileName
54
+ .replace(/\.md$/i, '')
55
+ .toLowerCase()
56
+ .replace(/[^a-z0-9]+/g, '-')
57
+ .replace(/^-+|-+$/g, '')
58
+ return PHASE_SKILL_PREFIX + '-' + slug
59
+ }
60
+
61
+ /** The markdown body a reader gets from the catalogue. */
62
+ function phaseSkillContent(rules: PhaseRules): string {
63
+ const lines: string[] = []
64
+ lines.push('# Phase rules: ' + rules.fileName)
65
+ lines.push('')
66
+ lines.push('This phase is ' + rules.label + '. Its required sections are checked by the linter,')
67
+ lines.push('and a missing one is a lint failure — not a style preference.')
68
+ lines.push('')
69
+ lines.push('## Required sections')
70
+ lines.push('')
71
+ for (const section of rules.requiredSections) {
72
+ lines.push('- ' + section)
73
+ }
74
+ if (rules.requiredSections.length === 0) {
75
+ lines.push('- (none declared for this phase)')
76
+ }
77
+ lines.push('')
78
+ lines.push('## Flags')
79
+ lines.push('')
80
+ lines.push('- audited: ' + (rules.audited ? 'yes — this phase needs a delegated audit' : 'no'))
81
+ lines.push('- tdd: ' + (rules.tdd ? 'yes — the TDD Compliance gate applies' : 'no'))
82
+ lines.push('- qa: ' + (rules.qa ? 'yes — the manual QA gate applies' : 'no'))
83
+ return lines.join('\n')
84
+ }
85
+
86
+ /** Build the registration for one phase. Pure, so the mapping is testable without a registry. */
87
+ export function phaseSkillRegistration(rules: PhaseRules): PhaseSkillRegistration {
88
+ const gates: string[] = []
89
+ if (rules.audited) gates.push('audited')
90
+ if (rules.tdd) gates.push('TDD')
91
+ if (rules.qa) gates.push('QA')
92
+ return {
93
+ name: phaseSkillName(rules.fileName),
94
+ description: 'Rules for ' + rules.fileName + ' (' + rules.label + '): its required sections and gates.',
95
+ whenToUse: 'Use when authoring, linting or reviewing ' + rules.fileName
96
+ + (gates.length === 0 ? '.' : ', which carries the ' + gates.join(' / ') + ' gate(s).'),
97
+ content: phaseSkillContent(rules),
98
+ source: 'runtime',
99
+ }
100
+ }
101
+
102
+ export interface PhaseSkillRegistrationResult {
103
+ /** Names registered, in phase order. */
104
+ registered: string[]
105
+ /** Disposers, so a caller can withdraw the contribution with its own effects. */
106
+ disposers: Array<() => void>
107
+ /** True when there was no registry: nothing was registered, and that is not an error. */
108
+ skipped: boolean
109
+ }
110
+
111
+ /**
112
+ * Register every phase's rules as a skill.
113
+ *
114
+ * Takes `fileNames` rather than reading a directory so the caller decides WHICH phases this
115
+ * composition exposes; the rules themselves come from `phaseRulesFor`, so there is one definition
116
+ * of what a phase requires and no second copy to drift.
117
+ */
118
+ export function registerPhaseSkills(
119
+ skills: SkillRegistryLike | null | undefined,
120
+ fileNames: readonly string[],
121
+ workflowProfile?: string,
122
+ ): PhaseSkillRegistrationResult {
123
+ if (skills === null || skills === undefined || typeof skills.register !== 'function') {
124
+ return { registered: [], disposers: [], skipped: true }
125
+ }
126
+ const registered: string[] = []
127
+ const disposers: Array<() => void> = []
128
+ for (const fileName of fileNames) {
129
+ const registration = phaseSkillRegistration(phaseRulesFor(fileName, workflowProfile))
130
+ disposers.push(skills.register(registration))
131
+ registered.push(registration.name)
132
+ }
133
+ return { registered, disposers, skipped: false }
134
+ }
135
+
136
+ /**
137
+ * A board-facing line for the registration result — says SKIPPED out loud rather than leaving a
138
+ * reader to infer a missing catalogue from an empty list.
139
+ */
140
+ export function describePhaseSkills(result: PhaseSkillRegistrationResult): string {
141
+ if (result.skipped) return 'phase rules were NOT registered as skills: no skill registry is mounted'
142
+ return result.registered.length + ' phase rule skill(s) registered: ' + result.registered.join(', ')
143
+ }
package/src/skills.ts ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Packaged `recursive-mode` skill (dsh plugin standard).
3
+ *
4
+ * Ships the workflow's operating contract as a bundled skill through
5
+ * `ctx.skills.registerProvider(...)` — the same shape as the shipped
6
+ * `dsh-skill-badge` provider: a `bundled` candidate at `BUNDLED_SKILL_RANK`
7
+ * (600), body read from the package's shipped `skills/recursive-mode/SKILL.md`
8
+ * (never an inlined TS string literal), with a directory resource base so the
9
+ * skill can resolve its own assets. Skills are optional instructions, not
10
+ * session events: this emits nothing and never appends a recursive/* event.
11
+ *
12
+ * Optionality: `skills` is a host-plane registry; when the composition has
13
+ * none, this is a no-op (returns undefined) rather than failing boot.
14
+ */
15
+ import { readFileSync } from 'node:fs'
16
+ import { dirname, join } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+ import type { Context } from '@deepseek-ai/cordis'
19
+
20
+ /** Package root: <package>/src/.. — skills/ sits next to src/. */
21
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url))
22
+ const PACKAGE_ROOT = join(MODULE_DIR, '..')
23
+
24
+ /** Precedence rank for packaged/bundled skills (mirrors `BUNDLED_SKILL_RANK`). */
25
+ const BUNDLED_SKILL_RANK = 600
26
+ const SKILL_NAME = 'recursive-mode'
27
+ const PROVIDER_NAME = 'recursive-mode'
28
+ const SKILL_BODY_PATH = join(PACKAGE_ROOT, 'skills', SKILL_NAME, 'SKILL.md')
29
+ const SKILL_RESOURCE_BASE = join(PACKAGE_ROOT, 'skills', SKILL_NAME)
30
+ const SKILL_DESCRIPTION =
31
+ 'Drive the recursive-mode workflow: an audit-gated, phase-disciplined loop that carries one requirement from AS-IS through TO-BE plan, implementation, test, and manual QA to locked control-plane state and durable memory. Use whenever the user asks to implement, resume, or advance a recursive run, or when a repo carries a /.recursive/ control plane.'
32
+
33
+ /** Invocation policy: both the model catalog and user `/name` surfaces may load it. */
34
+ const INVOCATION = { modelInvocable: true, userInvocable: true } as const
35
+
36
+ /** One skill contribution, mirrored from the `dsh-skill` registry contract. */
37
+ export interface SkillInvocationPolicyLike {
38
+ readonly modelInvocable: boolean
39
+ readonly userInvocable: boolean
40
+ }
41
+
42
+ /** Provider-owned base for relative resource resolution. */
43
+ export type SkillResourceBaseLike =
44
+ | { readonly kind: 'directory'; readonly path: string }
45
+ | { readonly kind: 'url'; readonly url: string }
46
+ | { readonly kind: 'opaque'; readonly description: string }
47
+
48
+ /** Invocation-neutral summary fields shared by candidates and definitions. */
49
+ export interface SkillSummaryLike {
50
+ readonly name: string
51
+ readonly description: string
52
+ readonly whenToUse?: string
53
+ readonly invocation: SkillInvocationPolicyLike
54
+ readonly source: string
55
+ readonly provider: string
56
+ readonly resourceBase?: SkillResourceBaseLike
57
+ }
58
+
59
+ /** Provider catalog entry: a summary plus rank and an opaque locator. */
60
+ export interface SkillCandidateLike extends SkillSummaryLike {
61
+ readonly rank: number
62
+ readonly locator: unknown
63
+ readonly path?: string
64
+ }
65
+
66
+ /** Complete loaded skill: a summary plus the markdown instruction body. */
67
+ export interface SkillDefinitionLike extends SkillSummaryLike {
68
+ readonly content: string
69
+ readonly path?: string
70
+ }
71
+
72
+ /** Lookup options passed to provider `list`/`get`. */
73
+ export interface SkillLookupOptionsLike {
74
+ readonly cwd?: string | undefined
75
+ readonly signal?: AbortSignal | undefined
76
+ }
77
+
78
+ /** Registration-scoped control borrowed by one provider. */
79
+ export interface SkillProviderControlLike {
80
+ readonly signal: AbortSignal
81
+ readonly invalidate: () => void
82
+ }
83
+
84
+ /** One source of skills, mirrored from the `dsh-skill` SkillProvider contract. */
85
+ export interface SkillProviderLike {
86
+ readonly name: string
87
+ readonly list: (options: SkillLookupOptionsLike) => Promise<readonly SkillCandidateLike[] | { readonly candidates: readonly SkillCandidateLike[]; readonly complete: boolean }>
88
+ readonly get: (candidate: SkillCandidateLike, options: SkillLookupOptionsLike) => Promise<SkillDefinitionLike | undefined>
89
+ }
90
+
91
+ /** Minimal host-realm contract for ctx.skills (the seam we call). */
92
+ export interface SkillsRuntimeLike {
93
+ registerProvider(create: (control: SkillProviderControlLike) => SkillProviderLike): () => void
94
+ }
95
+
96
+ /** The bundled candidate, stable across every `list()` call. */
97
+ function candidate(): SkillCandidateLike {
98
+ return {
99
+ name: SKILL_NAME,
100
+ description: SKILL_DESCRIPTION,
101
+ invocation: INVOCATION,
102
+ provider: PROVIDER_NAME,
103
+ source: 'bundled',
104
+ resourceBase: { kind: 'directory', path: SKILL_RESOURCE_BASE },
105
+ rank: BUNDLED_SKILL_RANK,
106
+ locator: SKILL_BODY_PATH,
107
+ path: SKILL_BODY_PATH,
108
+ }
109
+ }
110
+
111
+ /** Read the shipped operating-contract body (fail loud if the package lost it). */
112
+ function readBody(): string {
113
+ return readFileSync(SKILL_BODY_PATH, 'utf8').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
114
+ }
115
+
116
+ /** The one bundled provider this plugin contributes. */
117
+ function provider(): SkillProviderLike {
118
+ return {
119
+ name: PROVIDER_NAME,
120
+ list: () => Promise.resolve([candidate()]),
121
+ get: async () => ({
122
+ name: SKILL_NAME,
123
+ description: SKILL_DESCRIPTION,
124
+ invocation: INVOCATION,
125
+ provider: PROVIDER_NAME,
126
+ source: 'bundled',
127
+ resourceBase: { kind: 'directory', path: SKILL_RESOURCE_BASE },
128
+ content: readBody(),
129
+ path: SKILL_BODY_PATH,
130
+ }),
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Register the packaged `recursive-mode` skill into the host `skills` registry.
136
+ *
137
+ * Reads `ctx.get('skills')` optionally (the registry is host-plane; a
138
+ * composition without it is valid). On success returns the exact disposer that
139
+ * unregisters the provider; on absence returns undefined (a no-op, never a boot
140
+ * failure).
141
+ */
142
+ export function registerRecursiveSkill(ctx: Context): (() => void) | undefined {
143
+ const skills = ctx.get('skills')
144
+ if (skills === undefined || skills === null) return undefined
145
+ // SAFETY: the single boundary cast asserts the live ctx.skills satisfies the
146
+ // structural SkillsRuntimeLike seam (registerProvider). The live registry's
147
+ // method is a superset of this seam; the provider we return is a plain owned
148
+ // object read through its own leaf fields, never serialized.
149
+ const registry = skills as SkillsRuntimeLike
150
+ return registry.registerProvider(() => provider())
151
+ }