@patwoodman/dsh-sdlc-flow-tools 0.0.0-stage → 0.4.0-beta.14
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/LICENSE +21 -0
- package/README.md +28 -2
- package/cordis.patch.yml +11 -0
- package/icon.svg +28 -0
- package/lib/index.js +230 -0
- package/lib/plugin.js +85 -0
- package/lib/selftest.js +119 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +52 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SDLC Orchestrator contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @patwoodman/dsh-sdlc-flow-tools — model tools and the `/sdlc` command surface
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The propose-only tool surface, installed in **both** the lead and worker
|
|
4
|
+
profiles: workers need `sdlc_report_result`, `sdlc_claim_check` and
|
|
5
|
+
`sdlc_request_decision` to write their outbox proposal; the lead additionally
|
|
6
|
+
gets the delegation and planning tools. The `/sdlc` command surface
|
|
7
|
+
(`run`, `status`, `stop`, `resume`, `doctor`, `config`, `verify`, `explain`,
|
|
8
|
+
`debug`) is registered in interactive profiles.
|
|
9
|
+
|
|
10
|
+
None of these tools may grant authority — that is the lead's human-only
|
|
11
|
+
privilege (spec L.1 rule 4).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
dsh plugin --profile sdlc-worker add @patwoodman/dsh-sdlc-flow-guard @patwoodman/dsh-sdlc-flow-tools
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
It is also a member of [`@patwoodman/dsh-sdlc-flow`](https://www.npmjs.com/package/@patwoodman/dsh-sdlc-flow),
|
|
20
|
+
so a lead install brings it in without a second spec.
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- `@deepseek-ai/dsh` `>=0.2.0-rc.2 <0.3.0-0`
|
|
25
|
+
- Node.js ≥ 22.5
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
|
|
29
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# sdlc-tools — model-facing tools + the /sdlc command surface (spec v3 C.4).
|
|
2
|
+
#
|
|
3
|
+
# Installed into BOTH the lead and worker profiles (E6): the worker needs
|
|
4
|
+
# sdlc_report_result / sdlc_claim_check / sdlc_request_decision to write its
|
|
5
|
+
# outbox proposal; the lead additionally gets the delegation/planning tools.
|
|
6
|
+
# Tools are propose-only — none of them may grant authority (L.1 rule 4).
|
|
7
|
+
- insert:
|
|
8
|
+
- id: sdlc-tools
|
|
9
|
+
name: '@patwoodman/dsh-sdlc-flow-tools/plugin'
|
|
10
|
+
config:
|
|
11
|
+
worktreePath: !!js process.cwd()
|
package/icon.svg
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Universal SDLC Orchestrator">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="sdlc-bg" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#1e3a8a"/>
|
|
5
|
+
<stop offset="1" stop-color="#0e7490"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect width="64" height="64" rx="14" fill="url(#sdlc-bg)"/>
|
|
9
|
+
<g fill="none" stroke="#e0f2fe" stroke-width="2.4" stroke-linecap="round">
|
|
10
|
+
<path d="M32 17v9"/>
|
|
11
|
+
<path d="M32 34v4"/>
|
|
12
|
+
<path d="M24 44l-8 6"/>
|
|
13
|
+
<path d="M40 44l8 6"/>
|
|
14
|
+
</g>
|
|
15
|
+
<g fill="#e0f2fe">
|
|
16
|
+
<circle cx="32" cy="13" r="4.6"/>
|
|
17
|
+
<circle cx="32" cy="29" r="4.6"/>
|
|
18
|
+
<circle cx="19" cy="47" r="4.6"/>
|
|
19
|
+
<circle cx="45" cy="47" r="4.6"/>
|
|
20
|
+
</g>
|
|
21
|
+
<g fill="#0e7490">
|
|
22
|
+
<circle cx="32" cy="13" r="1.7"/>
|
|
23
|
+
<circle cx="32" cy="29" r="1.7"/>
|
|
24
|
+
<circle cx="19" cy="47" r="1.7"/>
|
|
25
|
+
<circle cx="45" cy="47" r="1.7"/>
|
|
26
|
+
</g>
|
|
27
|
+
<path d="M27.5 39.5l4.5 4.5 4.5-4.5" fill="none" stroke="#67e8f9" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
28
|
+
</svg>
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/sdlc-tools/src/index.js — model-facing tools and the /sdlc command surface.
|
|
3
|
+
*
|
|
4
|
+
* AUTHORITY (spec v3 C.4, L.1): tools are PROPOSE-ONLY. They run inside the
|
|
5
|
+
* worker's own process and write a schema-validated proposal to the worktree
|
|
6
|
+
* outbox (B.3.2); they have no `sdlc.db` access and no cross-process write path.
|
|
7
|
+
* The transition service validates and applies. Anything that GRANTS authority
|
|
8
|
+
* (approve/reject/stop) is a user-only command handler — never a model tool.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { appendFileSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { buildOutbox, OUTBOX_PATH } from '@patwoodman/dsh-sdlc-flow-core/worker-result';
|
|
14
|
+
|
|
15
|
+
export const WORKER_TOOLS = ['sdlc_report_result', 'sdlc_claim_check', 'sdlc_request_decision'];
|
|
16
|
+
export const LEAD_TOOLS = ['sdlc_dispatch_task', 'sdlc_approve_transition', 'sdlc_record_decision', 'sdlc_plan_delta'];
|
|
17
|
+
export const ALL_TOOLS = [...WORKER_TOOLS, ...LEAD_TOOLS];
|
|
18
|
+
|
|
19
|
+
export const COMMANDS = [
|
|
20
|
+
'run', 'status', 'tasks', 'workers', 'models', 'logs', 'verify', 'resume', 'stop',
|
|
21
|
+
'explain', 'retry', 'elevate', 'approve', 'reject', 'doctor', 'config', 'init', 'lock', 'selftest',
|
|
22
|
+
'debug',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The `debug` command surface (review + improvement). Read-only: it renders
|
|
27
|
+
* recorded run anatomy (time/tokens/tracepaths) side by side. Like `status`
|
|
28
|
+
* and `explain` it grants no authority, so it is available to every mode —
|
|
29
|
+
* including headless deployments that are otherwise guests of the state
|
|
30
|
+
* machine (fail-closed, C.2/L.5).
|
|
31
|
+
*/
|
|
32
|
+
export const DEBUG_COMMANDS = ['compare', 'trace'];
|
|
33
|
+
|
|
34
|
+
/** Commands that grant authority; user-only, executed directly by the handler. */
|
|
35
|
+
export const AUTHORITY_COMMANDS = ['approve', 'reject', 'stop', 'retry', 'elevate'];
|
|
36
|
+
|
|
37
|
+
const S = (type, description, extra = {}) => ({ type, description, ...extra });
|
|
38
|
+
const ARRAY_STR = (description) => S('array', description, { items: { type: 'string' } });
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Tool definitions in the `defineTool` DSL shape (C.4): every tool has a
|
|
42
|
+
* canonical `output` schema and a pure `render()`.
|
|
43
|
+
*/
|
|
44
|
+
export const TOOL_DEFS = {
|
|
45
|
+
sdlc_report_result: {
|
|
46
|
+
name: 'sdlc_report_result',
|
|
47
|
+
description: 'Report the outcome of this task. Writes a proposal to .sdlc/task-outbox.json; the runtime verifies it against the git diff.',
|
|
48
|
+
parameters: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
status: S('string', 'completed | failed | aborted', { enum: ['completed', 'failed', 'aborted'] }),
|
|
52
|
+
summary: S('string', 'One-paragraph summary of what changed'),
|
|
53
|
+
evidence_claims: S('array', 'Per acceptance criterion claims', {
|
|
54
|
+
items: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: { ac_id: S('string', 'AC id'), claim: S('string', 'pass | fail'), artifact: S('string', 'Path or test id') },
|
|
57
|
+
required: ['ac_id', 'claim'],
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
tests_run: ARRAY_STR('Test commands or selectors that were run'),
|
|
61
|
+
requests: ARRAY_STR('Explicit asks for the Lead (dependencies, decisions)'),
|
|
62
|
+
},
|
|
63
|
+
required: ['status', 'summary'],
|
|
64
|
+
},
|
|
65
|
+
output: { schema: { type: 'object', properties: { ok: S('boolean', 'proposal accepted') } }, render: (r) => (r.ok ? 'result recorded' : 'result rejected') },
|
|
66
|
+
authority: 'propose',
|
|
67
|
+
},
|
|
68
|
+
sdlc_claim_check: {
|
|
69
|
+
name: 'sdlc_claim_check',
|
|
70
|
+
description: 'Claim that a specific acceptance-criteria check passed, with its artifact. Claims are hints; the verifier re-runs the gate.',
|
|
71
|
+
parameters: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
ac_id: S('string', 'Acceptance criterion id'),
|
|
75
|
+
claim: S('string', 'pass | fail', { enum: ['pass', 'fail'] }),
|
|
76
|
+
artifact: S('string', 'Evidence path or test identifier'),
|
|
77
|
+
},
|
|
78
|
+
required: ['ac_id', 'claim'],
|
|
79
|
+
},
|
|
80
|
+
output: { schema: { type: 'object', properties: { ok: S('boolean', 'claim recorded') } }, render: (r) => (r.ok ? 'claim recorded' : 'claim rejected') },
|
|
81
|
+
authority: 'propose',
|
|
82
|
+
},
|
|
83
|
+
sdlc_request_decision: {
|
|
84
|
+
name: 'sdlc_request_decision',
|
|
85
|
+
description: 'Ask the Lead for a decision or a missing dependency. In a headless worker this resolves unavailable and the task stays BLOCKED (fail-closed).',
|
|
86
|
+
parameters: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
question: S('string', 'The decision required'),
|
|
90
|
+
options: ARRAY_STR('Candidate options'),
|
|
91
|
+
blocking: S('boolean', 'Whether work cannot continue without an answer'),
|
|
92
|
+
},
|
|
93
|
+
required: ['question'],
|
|
94
|
+
},
|
|
95
|
+
output: { schema: { type: 'object', properties: { ok: S('boolean', 'request queued') } }, render: (r) => (r.ok ? 'decision requested' : 'request rejected') },
|
|
96
|
+
authority: 'propose',
|
|
97
|
+
},
|
|
98
|
+
sdlc_dispatch_task: {
|
|
99
|
+
name: 'sdlc_dispatch_task',
|
|
100
|
+
description: 'Propose dispatching a persisted task. The scheduler decides; an illegal proposal returns INVALID_TRANSITION.',
|
|
101
|
+
parameters: {
|
|
102
|
+
type: 'object',
|
|
103
|
+
properties: { task_id: S('string', 'Task id'), note: S('string', 'Why now') },
|
|
104
|
+
required: ['task_id'],
|
|
105
|
+
},
|
|
106
|
+
output: { schema: { type: 'object', properties: { ok: S('boolean', ''), error: S('string', '') } }, render: (r) => (r.ok ? `dispatched ${r.task_id ?? ''}` : r.error) },
|
|
107
|
+
authority: 'propose',
|
|
108
|
+
},
|
|
109
|
+
sdlc_approve_transition: {
|
|
110
|
+
name: 'sdlc_approve_transition',
|
|
111
|
+
description: 'Propose approving a transition. The runtime applies it only if the transition matrix and evidence allow it; a model can never grant authority (L.1 rule 4).',
|
|
112
|
+
parameters: {
|
|
113
|
+
type: 'object',
|
|
114
|
+
properties: { task_id: S('string', 'Task id'), to: S('string', 'Target state'), reason: S('string', 'Evidence') },
|
|
115
|
+
required: ['task_id', 'to'],
|
|
116
|
+
},
|
|
117
|
+
output: { schema: { type: 'object', properties: { ok: S('boolean', ''), error: S('string', '') } }, render: (r) => (r.ok ? 'proposal recorded' : r.error) },
|
|
118
|
+
authority: 'propose',
|
|
119
|
+
},
|
|
120
|
+
sdlc_record_decision: {
|
|
121
|
+
name: 'sdlc_record_decision',
|
|
122
|
+
description: 'Propose recording an ADR in the decision ledger.',
|
|
123
|
+
parameters: {
|
|
124
|
+
type: 'object',
|
|
125
|
+
properties: {
|
|
126
|
+
question: S('string', ''), decision: S('string', ''), reason: S('string', ''),
|
|
127
|
+
alternatives: ARRAY_STR(''), consequences: ARRAY_STR(''), tags: ARRAY_STR(''),
|
|
128
|
+
},
|
|
129
|
+
required: ['question', 'decision'],
|
|
130
|
+
},
|
|
131
|
+
output: { schema: { type: 'object', properties: { ok: S('boolean', '') } }, render: (r) => (r.ok ? 'decision recorded' : 'rejected') },
|
|
132
|
+
authority: 'propose',
|
|
133
|
+
},
|
|
134
|
+
sdlc_plan_delta: {
|
|
135
|
+
name: 'sdlc_plan_delta',
|
|
136
|
+
description: 'Propose adding or editing tasks. The runtime applies deltas only through the normal planner path (spec §36).',
|
|
137
|
+
parameters: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
properties: { tasks: S('array', 'Task proposals', { items: { type: 'object' } }), reason: S('string', '') },
|
|
140
|
+
required: ['tasks'],
|
|
141
|
+
},
|
|
142
|
+
output: { schema: { type: 'object', properties: { ok: S('boolean', ''), count: S('number', '') } }, render: (r) => (r.ok ? `${r.count ?? 0} task proposals queued` : 'rejected') },
|
|
143
|
+
authority: 'propose',
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Atomic outbox write (write temp + rename) — B.3.2. */
|
|
148
|
+
export function writeOutbox(worktreePath, envelope) {
|
|
149
|
+
const target = join(worktreePath, OUTBOX_PATH);
|
|
150
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
151
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
152
|
+
writeFileSync(tmp, JSON.stringify(envelope, null, 2) + '\n');
|
|
153
|
+
renameSync(tmp, target);
|
|
154
|
+
return target;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function appendRequest(worktreePath, request) {
|
|
158
|
+
const target = join(worktreePath, '.sdlc', 'requests.jsonl');
|
|
159
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
160
|
+
appendFileSync(target, JSON.stringify({ ...request, at: new Date().toISOString() }) + '\n');
|
|
161
|
+
return target;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Handlers for the proposal channel. `allowed_paths`/`forbidden_paths` are NOT
|
|
166
|
+
* consulted here — the guard does that; these handlers only shape proposals.
|
|
167
|
+
*/
|
|
168
|
+
export function createHandlers({ worktreePath, runMeta = {} } = {}) {
|
|
169
|
+
const state = { claims: [], requests: [], reported: null };
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
sdlc_report_result(args) {
|
|
173
|
+
const envelope = buildOutbox({
|
|
174
|
+
runId: runMeta.runId, taskId: runMeta.taskId, attempt: runMeta.attempt, nonce: runMeta.nonce,
|
|
175
|
+
claims: {
|
|
176
|
+
status: args.status, summary: args.summary,
|
|
177
|
+
evidence_claims: args.evidence_claims ?? state.claims,
|
|
178
|
+
tests_run: args.tests_run ?? [], requests: [...state.requests, ...(args.requests ?? [])],
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
if (worktreePath) writeOutbox(worktreePath, envelope);
|
|
182
|
+
state.reported = envelope;
|
|
183
|
+
return { ok: true, envelope };
|
|
184
|
+
},
|
|
185
|
+
sdlc_claim_check(args) {
|
|
186
|
+
state.claims.push({ ac_id: args.ac_id, claim: args.claim, artifact: args.artifact ?? null });
|
|
187
|
+
return { ok: true, claims: state.claims.length };
|
|
188
|
+
},
|
|
189
|
+
sdlc_request_decision(args) {
|
|
190
|
+
const request = { question: args.question, options: args.options ?? [], blocking: Boolean(args.blocking) };
|
|
191
|
+
state.requests.push(request);
|
|
192
|
+
if (worktreePath) appendRequest(worktreePath, request);
|
|
193
|
+
return { ok: true, queued: true, blocking: request.blocking };
|
|
194
|
+
},
|
|
195
|
+
sdlc_dispatch_task(args) {
|
|
196
|
+
return { ok: false, error: 'INVALID_TRANSITION: dispatch is owned by the scheduler', task_id: args.task_id };
|
|
197
|
+
},
|
|
198
|
+
sdlc_approve_transition(args) {
|
|
199
|
+
return { ok: false, error: 'AUTHORITY_DENIED: approvals are user-only (L.1 rule 4)', task_id: args.task_id };
|
|
200
|
+
},
|
|
201
|
+
sdlc_record_decision(args) {
|
|
202
|
+
return { ok: true, proposal: { kind: 'decision', ...args } };
|
|
203
|
+
},
|
|
204
|
+
sdlc_plan_delta(args) {
|
|
205
|
+
return { ok: true, count: (args.tasks ?? []).length, proposal: { kind: 'plan-delta', ...args } };
|
|
206
|
+
},
|
|
207
|
+
_state: state,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Validate args against the tool's parameter schema (minimal JSON-Schema subset). */
|
|
212
|
+
export function validateToolArgs(name, args = {}) {
|
|
213
|
+
const def = TOOL_DEFS[name];
|
|
214
|
+
if (!def) return { ok: false, error: `unknown tool ${name}` };
|
|
215
|
+
const errors = [];
|
|
216
|
+
for (const key of def.parameters.required ?? []) {
|
|
217
|
+
if (args[key] === undefined) errors.push(`missing required parameter ${key}`);
|
|
218
|
+
}
|
|
219
|
+
for (const [key, spec] of Object.entries(def.parameters.properties ?? {})) {
|
|
220
|
+
const value = args[key];
|
|
221
|
+
if (value === undefined) continue;
|
|
222
|
+
if (spec.type === 'array' && !Array.isArray(value)) errors.push(`${key} must be an array`);
|
|
223
|
+
if (spec.type === 'string' && typeof value !== 'string') errors.push(`${key} must be a string`);
|
|
224
|
+
if (spec.type === 'boolean' && typeof value !== 'boolean') errors.push(`${key} must be a boolean`);
|
|
225
|
+
if (spec.enum && !spec.enum.includes(value)) errors.push(`${key} must be one of ${spec.enum.join('|')}`);
|
|
226
|
+
}
|
|
227
|
+
return errors.length ? { ok: false, error: errors.join('; ') } : { ok: true };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export default { TOOL_DEFS, WORKER_TOOLS, LEAD_TOOLS, COMMANDS, createHandlers, writeOutbox };
|
package/lib/plugin.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/sdlc-tools/src/plugin.js — DSH registration for tools and commands.
|
|
3
|
+
*
|
|
4
|
+
* Model tools are propose-only; authority-granting actions are user-only
|
|
5
|
+
* command handlers (C.4, authoring constraint 4). Registration is defensive so
|
|
6
|
+
* the package imports cleanly without a DSH host.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { COMMANDS, TOOL_DEFS, WORKER_TOOLS, LEAD_TOOLS, ALL_TOOLS, DEBUG_COMMANDS, createHandlers } from './index.js';
|
|
11
|
+
import { contributeSelftest, setSelftestMeta } from './selftest.js';
|
|
12
|
+
|
|
13
|
+
function pkgVersion() {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Register every tool definition on a DSH context. */
|
|
22
|
+
export function registerTools(ctx, config = {}) {
|
|
23
|
+
const registered = [];
|
|
24
|
+
const worktreePath = config.worktreePath ?? process.cwd();
|
|
25
|
+
const handlers = createHandlers({ worktreePath, runMeta: config.runMeta ?? {} });
|
|
26
|
+
|
|
27
|
+
for (const [name, def] of Object.entries(TOOL_DEFS)) {
|
|
28
|
+
const handler = handlers[name];
|
|
29
|
+
if (!handler) continue;
|
|
30
|
+
registered.push(name);
|
|
31
|
+
try {
|
|
32
|
+
if (typeof ctx?.tools?.register === 'function') {
|
|
33
|
+
ctx.tools.register({
|
|
34
|
+
...def,
|
|
35
|
+
handler: (args) => handler(args),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (ctx?.logger?.warn) ctx.logger.warn('sdlc-tools: failed to register %s: %s', name, err?.message ?? err);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return registered;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Register the `/sdlc` command surface (interactive profiles only — E3). */
|
|
46
|
+
export function registerCommands(ctx, config = {}) {
|
|
47
|
+
const registered = [];
|
|
48
|
+
for (const name of COMMANDS) {
|
|
49
|
+
registered.push(name);
|
|
50
|
+
try {
|
|
51
|
+
if (typeof ctx?.commands?.register === 'function') {
|
|
52
|
+
ctx.commands.register({
|
|
53
|
+
definitionId: `sdlc.${name}`,
|
|
54
|
+
name: `sdlc ${name}`,
|
|
55
|
+
description: `SDLC orchestrator: ${name}`,
|
|
56
|
+
input: { type: 'string' },
|
|
57
|
+
handler: (input) => {
|
|
58
|
+
if (ctx?.logger?.debug) ctx.logger.debug('sdlc %s %s', name, input ?? '');
|
|
59
|
+
return { ok: true, command: name, input: input ?? null, deferred: true };
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (ctx?.logger?.warn) ctx.logger.warn('sdlc-tools: failed to register command %s: %s', name, err?.message ?? err);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return registered;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Loader entry. MUST return nothing: the Cordis runner interprets a plugin's
|
|
72
|
+
* return value as an effect (`TypeError: Invalid effect` for a
|
|
73
|
+
* non-constructible apply; silently dropped for this regular-function apply).
|
|
74
|
+
* Tests that want the registration report call `registerTools` /
|
|
75
|
+
* `registerCommands` directly.
|
|
76
|
+
* @returns {undefined}
|
|
77
|
+
*/
|
|
78
|
+
export function apply(ctx, config = {}) {
|
|
79
|
+
setSelftestMeta({ version: pkgVersion() });
|
|
80
|
+
const tools = registerTools(ctx, config);
|
|
81
|
+
const commands = registerCommands(ctx, config);
|
|
82
|
+
contributeSelftest({ tools, commands });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { COMMANDS, TOOL_DEFS, WORKER_TOOLS, LEAD_TOOLS, ALL_TOOLS, DEBUG_COMMANDS };
|
package/lib/selftest.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* selftest.js — deterministic boot hook (spec v3 C.9).
|
|
3
|
+
*
|
|
4
|
+
* There is NO CLI for listing registered tools/commands (verification Q8), and
|
|
5
|
+
* asking a model is neither deterministic nor free. This module implements the
|
|
6
|
+
* `SDLC_SELFTEST` hook: when the env var is an ABSOLUTE path, the plugin suite
|
|
7
|
+
* writes a registration report there.
|
|
8
|
+
*
|
|
9
|
+
* Contract and limits:
|
|
10
|
+
* - inert unless `SDLC_SELFTEST` is an absolute path; a non-absolute or
|
|
11
|
+
* unwritable path is a start-up error, never a silent pass;
|
|
12
|
+
* - the hook reports REGISTRATION ONLY — behaviour is covered by tests;
|
|
13
|
+
* - the report is flushed only after the composition goes quiet (debounced),
|
|
14
|
+
* so it is independent of bundle load order.
|
|
15
|
+
*
|
|
16
|
+
* NOTE: this file is intentionally duplicated verbatim in sdlc-core, sdlc-tools
|
|
17
|
+
* and sdlc-presets. Those packages must remain independently installable (E6):
|
|
18
|
+
* the worker profile has sdlc-tools WITHOUT sdlc-core. The shared state lives on
|
|
19
|
+
* globalThis, so every copy contributes to one report.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { writeFileSync } from 'node:fs';
|
|
23
|
+
import { isAbsolute } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const STATE_KEY = '__SDLC_SELFTEST_STATE__';
|
|
26
|
+
const FLUSH_DELAY_MS = 50;
|
|
27
|
+
|
|
28
|
+
function state() {
|
|
29
|
+
const g = globalThis;
|
|
30
|
+
if (!g[STATE_KEY]) {
|
|
31
|
+
g[STATE_KEY] = {
|
|
32
|
+
version: null,
|
|
33
|
+
dsh: null,
|
|
34
|
+
tools: new Set(),
|
|
35
|
+
commands: new Set(),
|
|
36
|
+
services: new Set(),
|
|
37
|
+
presets: new Set(),
|
|
38
|
+
guard: {},
|
|
39
|
+
timer: null,
|
|
40
|
+
written: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return g[STATE_KEY];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Set the runtime/DSH versions exactly once. */
|
|
47
|
+
export function setSelftestMeta({ version = null, dsh = null } = {}) {
|
|
48
|
+
const s = state();
|
|
49
|
+
if (version) s.version = version;
|
|
50
|
+
if (dsh) s.dsh = dsh;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Contribute registration names. Safe to call from any package's apply(). */
|
|
54
|
+
export function contributeSelftest({ tools = [], commands = [], services = [], presets = [], guard = null } = {}) {
|
|
55
|
+
const s = state();
|
|
56
|
+
for (const t of tools) s.tools.add(t);
|
|
57
|
+
for (const c of commands) s.commands.add(c);
|
|
58
|
+
for (const sv of services) s.services.add(sv);
|
|
59
|
+
for (const p of presets) s.presets.add(p);
|
|
60
|
+
if (guard) {
|
|
61
|
+
// MERGE, do not clobber: a later contributor that does not know the policy
|
|
62
|
+
// (sdlc-core contributes a placeholder) must not overwrite a richer value
|
|
63
|
+
// already reported by the owner (sdlc-guard). An empty array never wins
|
|
64
|
+
// over a populated one.
|
|
65
|
+
const merged = { ...s.guard };
|
|
66
|
+
for (const [k, v] of Object.entries(guard)) {
|
|
67
|
+
const isEmpty = v == null || (Array.isArray(v) && v.length === 0);
|
|
68
|
+
if (isEmpty && Array.isArray(merged[k]) && merged[k].length > 0) continue;
|
|
69
|
+
if (isEmpty && merged[k] !== undefined) continue;
|
|
70
|
+
merged[k] = v;
|
|
71
|
+
}
|
|
72
|
+
s.guard = merged;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!process.env.SDLC_SELFTEST) return null;
|
|
76
|
+
|
|
77
|
+
if (!isAbsolute(process.env.SDLC_SELFTEST)) {
|
|
78
|
+
throw new Error(`SDLC_SELFTEST must be an absolute path (got "${process.env.SDLC_SELFTEST}")`);
|
|
79
|
+
}
|
|
80
|
+
clearTimeout(s.timer);
|
|
81
|
+
s.timer = setTimeout(flushSelftest, FLUSH_DELAY_MS);
|
|
82
|
+
if (typeof s.timer?.unref === 'function') s.timer.unref();
|
|
83
|
+
return s.timer;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function buildSelftestReport() {
|
|
87
|
+
const s = state();
|
|
88
|
+
const sort = (set) => [...set].sort();
|
|
89
|
+
return {
|
|
90
|
+
version: s.version,
|
|
91
|
+
dsh: s.dsh,
|
|
92
|
+
tools: sort(s.tools),
|
|
93
|
+
commands: sort(s.commands),
|
|
94
|
+
services: sort(s.services),
|
|
95
|
+
presets: sort(s.presets),
|
|
96
|
+
guard: {
|
|
97
|
+
orchestratorPaths: s.guard.orchestratorPaths ?? ['.sdlc/**', '.dsh/**'],
|
|
98
|
+
patterns: s.guard.forbiddenPatterns ?? s.guard.patterns ?? [],
|
|
99
|
+
forbiddenPatterns: s.guard.forbiddenPatterns ?? s.guard.patterns ?? [],
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Write the report. Exported so the M0 harness can force a flush in tests. */
|
|
105
|
+
export function flushSelftest() {
|
|
106
|
+
const path = process.env.SDLC_SELFTEST;
|
|
107
|
+
if (!path) return null;
|
|
108
|
+
if (!isAbsolute(path)) throw new Error(`SDLC_SELFTEST must be an absolute path (got "${path}")`);
|
|
109
|
+
const report = buildSelftestReport();
|
|
110
|
+
writeFileSync(path, JSON.stringify(report, null, 2) + '\n');
|
|
111
|
+
state().written = true;
|
|
112
|
+
return report;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function selftestWritten() {
|
|
116
|
+
return state().written;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export default { contributeSelftest, setSelftestMeta, flushSelftest, buildSelftestReport };
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patwoodman/dsh-sdlc-flow-tools",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0-beta.14",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Model-facing propose-only tools (worker + lead) and the /sdlc command surface.",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./plugin": "./lib/plugin.js",
|
|
10
|
+
"./package.json": "./package.json",
|
|
11
|
+
"./locale/*.json": "./locale/*.json",
|
|
12
|
+
"./icon": "./icon.svg",
|
|
13
|
+
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"lib",
|
|
17
|
+
"cordis.patch.yml",
|
|
18
|
+
"locale/*.json",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"README.md",
|
|
21
|
+
"icon.svg"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "node ../../scripts/build.mjs"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@deepseek-ai/dsh": ">=0.2.0-rc.2 <0.3.0-0"
|
|
28
|
+
},
|
|
29
|
+
"dsh": {
|
|
30
|
+
"bundle": {
|
|
31
|
+
"patch": "./cordis.patch.yml"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"directory": "packages/sdlc-tools",
|
|
38
|
+
"url": "git+https://github.com/pwoodman/dsh-sdlc.git"
|
|
39
|
+
},
|
|
40
|
+
"icon": "icon.svg",
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=22.5.0"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/pwoodman/dsh-sdlc",
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/pwoodman/dsh-sdlc/issues"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@patwoodman/dsh-sdlc-flow-core": "0.4.0-beta.14"
|
|
53
|
+
}
|
|
54
|
+
}
|