@smartmemory/compose 0.2.57-beta → 0.3.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.
- package/README.md +2 -2
- package/bin/compose.js +55 -54
- package/contracts/feature-json.schema.json +16 -0
- package/dist/assets/{App-BmhlHOXF.js → App-DJ5xk_Wx.js} +219 -219
- package/dist/assets/{abnfDiagram-VRR7QNED-CMOTYAnt.js → abnfDiagram-VRR7QNED-BPWGdCFx.js} +1 -1
- package/dist/assets/{arc-BJAv_6dL.js → arc-DX5jqmcO.js} +1 -1
- package/dist/assets/{architectureDiagram-ZJ3FMSHR-xa4DLUcp.js → architectureDiagram-ZJ3FMSHR-B6jmNVw7.js} +1 -1
- package/dist/assets/{blockDiagram-677ZJIJ3-BdkDqeQF.js → blockDiagram-677ZJIJ3-DAx2i-sB.js} +1 -1
- package/dist/assets/{c4Diagram-LMCZKHZV-DiNrLIuL.js → c4Diagram-LMCZKHZV-zmsZCplj.js} +1 -1
- package/dist/assets/channel-vsDnTvkh.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-BsjtgrSL.js → chunk-2Q5K7J3B-3QSh6sI7.js} +1 -1
- package/dist/assets/{chunk-32BRIVSS-DpImULWy.js → chunk-32BRIVSS-BiH5Di_y.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-DTdG5JnR.js → chunk-5VM5RSS4-C0EfjFLd.js} +1 -1
- package/dist/assets/{chunk-EX3LRPZG-Cj1jNuKY.js → chunk-EX3LRPZG-BGyfmVm-.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-DwQ0wwWn.js → chunk-JWPE2WC7-Ba0mrNlg.js} +1 -1
- package/dist/assets/{chunk-MOJQB5TN-DZZa7unc.js → chunk-MOJQB5TN-D3QO9EzH.js} +1 -1
- package/dist/assets/{chunk-RYQCIY6F-CgdjqYQj.js → chunk-RYQCIY6F-D1uvNA6d.js} +1 -1
- package/dist/assets/{chunk-V7JOEXUC-DrPIj6XI.js → chunk-V7JOEXUC-fihlnodT.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-BBUOVC5W.js → chunk-VR4S4FIN-nmRUEo1H.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-BEIiZ6gS.js → chunk-XXDRQBXY-CM693yEg.js} +1 -1
- package/dist/assets/classDiagram-OUVF2IWQ-DO-wdSFZ.js +1 -0
- package/dist/assets/classDiagram-v2-EOCWNBFH-DO-wdSFZ.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-ClFTQEyD.js → cose-bilkent-JH36ORCC-CkacPn8W.js} +1 -1
- package/dist/assets/{cynefin-VYW2F7L2-5Bp7xFO0.js → cynefin-VYW2F7L2-BiHaIptG.js} +1 -1
- package/dist/assets/{cynefinDiagram-TSTJHNR4-C-0QlSda.js → cynefinDiagram-TSTJHNR4-CdKxMmiy.js} +1 -1
- package/dist/assets/{dagre-VKFMJZFB-DLkqsfBy.js → dagre-VKFMJZFB-BQLY5y_W.js} +1 -1
- package/dist/assets/{diagram-FQU43EPY-Cwh_tJ_r.js → diagram-FQU43EPY-D7uMBHvq.js} +1 -1
- package/dist/assets/{diagram-G47NLZAW-CvvAjulf.js → diagram-G47NLZAW-B3Z1cuH7.js} +1 -1
- package/dist/assets/{diagram-NH7WQ7WH-CCoyx8_e.js → diagram-NH7WQ7WH-BZyRD45e.js} +1 -1
- package/dist/assets/{diagram-OA4YK3LP-DfjhKYCp.js → diagram-OA4YK3LP-DcThOTt7.js} +1 -1
- package/dist/assets/{diagram-WEI45ONY-Vjz0ygfB.js → diagram-WEI45ONY-BcgRAkqY.js} +1 -1
- package/dist/assets/{ebnfDiagram-CCIWWBDH-GsA8c6g2.js → ebnfDiagram-CCIWWBDH-CGwfO_xH.js} +1 -1
- package/dist/assets/{erDiagram-Q63AITRT-Cc0lJ9sQ.js → erDiagram-Q63AITRT-pV58-Ncc.js} +1 -1
- package/dist/assets/{flowDiagram-23GEKE2U-C_sotK-k.js → flowDiagram-23GEKE2U-DJ_SqE8h.js} +1 -1
- package/dist/assets/{ganttDiagram-NO4QXBWP-BBtu_WUe.js → ganttDiagram-NO4QXBWP-Dgy0Iyss.js} +1 -1
- package/dist/assets/{gitGraphDiagram-IHSO6WYX-BV_aSB8-.js → gitGraphDiagram-IHSO6WYX-CbiZw9fb.js} +1 -1
- package/dist/assets/{index-CKfOVv2N.js → index-DZTJEk-y.js} +2 -2
- package/dist/assets/{infoDiagram-FWYZ7A6U-DMAvNGtX.js → infoDiagram-FWYZ7A6U-CtsyyEc-.js} +1 -1
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-DfQB91MZ.js → ishikawaDiagram-FXEZZL3T-BJilNFkK.js} +1 -1
- package/dist/assets/{journeyDiagram-5HDEW3XC-Bq4Llm9o.js → journeyDiagram-5HDEW3XC-C2UCMP4t.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-DTXHiDiE.js → kanban-definition-HUTT4EX6-DmLDJBRy.js} +1 -1
- package/dist/assets/{linear-BQSYZcVg.js → linear-C1paCqE7.js} +1 -1
- package/dist/assets/{mindmap-definition-LN4V7U3C-KuH2NIj0.js → mindmap-definition-LN4V7U3C-CX-RxKVn.js} +1 -1
- package/dist/assets/{pegDiagram-2B236MQR-BKoNGQFr.js → pegDiagram-2B236MQR-CTU32H2W.js} +1 -1
- package/dist/assets/{pieDiagram-ENE6RG2P-EpYv162Y.js → pieDiagram-ENE6RG2P-D8L1aYoo.js} +1 -1
- package/dist/assets/{quadrantDiagram-ABIIQ3AL-C9deUjQ2.js → quadrantDiagram-ABIIQ3AL-hQ3bXosy.js} +1 -1
- package/dist/assets/{railroadDiagram-RFXS5EU6-Csqw2hK9.js → railroadDiagram-RFXS5EU6--_vuYcda.js} +1 -1
- package/dist/assets/{requirementDiagram-TGXJPOKE-DCuz3emr.js → requirementDiagram-TGXJPOKE-C6h_m3Az.js} +1 -1
- package/dist/assets/{sankeyDiagram-HTMAVEWB-BTeTL24f.js → sankeyDiagram-HTMAVEWB-Dpc7CfIQ.js} +1 -1
- package/dist/assets/{sequenceDiagram-DBY2YBRQ-Cf1ZkTwO.js → sequenceDiagram-DBY2YBRQ-CK5ZzbvJ.js} +1 -1
- package/dist/assets/{sizeCapture-X5ZJPWSS-D3Lbewzg.js → sizeCapture-X5ZJPWSS-BAxVvM9G.js} +1 -1
- package/dist/assets/{stateDiagram-2N3HPSRC-O8l37yJI.js → stateDiagram-2N3HPSRC-7j33_2PY.js} +1 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-GviN0FpJ.js +1 -0
- package/dist/assets/{swimlanes-5IMT3BWC-BikmTh8b.js → swimlanes-5IMT3BWC-CBG2yOob.js} +2 -2
- package/dist/assets/swimlanesDiagram-G3AALYLV-D0wMoxl1.js +8 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-BOxSosG0.js → timeline-definition-FHXFAJF6-BOnZkQvc.js} +1 -1
- package/dist/assets/{vennDiagram-L72KCM5P-BSClOErZ.js → vennDiagram-L72KCM5P-D52NhIfj.js} +1 -1
- package/dist/assets/{wardleyDiagram-EHGQE667-DHFRjOK0.js → wardleyDiagram-EHGQE667-YoPY2ZU-.js} +1 -1
- package/dist/assets/{xychartDiagram-FW5EYKEG-Dh1v4x-O.js → xychartDiagram-FW5EYKEG-BanNfgtO.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/build-all.js +0 -5
- package/lib/build-stream-schema.js +1 -1
- package/lib/build.js +1809 -2646
- package/lib/consumer-fanout.js +1317 -0
- package/lib/escalation.js +69 -0
- package/lib/feature-validator.js +6 -4
- package/lib/feature-writer.js +58 -3
- package/lib/flow-state.js +15 -14
- package/lib/gsd-budget.js +48 -10
- package/lib/gsd-prompt.js +3 -4
- package/lib/gsd-stuck.js +1 -1
- package/lib/gsd.js +303 -149
- package/lib/lane-gate.js +200 -0
- package/lib/local-claude-connector.js +149 -0
- package/lib/new.js +162 -307
- package/lib/result-normalizer.js +233 -18
- package/lib/review-lenses.js +1 -1
- package/lib/step-prompt.js +41 -119
- package/lib/stratum-engine.js +297 -0
- package/lib/stratum-mcp-client.js +224 -213
- package/lib/triage.js +144 -0
- package/lib/vocabulary-compliance.js +268 -0
- package/lib/vocabulary-inject.js +1 -36
- package/package.json +2 -1
- package/pipelines/build-quick.stratum.yaml +5 -17
- package/pipelines/build.profiles.json +11 -0
- package/pipelines/build.stratum.yaml +288 -467
- package/pipelines/gsd.stratum.yaml +73 -125
- package/pipelines/new.stratum.yaml +68 -149
- package/server/build-routes.js +4 -1
- package/server/design-routes.js +37 -21
- package/server/index.js +13 -21
- package/server/lifecycle-guard.js +1 -1
- package/server/pipeline-routes.js +113 -31
- package/server/stratum-client.js +33 -47
- package/server/stratum-sync.js +3 -4
- package/server/vision-server.js +1 -1
- package/dist/assets/channel-CYErfopw.js +0 -1
- package/dist/assets/classDiagram-OUVF2IWQ-qCC_hnXu.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-qCC_hnXu.js +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-BWqk_9py.js +0 -1
- package/dist/assets/swimlanesDiagram-G3AALYLV-C60ICdks.js +0 -8
- package/lib/connector-factory-shim.js +0 -167
- package/server/agent-mcp.js +0 -10
package/lib/lane-gate.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// lib/lane-gate.js
|
|
2
|
+
//
|
|
3
|
+
// COMP-TRIAGE-5 — E3 (Estimate → Execute → Expand) front-of-pipeline scope
|
|
4
|
+
// estimation + verification-gated escalation. Two entry points, extracted from
|
|
5
|
+
// build.js runBuild/executeShipStep so they are unit-testable in isolation:
|
|
6
|
+
//
|
|
7
|
+
// applyFrontTriage() — E3 "Estimate": derive the lane from the RAW REQUEST
|
|
8
|
+
// before any design/plan/blueprint doc is read, and
|
|
9
|
+
// persist validated feature fields (closing the
|
|
10
|
+
// complexity: String(tier) bypass at build.js:997/1006).
|
|
11
|
+
//
|
|
12
|
+
// maybeEscalateLane() — E3 "Expand": on a failed ship-time test gate, escalate
|
|
13
|
+
// the lane so the NEXT build runs wider — persist the
|
|
14
|
+
// escalated lane + a widened profile + a bounded counter
|
|
15
|
+
// and drop a resume checkpoint. STOP → human handoff.
|
|
16
|
+
// Re-entry is via re-invocation reading the escalated
|
|
17
|
+
// lane, NOT inline runBuild surgery.
|
|
18
|
+
|
|
19
|
+
import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
20
|
+
import { join, resolve } from 'node:path';
|
|
21
|
+
import {
|
|
22
|
+
estimateScope,
|
|
23
|
+
tierToComplexity,
|
|
24
|
+
floorProfileToLane,
|
|
25
|
+
runTriage,
|
|
26
|
+
tierToLane,
|
|
27
|
+
narrowerLane,
|
|
28
|
+
} from './triage.js';
|
|
29
|
+
import { validateFeatureFields } from './feature-writer.js';
|
|
30
|
+
import { escalate } from './escalation.js';
|
|
31
|
+
|
|
32
|
+
// Docs that make a doc-reading refinement meaningful. Absent all three, runTriage
|
|
33
|
+
// would see zero files and return tier 0 ('trivial') — which is "no signal", not
|
|
34
|
+
// "confirmed simple", and must NOT be allowed to narrow away the front safety clamp.
|
|
35
|
+
const REFINEMENT_DOCS = ['design.md', 'plan.md', 'blueprint.md'];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* E3 Estimate. Runs before spec load. Derives {tier, profile, lane} from the raw
|
|
39
|
+
* request (doc-free) and persists validated fields through the shared validator
|
|
40
|
+
* (so the tier can no longer be stringified into `complexity`). Returns the
|
|
41
|
+
* values runBuild needs to toggle skip_if.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} a
|
|
44
|
+
* @param {string} a.featureCode
|
|
45
|
+
* @param {string} [a.request] raw task text (opts.description); falls back to the code
|
|
46
|
+
* @param {object} a.provider build provider (getFeature/createFeature/putFeature)
|
|
47
|
+
* @param {object|null} a.cachedFeature already-fetched feature.json, or null
|
|
48
|
+
* @returns {Promise<{buildProfile:object, tier:number, lane:string, tierLabel:string, rationale:string, cachedFeature:object}>}
|
|
49
|
+
*/
|
|
50
|
+
export async function applyFrontTriage({ featureCode, request, provider, cachedFeature, cwd, featuresDir }) {
|
|
51
|
+
// The CLI build path does not thread a description into opts, so fall back to
|
|
52
|
+
// the feature's own persisted description before the (signal-free) code string —
|
|
53
|
+
// otherwise every CLI build would classify the code and land low-confidence.
|
|
54
|
+
const req = request ?? cachedFeature?.description ?? featureCode;
|
|
55
|
+
const front = estimateScope(req);
|
|
56
|
+
let tier = front.tier;
|
|
57
|
+
let lane = front.lane;
|
|
58
|
+
let buildProfile = front.profile;
|
|
59
|
+
let estimateSource = 'front';
|
|
60
|
+
|
|
61
|
+
// E3 refinement (narrow-only): once design/plan/blueprint docs exist, a
|
|
62
|
+
// doc-reading pass may CONFIRM a smaller scope than the raw request implied —
|
|
63
|
+
// but it may only narrow the front lane, never widen it (widening is the
|
|
64
|
+
// escalation path's job). Gated on doc existence so a doc-less tier-0 read
|
|
65
|
+
// cannot undo the front safety clamp.
|
|
66
|
+
if (cwd && featuresDir) {
|
|
67
|
+
const dir = resolve(cwd, featuresDir, featureCode);
|
|
68
|
+
const hasDocs = REFINEMENT_DOCS.some((f) => existsSync(join(dir, f)));
|
|
69
|
+
if (hasDocs) {
|
|
70
|
+
try {
|
|
71
|
+
const refined = await runTriage(featureCode, { cwd, featuresDir });
|
|
72
|
+
const narrowed = narrowerLane(lane, tierToLane(refined.tier));
|
|
73
|
+
if (narrowed !== lane) {
|
|
74
|
+
lane = narrowed;
|
|
75
|
+
tier = refined.tier;
|
|
76
|
+
buildProfile = floorProfileToLane(refined.profile, narrowed);
|
|
77
|
+
estimateSource = 'refined';
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
/* triage read failed — keep the front estimate */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const fields = {
|
|
86
|
+
complexity: tierToComplexity(tier),
|
|
87
|
+
triageTier: tier,
|
|
88
|
+
lane,
|
|
89
|
+
estimateSource,
|
|
90
|
+
profile: buildProfile,
|
|
91
|
+
triageTimestamp: new Date().toISOString(),
|
|
92
|
+
};
|
|
93
|
+
// Throws on any invalid field — this is the guard the raw provider write bypassed.
|
|
94
|
+
validateFeatureFields(fields);
|
|
95
|
+
|
|
96
|
+
let updated;
|
|
97
|
+
if (!cachedFeature) {
|
|
98
|
+
updated = await provider.createFeature(featureCode, {
|
|
99
|
+
code: featureCode,
|
|
100
|
+
description: req,
|
|
101
|
+
status: 'PLANNED',
|
|
102
|
+
...fields,
|
|
103
|
+
});
|
|
104
|
+
} else {
|
|
105
|
+
updated = await provider.putFeature(featureCode, { ...cachedFeature, ...fields });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
buildProfile,
|
|
110
|
+
tier,
|
|
111
|
+
lane,
|
|
112
|
+
tierLabel: fields.complexity,
|
|
113
|
+
rationale: front.rationale,
|
|
114
|
+
cachedFeature: updated,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* E3 Expand. On a failed ship-time test gate, escalate the lane so the next build
|
|
120
|
+
* runs wider. Bounded via escalationCount; on STOP or exhausted ladder, writes a
|
|
121
|
+
* checkpoint and hands off to a human. Best-effort — the caller never blocks ship
|
|
122
|
+
* on this. Only acts on features that went through front triage (have a `lane`).
|
|
123
|
+
*
|
|
124
|
+
* @param {object} a
|
|
125
|
+
* @param {string} a.featureCode
|
|
126
|
+
* @param {object} a.provider build provider
|
|
127
|
+
* @param {string} [a.featureDir] dir to write the escalation checkpoint into
|
|
128
|
+
* @param {string|null} [a.currentPhase] optional live phase (unused for reEntry today; see escalation.js)
|
|
129
|
+
* @returns {Promise<{action:'none'|'escalate'|'stop', [from]:string, [to]:string, [reEntryPhase]:string, [escalationCount]:number}>}
|
|
130
|
+
*/
|
|
131
|
+
export async function maybeEscalateLane({ featureCode, provider, featureDir, currentPhase = null }) {
|
|
132
|
+
const feature = await provider.getFeature(featureCode);
|
|
133
|
+
// Only features that went through the front seam carry a lane — never escalate
|
|
134
|
+
// a feature that never opted into lane-based execution.
|
|
135
|
+
if (!feature?.lane) return { action: 'none' };
|
|
136
|
+
|
|
137
|
+
const lane = feature.lane;
|
|
138
|
+
const escalationCount = feature.escalationCount ?? 0;
|
|
139
|
+
const decision = escalate({ gate: 'test', passed: false }, lane, escalationCount, currentPhase);
|
|
140
|
+
|
|
141
|
+
if (decision === 'STOP') {
|
|
142
|
+
writeCheckpoint(featureDir, { featureCode, lane, decision: 'STOP', reEntryPhase: null, escalationCount });
|
|
143
|
+
return { action: 'stop', lane, escalationCount };
|
|
144
|
+
}
|
|
145
|
+
if (!decision) return { action: 'none' };
|
|
146
|
+
|
|
147
|
+
// Widen the profile to match the ESCALATED lane (not always full) so lane and
|
|
148
|
+
// profile stay consistent and each rung actually adds phases.
|
|
149
|
+
const widenedProfile = floorProfileToLane(feature.profile ?? {}, decision.nextLane);
|
|
150
|
+
validateFeatureFields({ lane: decision.nextLane, estimateSource: 'escalated' });
|
|
151
|
+
// Write the checkpoint BEFORE stamping triageTimestamp so the checkpoint file's
|
|
152
|
+
// mtime is older than the stamp — otherwise isTriageStale would treat the just-
|
|
153
|
+
// escalated feature as stale on the next build and overwrite the escalation.
|
|
154
|
+
writeCheckpoint(featureDir, {
|
|
155
|
+
featureCode,
|
|
156
|
+
lane: decision.nextLane,
|
|
157
|
+
decision: 'escalate',
|
|
158
|
+
reEntryPhase: decision.reEntryPhase,
|
|
159
|
+
escalationCount: escalationCount + 1,
|
|
160
|
+
});
|
|
161
|
+
await provider.putFeature(featureCode, {
|
|
162
|
+
...feature,
|
|
163
|
+
lane: decision.nextLane,
|
|
164
|
+
estimateSource: 'escalated',
|
|
165
|
+
escalationCount: escalationCount + 1,
|
|
166
|
+
profile: widenedProfile,
|
|
167
|
+
triageTimestamp: new Date().toISOString(),
|
|
168
|
+
});
|
|
169
|
+
return {
|
|
170
|
+
action: 'escalate',
|
|
171
|
+
from: lane,
|
|
172
|
+
to: decision.nextLane,
|
|
173
|
+
reEntryPhase: decision.reEntryPhase,
|
|
174
|
+
escalationCount: escalationCount + 1,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function writeCheckpoint(featureDir, { featureCode, lane, decision, reEntryPhase, escalationCount }) {
|
|
179
|
+
if (!featureDir) return;
|
|
180
|
+
try {
|
|
181
|
+
mkdirSync(featureDir, { recursive: true });
|
|
182
|
+
const lines = [
|
|
183
|
+
`# COMP-TRIAGE-5 Escalation Checkpoint — ${featureCode}`,
|
|
184
|
+
'',
|
|
185
|
+
`- Decision: ${decision}`,
|
|
186
|
+
`- Lane: ${lane}`,
|
|
187
|
+
reEntryPhase ? `- Re-enter at phase: ${reEntryPhase}` : '- Re-enter: n/a (STOP — human handoff)',
|
|
188
|
+
`- Escalation count: ${escalationCount}`,
|
|
189
|
+
'- Trigger: ship-time test gate failed (E3 Expand)',
|
|
190
|
+
'',
|
|
191
|
+
decision === 'STOP'
|
|
192
|
+
? 'Escalation bound reached. A human should investigate before re-running.'
|
|
193
|
+
: `Re-run \`compose build ${featureCode}\` — it reads the escalated lane and runs the heavier phases.`,
|
|
194
|
+
'',
|
|
195
|
+
];
|
|
196
|
+
writeFileSync(join(featureDir, 'escalation-checkpoint.md'), lines.join('\n'), 'utf-8');
|
|
197
|
+
} catch {
|
|
198
|
+
/* best-effort — a checkpoint failure must never break the build */
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* local-claude-connector.js — V2/V3 (STRAT-TS-FANOUT-CONSUMER).
|
|
3
|
+
*
|
|
4
|
+
* The TS `stratum_agent_run` surface is a SYNCHRONOUS black box: it returns no
|
|
5
|
+
* runId (no pre-completion cancel handle), streams no progress notifications,
|
|
6
|
+
* and its `sandboxMode` binds only the codex connector. The engine's background
|
|
7
|
+
* agent mode (surface 8) is codex-only AND read-only-only. So there is NO engine
|
|
8
|
+
* seam that can, for a CLAUDE agent:
|
|
9
|
+
* - enforce tool restrictions (V3 — a read-only review fanout must not Edit/
|
|
10
|
+
* Write/Bash in the target workspace), or
|
|
11
|
+
* - be interrupted mid-run (V2 — per-item timeout / stuck / user interrupt).
|
|
12
|
+
*
|
|
13
|
+
* Compose owns consumer/review execution by design, and already depends on
|
|
14
|
+
* `@anthropic-ai/claude-agent-sdk`, so CONTROLLED claude executions run here.
|
|
15
|
+
* The connector enforces allowedTools/disallowedTools, aborts via an
|
|
16
|
+
* AbortController, streams tool_use events (for the stuck detector + narration),
|
|
17
|
+
* and reports usage. The SDK `query` is injectable for tests.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk';
|
|
21
|
+
|
|
22
|
+
const SENSITIVE_ENV_VARS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'CLAUDE_API_KEY', 'CLAUDECODE'];
|
|
23
|
+
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function nonneg(value) {
|
|
29
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run a controlled claude agent locally.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} prompt
|
|
36
|
+
* @param {object} [opts]
|
|
37
|
+
* @param {string} [opts.cwd]
|
|
38
|
+
* @param {string} [opts.model]
|
|
39
|
+
* @param {string[]} [opts.allowedTools] enforced tool allowlist (read-only review)
|
|
40
|
+
* @param {string[]} [opts.disallowedTools]
|
|
41
|
+
* @param {object} [opts.thinking]
|
|
42
|
+
* @param {AbortController} [opts.abortController] abort → interrupt the run
|
|
43
|
+
* @param {(ev:{tool:string,input:object})=>void} [opts.onToolUse] per tool_use block
|
|
44
|
+
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
45
|
+
* @param {Function} [opts.query] SDK `query` seam for tests
|
|
46
|
+
* @returns {Promise<{text:string, usage:object, telemetry:object}>}
|
|
47
|
+
*/
|
|
48
|
+
export async function runLocalClaudeAgent(prompt, opts = {}) {
|
|
49
|
+
const query = opts.query ?? sdkQuery;
|
|
50
|
+
const env = { ...(opts.env ?? process.env) };
|
|
51
|
+
for (const key of SENSITIVE_ENV_VARS) delete env[key];
|
|
52
|
+
|
|
53
|
+
const sdkOptions = {
|
|
54
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
55
|
+
model: opts.model ?? process.env.CLAUDE_MODEL ?? 'claude-sonnet-4-6',
|
|
56
|
+
permissionMode: 'acceptEdits',
|
|
57
|
+
env,
|
|
58
|
+
...(opts.abortController ? { abortController: opts.abortController } : {}),
|
|
59
|
+
...(opts.thinking !== undefined ? { thinking: opts.thinking } : {}),
|
|
60
|
+
};
|
|
61
|
+
// A read-only profile passes an explicit allowlist; without one, the agent
|
|
62
|
+
// gets the full claude_code preset (unrestricted).
|
|
63
|
+
if (opts.allowedTools !== undefined) {
|
|
64
|
+
// `allowedTools` only auto-allows-without-prompting; on its own the agent
|
|
65
|
+
// still HAS every tool (minus the denylist) under permissionMode
|
|
66
|
+
// 'acceptEdits'. To actually restrict AVAILABILITY (a read-only reviewer must
|
|
67
|
+
// not be able to Edit/Write/Bash), the SDK requires `tools` set to the
|
|
68
|
+
// specific tool names. Set both: `tools` binds availability, `allowedTools`
|
|
69
|
+
// suppresses the prompt for those same tools.
|
|
70
|
+
sdkOptions.tools = [...opts.allowedTools];
|
|
71
|
+
sdkOptions.allowedTools = opts.allowedTools;
|
|
72
|
+
if (opts.disallowedTools !== undefined) sdkOptions.disallowedTools = opts.disallowedTools;
|
|
73
|
+
} else {
|
|
74
|
+
sdkOptions.tools = { type: 'preset', preset: 'claude_code' };
|
|
75
|
+
if (opts.disallowedTools !== undefined) sdkOptions.disallowedTools = opts.disallowedTools;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const startedAt = Date.now();
|
|
79
|
+
let resolvedModel = sdkOptions.model;
|
|
80
|
+
let finalText;
|
|
81
|
+
let assistantText = '';
|
|
82
|
+
let durationMs = 0;
|
|
83
|
+
let inputTokens = 0;
|
|
84
|
+
let outputTokens = 0;
|
|
85
|
+
let costUsd = 0;
|
|
86
|
+
|
|
87
|
+
for await (const raw of query({ prompt, options: sdkOptions })) {
|
|
88
|
+
if (!isRecord(raw)) continue;
|
|
89
|
+
if (raw.type === 'system' && raw.subtype === 'init' && typeof raw.model === 'string') {
|
|
90
|
+
resolvedModel = raw.model;
|
|
91
|
+
}
|
|
92
|
+
if (raw.type === 'assistant' && isRecord(raw.message) && Array.isArray(raw.message.content)) {
|
|
93
|
+
for (const block of raw.message.content) {
|
|
94
|
+
if (!isRecord(block)) continue;
|
|
95
|
+
if (block.type === 'text' && typeof block.text === 'string') assistantText += block.text;
|
|
96
|
+
if (block.type === 'tool_use' && typeof block.name === 'string' && typeof opts.onToolUse === 'function') {
|
|
97
|
+
opts.onToolUse({ tool: block.name, input: isRecord(block.input) ? block.input : {} });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (raw.type !== 'result') continue;
|
|
102
|
+
durationMs = nonneg(raw.duration_ms);
|
|
103
|
+
if (raw.subtype !== 'success') {
|
|
104
|
+
// F3: a failed run still consumed billable tokens/cost. Capture them from
|
|
105
|
+
// the error result (SDKResultError carries usage + total_cost_usd) and
|
|
106
|
+
// attach to the thrown Error — same usage shape as the success return — so
|
|
107
|
+
// the consumer failure path can debit the engine/GSD ledgers. Without this,
|
|
108
|
+
// repeated failures evade budget exhaustion.
|
|
109
|
+
const failCost = nonneg(raw.total_cost_usd);
|
|
110
|
+
const failIn = isRecord(raw.usage) ? nonneg(raw.usage.input_tokens) : 0;
|
|
111
|
+
const failOut = isRecord(raw.usage) ? nonneg(raw.usage.output_tokens) : 0;
|
|
112
|
+
const errors = Array.isArray(raw.errors) ? raw.errors.filter((v) => typeof v === 'string') : [];
|
|
113
|
+
const err = new Error(errors.join('; ') || `claude query failed: ${String(raw.subtype)}`);
|
|
114
|
+
err.usage = {
|
|
115
|
+
input_tokens: failIn,
|
|
116
|
+
output_tokens: failOut,
|
|
117
|
+
tokens: failIn + failOut,
|
|
118
|
+
cost_usd: failCost,
|
|
119
|
+
usd: failCost,
|
|
120
|
+
duration_ms: durationMs,
|
|
121
|
+
ms: durationMs,
|
|
122
|
+
model: resolvedModel,
|
|
123
|
+
};
|
|
124
|
+
err.costUsd = failCost;
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
if (typeof raw.result === 'string') finalText = raw.result;
|
|
128
|
+
costUsd = nonneg(raw.total_cost_usd);
|
|
129
|
+
if (isRecord(raw.usage)) {
|
|
130
|
+
inputTokens = nonneg(raw.usage.input_tokens);
|
|
131
|
+
outputTokens = nonneg(raw.usage.output_tokens);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
text: finalText ?? assistantText,
|
|
137
|
+
usage: {
|
|
138
|
+
input_tokens: inputTokens,
|
|
139
|
+
output_tokens: outputTokens,
|
|
140
|
+
tokens: inputTokens + outputTokens,
|
|
141
|
+
cost_usd: costUsd,
|
|
142
|
+
usd: costUsd,
|
|
143
|
+
duration_ms: durationMs,
|
|
144
|
+
ms: durationMs,
|
|
145
|
+
model: resolvedModel,
|
|
146
|
+
},
|
|
147
|
+
telemetry: { durationMs, model: resolvedModel },
|
|
148
|
+
};
|
|
149
|
+
}
|