harnessed 1.0.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/LICENSE +201 -0
- package/NOTICE +20 -0
- package/README.md +178 -0
- package/config-templates/README.md +21 -0
- package/config-templates/hooks/.gitkeep +0 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.mjs +4653 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.mjs +10 -0
- package/dist/index.mjs.map +1 -0
- package/dist/schemas/index.d.ts +47 -0
- package/dist/schemas/index.mjs +384 -0
- package/dist/schemas/index.mjs.map +1 -0
- package/manifests/README.md +23 -0
- package/manifests/SCHEMA.md +180 -0
- package/manifests/aliases.yaml +14 -0
- package/manifests/cc-hooks/dashboard-autospawn.yaml +45 -0
- package/manifests/skill-packs/.gitkeep +0 -0
- package/manifests/skill-packs/anthropics-skills-pptx.yaml +46 -0
- package/manifests/skill-packs/anthropics-skills-slide-deck.yaml +46 -0
- package/manifests/skill-packs/frontend-design.yaml +63 -0
- package/manifests/skill-packs/gsd.yaml +43 -0
- package/manifests/skill-packs/gstack.yaml +40 -0
- package/manifests/skill-packs/karpathy-skills.yaml +64 -0
- package/manifests/skill-packs/mattpocock-skills.yaml +40 -0
- package/manifests/skill-packs/planning-with-files.yaml +45 -0
- package/manifests/skill-packs/ui-ux-pro-max.yaml +61 -0
- package/manifests/tools/.gitkeep +0 -0
- package/manifests/tools/chrome-devtools-mcp.yaml +44 -0
- package/manifests/tools/ctx7.yaml +39 -0
- package/manifests/tools/exa-mcp.yaml +39 -0
- package/manifests/tools/playwright-test.yaml +47 -0
- package/manifests/tools/ralph-loop.yaml +46 -0
- package/manifests/tools/superpowers.yaml +42 -0
- package/manifests/tools/tavily-mcp.yaml +39 -0
- package/package.json +96 -0
- package/routing/.gitkeep +0 -0
- package/routing/README.md +22 -0
- package/routing/SCHEMA.md +199 -0
- package/routing/decision_rules.yaml +387 -0
- package/routing/plan-review-schema.yaml +50 -0
- package/schemas/.gitkeep +0 -0
- package/schemas/README.md +33 -0
- package/schemas/manifest.v1.schema.json +1107 -0
- package/workflows/.gitkeep +0 -0
- package/workflows/README.md +23 -0
- package/workflows/SCHEMA.md +157 -0
- package/workflows/execute-task/SKILL.md +70 -0
- package/workflows/execute-task/phases.yaml +27 -0
- package/workflows/plan-feature/workflow.yaml +40 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,4653 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync, spawn } from 'child_process';
|
|
3
|
+
import { writeFileSync, existsSync, readFileSync, mkdirSync, appendFileSync, readdirSync } from 'fs';
|
|
4
|
+
import { resolve, join, dirname, relative } from 'path';
|
|
5
|
+
import { Type } from '@sinclair/typebox';
|
|
6
|
+
import { Value } from '@sinclair/typebox/value';
|
|
7
|
+
import { LineCounter, parseDocument, parse, isSeq, isScalar } from 'yaml';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
import { readFile, readdir, unlink, writeFile, stat, rm, access, mkdir, rename } from 'fs/promises';
|
|
10
|
+
import lockfile from 'proper-lockfile';
|
|
11
|
+
import { Command } from 'commander';
|
|
12
|
+
import { Ajv } from 'ajv';
|
|
13
|
+
import * as ajvFormatsNs from 'ajv-formats';
|
|
14
|
+
import { createHash } from 'crypto';
|
|
15
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
16
|
+
import * as p from '@clack/prompts';
|
|
17
|
+
import { createPatch } from 'diff';
|
|
18
|
+
import pc from 'picocolors';
|
|
19
|
+
import { stdout, stdin } from 'process';
|
|
20
|
+
import * as readline from 'readline/promises';
|
|
21
|
+
|
|
22
|
+
var __defProp = Object.defineProperty;
|
|
23
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
24
|
+
var __esm = (fn, res) => function __init() {
|
|
25
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
26
|
+
};
|
|
27
|
+
var __export = (target, all) => {
|
|
28
|
+
for (var name in all)
|
|
29
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// src/cli/lib/origin-check.ts
|
|
33
|
+
var origin_check_exports = {};
|
|
34
|
+
__export(origin_check_exports, {
|
|
35
|
+
checkOrigin: () => checkOrigin
|
|
36
|
+
});
|
|
37
|
+
function normalizeUrl(s) {
|
|
38
|
+
return s.trim().replace(/^(https?:\/\/|git@github\.com:|ssh:\/\/git@github\.com\/)/, "").replace(/\.git$/, "").replace(":", "/").replace(/\/$/, "").toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
function checkOrigin(cwd = process.cwd(), opts = {}) {
|
|
41
|
+
const allowFork = opts.allowFork ?? true;
|
|
42
|
+
let expected = null;
|
|
43
|
+
try {
|
|
44
|
+
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
|
|
45
|
+
expected = typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url ?? null;
|
|
46
|
+
} catch {
|
|
47
|
+
}
|
|
48
|
+
if (!expected) {
|
|
49
|
+
return {
|
|
50
|
+
status: "warn",
|
|
51
|
+
detail: "package.json has no repository.url field",
|
|
52
|
+
fix: "add `repository` field to package.json"
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const r = spawnSync("git", ["config", "--get", "remote.origin.url"], {
|
|
56
|
+
cwd,
|
|
57
|
+
encoding: "utf8"
|
|
58
|
+
});
|
|
59
|
+
if (r.status !== 0) {
|
|
60
|
+
return {
|
|
61
|
+
status: "warn",
|
|
62
|
+
detail: "no git remote origin (detached / non-clone)",
|
|
63
|
+
fix: "git remote add origin <expected-url>"
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const actual = r.stdout.trim();
|
|
67
|
+
if (normalizeUrl(actual) === normalizeUrl(expected)) {
|
|
68
|
+
return { status: "pass", detail: actual };
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
status: allowFork ? "warn" : "fail",
|
|
72
|
+
detail: `origin '${actual}' \u2260 expected '${expected}'`,
|
|
73
|
+
fix: allowFork ? "verify intentional fork; if not, `git remote set-url origin <expected>`" : "origin URL drift \u2014 possible tamper, `git remote set-url origin <expected>` to restore"
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
var init_origin_check = __esm({
|
|
77
|
+
"src/cli/lib/origin-check.ts"() {
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// src/cli/lib/probe-gstack.ts
|
|
82
|
+
var probe_gstack_exports = {};
|
|
83
|
+
__export(probe_gstack_exports, {
|
|
84
|
+
probeGstackPrefix: () => probeGstackPrefix
|
|
85
|
+
});
|
|
86
|
+
function probeOne(cmd) {
|
|
87
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
88
|
+
const r = spawnSync(finder, [cmd], { encoding: "utf8" });
|
|
89
|
+
return r.status === 0 && (r.stdout?.trim().length ?? 0) > 0;
|
|
90
|
+
}
|
|
91
|
+
function probeGstackPrefix() {
|
|
92
|
+
const hasGstack = probeOne("gstack-office-hours");
|
|
93
|
+
const hasBare = probeOne("office-hours");
|
|
94
|
+
if (hasGstack && !hasBare) {
|
|
95
|
+
return { status: "pass", prefix: "gstack-", detail: "gstack-office-hours found" };
|
|
96
|
+
}
|
|
97
|
+
if (!hasGstack && hasBare) {
|
|
98
|
+
return { status: "pass", prefix: "", detail: "office-hours found (--no-prefix mode)" };
|
|
99
|
+
}
|
|
100
|
+
if (hasGstack && hasBare) {
|
|
101
|
+
return {
|
|
102
|
+
status: "fail",
|
|
103
|
+
detail: "both gstack-office-hours AND office-hours found \u2014 ambiguous",
|
|
104
|
+
fix: `edit .harnessed/config.json manually: '{"gstack_prefix":"gstack-"}' OR '{"gstack_prefix":""}'`
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
status: "fail",
|
|
109
|
+
detail: "neither gstack-office-hours nor office-hours found in PATH",
|
|
110
|
+
fix: "install gstack: `npm i -g @gstack/cli` (or your preferred install method)"
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
var init_probe_gstack = __esm({
|
|
114
|
+
"src/cli/lib/probe-gstack.ts"() {
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// src/manifest/lib/path-guard.ts
|
|
119
|
+
function checkPathSafe(input) {
|
|
120
|
+
for (const re of PATH_TRAVERSAL_PATTERNS) {
|
|
121
|
+
if (re.test(input)) throw new PathTraversalError();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
var PATH_TRAVERSAL_PATTERNS, PathTraversalError;
|
|
125
|
+
var init_path_guard = __esm({
|
|
126
|
+
"src/manifest/lib/path-guard.ts"() {
|
|
127
|
+
PATH_TRAVERSAL_PATTERNS = [
|
|
128
|
+
/\.\.\//,
|
|
129
|
+
// (1) Unix dot-dot-slash: ../../etc/passwd
|
|
130
|
+
/\.\.\\/,
|
|
131
|
+
// (2) Windows backslash: ..\windows\system32
|
|
132
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional null-byte injection detection (R10.4 D-03 OWASP A1 vector 3)
|
|
133
|
+
/\x00/,
|
|
134
|
+
// (3) Null byte injection: path\x00attack
|
|
135
|
+
/%2[eE]%2[eE]/,
|
|
136
|
+
// (4) URL-encoded dot-dot: %2e%2e%2fetc
|
|
137
|
+
/%25[2][eE]%25[2][eE]/
|
|
138
|
+
// (5) Double-encoded: %252e%252e%252f
|
|
139
|
+
];
|
|
140
|
+
PathTraversalError = class _PathTraversalError extends Error {
|
|
141
|
+
constructor() {
|
|
142
|
+
super("path traversal attempt detected");
|
|
143
|
+
this.name = "PathTraversalError";
|
|
144
|
+
Object.setPrototypeOf(this, _PathTraversalError.prototype);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
function branchOnSchemaVersion(v, handlers) {
|
|
150
|
+
const isKnownV1 = Object.values(SCHEMA_VERSIONS).includes(v);
|
|
151
|
+
return isKnownV1 ? handlers.v1() : handlers.unknown();
|
|
152
|
+
}
|
|
153
|
+
var SCHEMA_VERSIONS;
|
|
154
|
+
var init_schemaVersion = __esm({
|
|
155
|
+
"src/types/schemaVersion.ts"() {
|
|
156
|
+
SCHEMA_VERSIONS = {
|
|
157
|
+
routingSnapshot: "harnessed.routing-snapshot.v1",
|
|
158
|
+
handoffDoc: "harnessed.handoff-doc.v1",
|
|
159
|
+
phasesYaml: "harnessed.phases-yaml.v1",
|
|
160
|
+
manifestState: "harnessed.manifest-state.v1",
|
|
161
|
+
installerState: "harnessed.installer-state.v1",
|
|
162
|
+
routeDecisionLog: "harnessed.route-decision-log.v1",
|
|
163
|
+
checkpoint: "harnessed.checkpoint.v1",
|
|
164
|
+
currentWorkflow: "harnessed.current-workflow.v1",
|
|
165
|
+
// ← Phase 3.1 W1 T1.1 ADD 8th surface (D-02 KARPATHY 3-state)
|
|
166
|
+
config: "harnessed.config.v1",
|
|
167
|
+
// ← Phase 3.2 W1 T1.1 ADD 9th surface (D-01 PROBE gstack_prefix store)
|
|
168
|
+
governance: "harnessed.governance.v1",
|
|
169
|
+
// ← Phase 3.2 W1 T1.1 ADD 10th surface (D-04 PUSH veto status)
|
|
170
|
+
planFeature: "harnessed.plan-feature.v1",
|
|
171
|
+
// ← Phase 3.3 W0 T0.5 BACKFILL 11th surface (sister Phase 3.2 W2 T2.2 b875e21 commit msg claim "11th surface" was LATENT STALE — never registered; T0.5 surgical fix per sister Phase 3.2 W2 T2.6 latent W1 c37ee29 Rule 1 pattern)
|
|
172
|
+
aliases: "harnessed.aliases.v1",
|
|
173
|
+
// ← Phase 3.3 W1 T1.1 ADD 12th surface (D-01 RICH manifests/aliases.yaml upstream rename redirect + metadata)
|
|
174
|
+
knownGood: "harnessed.known-good.v1"
|
|
175
|
+
// ← Phase 3.3 W1 T1.1 ADD 13th surface (D-03 YAML versions/<harnessed-ver>-known-good.yaml per-version lock)
|
|
176
|
+
};
|
|
177
|
+
Type.Union([
|
|
178
|
+
Type.Literal(SCHEMA_VERSIONS.routingSnapshot),
|
|
179
|
+
Type.Literal(SCHEMA_VERSIONS.handoffDoc),
|
|
180
|
+
Type.Literal(SCHEMA_VERSIONS.phasesYaml),
|
|
181
|
+
Type.Literal(SCHEMA_VERSIONS.manifestState),
|
|
182
|
+
Type.Literal(SCHEMA_VERSIONS.installerState),
|
|
183
|
+
Type.Literal(SCHEMA_VERSIONS.routeDecisionLog),
|
|
184
|
+
Type.Literal(SCHEMA_VERSIONS.checkpoint),
|
|
185
|
+
Type.Literal(SCHEMA_VERSIONS.currentWorkflow),
|
|
186
|
+
// ← Phase 3.1 W1 T1.1 ADD 8th surface
|
|
187
|
+
Type.Literal(SCHEMA_VERSIONS.config),
|
|
188
|
+
// ← Phase 3.2 W1 T1.1 ADD 9th surface
|
|
189
|
+
Type.Literal(SCHEMA_VERSIONS.governance),
|
|
190
|
+
// ← Phase 3.2 W1 T1.1 ADD 10th surface
|
|
191
|
+
Type.Literal(SCHEMA_VERSIONS.planFeature),
|
|
192
|
+
// ← Phase 3.3 W0 T0.5 BACKFILL 11th surface
|
|
193
|
+
Type.Literal(SCHEMA_VERSIONS.aliases),
|
|
194
|
+
// ← Phase 3.3 W1 T1.1 ADD 12th surface
|
|
195
|
+
Type.Literal(SCHEMA_VERSIONS.knownGood)
|
|
196
|
+
// ← Phase 3.3 W1 T1.1 ADD 13th surface
|
|
197
|
+
]);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
var AliasEntryV1, AliasesV1;
|
|
201
|
+
var init_aliases_v1 = __esm({
|
|
202
|
+
"src/manifest/schema/aliases.v1.ts"() {
|
|
203
|
+
init_schemaVersion();
|
|
204
|
+
AliasEntryV1 = Type.Object(
|
|
205
|
+
{
|
|
206
|
+
redirect: Type.String({ minLength: 1 }),
|
|
207
|
+
reason: Type.String({ minLength: 1, maxLength: 500 }),
|
|
208
|
+
// DOS cap sister governance.ts
|
|
209
|
+
since_version: Type.String({ pattern: "^\\d+\\.\\d+\\.\\d+$" }),
|
|
210
|
+
// semver strict
|
|
211
|
+
deprecation_date: Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" }),
|
|
212
|
+
// ISO-date Phase 3.2 W2 Rule 1
|
|
213
|
+
removal_date: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" }))
|
|
214
|
+
// optional long-tail window
|
|
215
|
+
},
|
|
216
|
+
{ additionalProperties: false }
|
|
217
|
+
);
|
|
218
|
+
AliasesV1 = Type.Object(
|
|
219
|
+
{
|
|
220
|
+
schemaVersion: Type.Literal(SCHEMA_VERSIONS.aliases),
|
|
221
|
+
// 'harnessed.aliases.v1'
|
|
222
|
+
aliases: Type.Record(Type.String({ minLength: 1 }), AliasEntryV1)
|
|
223
|
+
},
|
|
224
|
+
{ additionalProperties: false }
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// src/manifest/aliases.ts
|
|
230
|
+
var aliases_exports = {};
|
|
231
|
+
__export(aliases_exports, {
|
|
232
|
+
listDeprecations: () => listDeprecations,
|
|
233
|
+
loadAliases: () => loadAliases,
|
|
234
|
+
resolveAlias: () => resolveAlias
|
|
235
|
+
});
|
|
236
|
+
function loadAliases() {
|
|
237
|
+
if (_cached) return _cached;
|
|
238
|
+
if (!existsSync(ALIASES_PATH)) return null;
|
|
239
|
+
const raw = readFileSync(ALIASES_PATH, "utf8");
|
|
240
|
+
const parsed = parse(raw);
|
|
241
|
+
if (!Value.Check(AliasesV1, parsed)) {
|
|
242
|
+
const errs = [...Value.Errors(AliasesV1, parsed)].slice(0, 3);
|
|
243
|
+
throw new Error(
|
|
244
|
+
`aliases.yaml schema invalid: ${errs.map((e) => `${e.path} ${e.message}`).join("; ")}`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
_cached = parsed;
|
|
248
|
+
return parsed;
|
|
249
|
+
}
|
|
250
|
+
function resolveAlias(name) {
|
|
251
|
+
checkPathSafe(name);
|
|
252
|
+
return loadAliases()?.aliases?.[name]?.redirect ?? null;
|
|
253
|
+
}
|
|
254
|
+
function listDeprecations() {
|
|
255
|
+
const a = loadAliases();
|
|
256
|
+
return a ? Object.entries(a.aliases).map(([old, entry]) => ({ old, entry })) : [];
|
|
257
|
+
}
|
|
258
|
+
var ALIASES_PATH, _cached;
|
|
259
|
+
var init_aliases = __esm({
|
|
260
|
+
"src/manifest/aliases.ts"() {
|
|
261
|
+
init_path_guard();
|
|
262
|
+
init_aliases_v1();
|
|
263
|
+
ALIASES_PATH = join(process.cwd(), "manifests", "aliases.yaml");
|
|
264
|
+
_cached = null;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// src/cli/lib/check-deprecations.ts
|
|
269
|
+
var check_deprecations_exports = {};
|
|
270
|
+
__export(check_deprecations_exports, {
|
|
271
|
+
checkDeprecations: () => checkDeprecations
|
|
272
|
+
});
|
|
273
|
+
function checkDeprecations() {
|
|
274
|
+
try {
|
|
275
|
+
const deprecations = listDeprecations();
|
|
276
|
+
if (deprecations.length === 0) {
|
|
277
|
+
return { name: "deprecated manifests", status: "pass", message: "no deprecated manifests" };
|
|
278
|
+
}
|
|
279
|
+
const lines = deprecations.map(({ old, entry }) => {
|
|
280
|
+
const removal = entry.removal_date ? `, removes ${entry.removal_date}` : "";
|
|
281
|
+
return ` '${old}' \u2192 '${entry.redirect}' (since ${entry.since_version}, ${entry.deprecation_date}${removal}; ${entry.reason})`;
|
|
282
|
+
});
|
|
283
|
+
return {
|
|
284
|
+
name: "deprecated manifests",
|
|
285
|
+
status: "warn",
|
|
286
|
+
message: `${deprecations.length} deprecated manifest(s):
|
|
287
|
+
${lines.join("\n")}`,
|
|
288
|
+
fix: "install paths auto-redirect; consider migrating manifest references to new names"
|
|
289
|
+
};
|
|
290
|
+
} catch (e) {
|
|
291
|
+
return {
|
|
292
|
+
name: "deprecated manifests",
|
|
293
|
+
status: "fail",
|
|
294
|
+
message: `aliases.yaml load error: ${e.message}`,
|
|
295
|
+
fix: "verify manifests/aliases.yaml schema (see docs/PROJECT-SPEC.md)"
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
var init_check_deprecations = __esm({
|
|
300
|
+
"src/cli/lib/check-deprecations.ts"() {
|
|
301
|
+
init_aliases();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
var CheckpointStatus, CheckpointV1;
|
|
305
|
+
var init_checkpoint_v1 = __esm({
|
|
306
|
+
"src/checkpoint/schema/checkpoint.v1.ts"() {
|
|
307
|
+
init_schemaVersion();
|
|
308
|
+
CheckpointStatus = Type.Union([
|
|
309
|
+
Type.Literal("active"),
|
|
310
|
+
Type.Literal("paused"),
|
|
311
|
+
Type.Literal("complete")
|
|
312
|
+
]);
|
|
313
|
+
CheckpointV1 = Type.Object(
|
|
314
|
+
{
|
|
315
|
+
schemaVersion: Type.Literal(SCHEMA_VERSIONS.checkpoint),
|
|
316
|
+
phase: Type.String({ minLength: 1 }),
|
|
317
|
+
status: CheckpointStatus,
|
|
318
|
+
last_task: Type.String(),
|
|
319
|
+
key_decisions: Type.Array(Type.String()),
|
|
320
|
+
canonical_refs: Type.Array(Type.String()),
|
|
321
|
+
/** D-04 WIRE-IN: optional SDK session_id captured via `sdkSpawn`
|
|
322
|
+
* `onSessionId` callback (CD-4 closure-ready) for future `--resume`. */
|
|
323
|
+
session_id: Type.Optional(Type.String()),
|
|
324
|
+
/** RESEARCH § 1.3 critical constraint — SDK session resume requires cwd
|
|
325
|
+
* match; we capture and validate at restore time. */
|
|
326
|
+
cwd: Type.String({ minLength: 1 }),
|
|
327
|
+
timestamp: Type.String({ minLength: 1 }),
|
|
328
|
+
// ISO-8601 by convention (TypeBox `format` requires Ajv-style format registry; we keep shape-check only — drift caught in writeCheckpoint path)
|
|
329
|
+
archive_path: Type.String({ minLength: 1 })
|
|
330
|
+
},
|
|
331
|
+
{ additionalProperties: false }
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
var WorkflowStatus, CurrentWorkflowV1;
|
|
336
|
+
var init_currentWorkflow_v1 = __esm({
|
|
337
|
+
"src/checkpoint/schema/currentWorkflow.v1.ts"() {
|
|
338
|
+
init_schemaVersion();
|
|
339
|
+
WorkflowStatus = Type.Union([
|
|
340
|
+
Type.Literal("active"),
|
|
341
|
+
Type.Literal("paused"),
|
|
342
|
+
Type.Literal("complete")
|
|
343
|
+
]);
|
|
344
|
+
CurrentWorkflowV1 = Type.Object(
|
|
345
|
+
{
|
|
346
|
+
schemaVersion: Type.Literal(SCHEMA_VERSIONS.currentWorkflow),
|
|
347
|
+
phase: Type.String({ minLength: 1 }),
|
|
348
|
+
status: WorkflowStatus,
|
|
349
|
+
last_checkpoint_path: Type.Union([Type.String(), Type.Null()]),
|
|
350
|
+
// ISO-8601 by convention (TypeBox `format` requires Ajv-style registry; shape-check only here, drift surfaces in state.ts writer).
|
|
351
|
+
started_at: Type.String({ minLength: 1 }),
|
|
352
|
+
paused_at: Type.Optional(Type.String({ minLength: 1 })),
|
|
353
|
+
completed_at: Type.Optional(Type.String({ minLength: 1 }))
|
|
354
|
+
},
|
|
355
|
+
{ additionalProperties: false }
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// src/checkpoint/schema/index.ts
|
|
361
|
+
var init_schema = __esm({
|
|
362
|
+
"src/checkpoint/schema/index.ts"() {
|
|
363
|
+
init_checkpoint_v1();
|
|
364
|
+
init_currentWorkflow_v1();
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
function estimateTokens(s) {
|
|
368
|
+
return Math.ceil(Buffer.byteLength(s, "utf8") / 4);
|
|
369
|
+
}
|
|
370
|
+
function enforceBudget(c, budget = BUDGET_TOKEN) {
|
|
371
|
+
let candidate = c;
|
|
372
|
+
let tokens = estimateTokens(JSON.stringify(candidate));
|
|
373
|
+
if (tokens <= budget) return candidate;
|
|
374
|
+
candidate = { ...candidate, last_task: candidate.last_task.slice(0, 200) };
|
|
375
|
+
tokens = estimateTokens(JSON.stringify(candidate));
|
|
376
|
+
if (tokens <= budget) return candidate;
|
|
377
|
+
candidate = { ...candidate, key_decisions: candidate.key_decisions.slice(0, 5) };
|
|
378
|
+
tokens = estimateTokens(JSON.stringify(candidate));
|
|
379
|
+
if (tokens <= budget) return candidate;
|
|
380
|
+
throw new CheckpointTooLargeError(
|
|
381
|
+
`Checkpoint exceeds ${budget}-token budget even after truncation (estimated ${tokens})`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
function writeCheckpoint(c, customPath) {
|
|
385
|
+
if (!Value.Check(CheckpointV1, c)) {
|
|
386
|
+
const errs = [...Value.Errors(CheckpointV1, c)].map((e) => e.message).join("; ");
|
|
387
|
+
throw new CheckpointWriteError(`Schema validation failed: ${errs}`);
|
|
388
|
+
}
|
|
389
|
+
const enforced = enforceBudget(c);
|
|
390
|
+
const path = `.harnessed/checkpoints/${enforced.phase}.json`;
|
|
391
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
392
|
+
writeFileSync(path, JSON.stringify(enforced, null, 2), "utf8");
|
|
393
|
+
return path;
|
|
394
|
+
}
|
|
395
|
+
var BUDGET_TOKEN, CheckpointTooLargeError, CheckpointWriteError;
|
|
396
|
+
var init_template = __esm({
|
|
397
|
+
"src/checkpoint/template.ts"() {
|
|
398
|
+
init_schema();
|
|
399
|
+
BUDGET_TOKEN = 1e3;
|
|
400
|
+
CheckpointTooLargeError = class extends Error {
|
|
401
|
+
constructor(message) {
|
|
402
|
+
super(message);
|
|
403
|
+
this.name = "CheckpointTooLargeError";
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
CheckpointWriteError = class extends Error {
|
|
407
|
+
constructor(message) {
|
|
408
|
+
super(message);
|
|
409
|
+
this.name = "CheckpointWriteError";
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// src/cli/lib/check-token-budget.ts
|
|
416
|
+
var check_token_budget_exports = {};
|
|
417
|
+
__export(check_token_budget_exports, {
|
|
418
|
+
checkTokenBudget: () => checkTokenBudget
|
|
419
|
+
});
|
|
420
|
+
function scanSkillsDir(root) {
|
|
421
|
+
if (!existsSync(root)) return [];
|
|
422
|
+
return readdirSync(root).flatMap((name) => {
|
|
423
|
+
const md = join(root, name, "SKILL.md");
|
|
424
|
+
if (!existsSync(md)) return [];
|
|
425
|
+
const fm = readFileSync(md, "utf8").match(/^---\n([\s\S]*?)\n---/)?.[1] ?? "";
|
|
426
|
+
const desc = fm.match(/^description:\s*(.+)$/m)?.[1] ?? "";
|
|
427
|
+
return [{ name, tokens: estimateTokens(desc) }];
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
function checkTokenBudget() {
|
|
431
|
+
const roots = [join(homedir(), ".claude", "skills"), join(process.cwd(), "skills")];
|
|
432
|
+
const items = roots.flatMap(scanSkillsDir);
|
|
433
|
+
const total = items.reduce((s, i) => s + i.tokens, 0);
|
|
434
|
+
const over = items.filter((i) => i.tokens > PER_SKILL_THRESHOLD).length;
|
|
435
|
+
if (total <= TOTAL_THRESHOLD && over === 0) {
|
|
436
|
+
const msg = `${items.length} skill(s) total ${total} tokens (under 1% / 2000 threshold)`;
|
|
437
|
+
return { name: "token budget", status: "pass", message: msg };
|
|
438
|
+
}
|
|
439
|
+
const top = [...items].sort((a, b) => b.tokens - a.tokens).slice(0, 3).map((t) => `${t.name}:${t.tokens}`).join(", ");
|
|
440
|
+
const pct = (total / CONTEXT_WINDOW_TOKENS * 100).toFixed(2);
|
|
441
|
+
const message = `${items.length} skill(s) total ${total} tokens (${pct}% of 200000) \u2014 top: ${top}`;
|
|
442
|
+
return {
|
|
443
|
+
name: "token budget",
|
|
444
|
+
status: "warn",
|
|
445
|
+
message,
|
|
446
|
+
fix: "shorten verbose skill descriptions OR review per-skill > 5000 tokens"
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
var CONTEXT_WINDOW_TOKENS, TOTAL_THRESHOLD, PER_SKILL_THRESHOLD;
|
|
450
|
+
var init_check_token_budget = __esm({
|
|
451
|
+
"src/cli/lib/check-token-budget.ts"() {
|
|
452
|
+
init_template();
|
|
453
|
+
CONTEXT_WINDOW_TOKENS = 2e5;
|
|
454
|
+
TOTAL_THRESHOLD = 2e3;
|
|
455
|
+
PER_SKILL_THRESHOLD = 5e3;
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
async function withLock(fn) {
|
|
459
|
+
let release;
|
|
460
|
+
try {
|
|
461
|
+
release = await lockfile.lock(LOCK_TARGET, LOCK_OPTS);
|
|
462
|
+
} catch (e) {
|
|
463
|
+
if (e.code === "ELOCKED") throw new LockHeldError();
|
|
464
|
+
throw e;
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
return await fn();
|
|
468
|
+
} finally {
|
|
469
|
+
await release?.();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
async function readCurrentWorkflow() {
|
|
473
|
+
let raw;
|
|
474
|
+
try {
|
|
475
|
+
raw = await readFile(STATE_PATH, "utf8");
|
|
476
|
+
} catch {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
let parsed;
|
|
480
|
+
try {
|
|
481
|
+
parsed = JSON.parse(raw);
|
|
482
|
+
} catch {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
const v = parsed.schemaVersion ?? "";
|
|
486
|
+
return branchOnSchemaVersion(v, {
|
|
487
|
+
v1: () => Value.Check(CurrentWorkflowV1, parsed) ? parsed : null,
|
|
488
|
+
unknown: () => null
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
async function writeCurrentWorkflow(s) {
|
|
492
|
+
if (!Value.Check(CurrentWorkflowV1, s)) {
|
|
493
|
+
const errs = [...Value.Errors(CurrentWorkflowV1, s)].map((e) => e.message).join("; ");
|
|
494
|
+
throw new WorkflowStateError(`current-workflow schema validation failed: ${errs}`);
|
|
495
|
+
}
|
|
496
|
+
await mkdir(dirname(STATE_PATH), { recursive: true });
|
|
497
|
+
await withLock(async () => {
|
|
498
|
+
await writeFile(STATE_PATH, JSON.stringify(s, null, 2), "utf8");
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
async function activate(phase, checkpointPath = null) {
|
|
502
|
+
await writeCurrentWorkflow({
|
|
503
|
+
schemaVersion: SCHEMA_VERSIONS.currentWorkflow,
|
|
504
|
+
phase,
|
|
505
|
+
status: "active",
|
|
506
|
+
last_checkpoint_path: checkpointPath,
|
|
507
|
+
started_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
async function complete() {
|
|
511
|
+
const s = await readCurrentWorkflow();
|
|
512
|
+
if (!s) return;
|
|
513
|
+
await writeCurrentWorkflow({ ...s, status: "complete", completed_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
514
|
+
}
|
|
515
|
+
var STATE_PATH, LOCK_TARGET, LOCK_OPTS, WorkflowStateError, LockHeldError;
|
|
516
|
+
var init_state = __esm({
|
|
517
|
+
"src/checkpoint/state.ts"() {
|
|
518
|
+
init_schemaVersion();
|
|
519
|
+
init_schema();
|
|
520
|
+
STATE_PATH = ".harnessed/current-workflow.json";
|
|
521
|
+
LOCK_TARGET = ".harnessed";
|
|
522
|
+
LOCK_OPTS = {
|
|
523
|
+
stale: 1e4,
|
|
524
|
+
retries: { retries: 3, factor: 2, minTimeout: 100 },
|
|
525
|
+
lockfilePath: ".harnessed/.lock"
|
|
526
|
+
};
|
|
527
|
+
WorkflowStateError = class extends Error {
|
|
528
|
+
constructor(message) {
|
|
529
|
+
super(message);
|
|
530
|
+
this.name = "WorkflowStateError";
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
LockHeldError = class _LockHeldError extends Error {
|
|
534
|
+
constructor() {
|
|
535
|
+
super(
|
|
536
|
+
"another harnessed process holds the lock at .harnessed/.lock \u2014 wait or kill stale process (try: harnessed status)"
|
|
537
|
+
);
|
|
538
|
+
this.name = "LockHeldError";
|
|
539
|
+
Object.setPrototypeOf(this, _LockHeldError.prototype);
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
var PinnedUpstream, KnownGoodV1;
|
|
545
|
+
var init_known_good_v1 = __esm({
|
|
546
|
+
"src/manifest/schema/known-good.v1.ts"() {
|
|
547
|
+
init_schemaVersion();
|
|
548
|
+
PinnedUpstream = Type.Object(
|
|
549
|
+
{
|
|
550
|
+
name: Type.String({ minLength: 1 }),
|
|
551
|
+
version: Type.String({ minLength: 1 }),
|
|
552
|
+
install_method: Type.String({ minLength: 1 })
|
|
553
|
+
// npm-cli / mcp-stdio-add / etc per Phase 2.X
|
|
554
|
+
},
|
|
555
|
+
{ additionalProperties: false }
|
|
556
|
+
);
|
|
557
|
+
KnownGoodV1 = Type.Object(
|
|
558
|
+
{
|
|
559
|
+
schemaVersion: Type.Literal(SCHEMA_VERSIONS.knownGood),
|
|
560
|
+
// 'harnessed.known-good.v1'
|
|
561
|
+
harnessed_version: Type.String({ pattern: "^\\d+\\.\\d+\\.\\d+$" }),
|
|
562
|
+
// semver strict
|
|
563
|
+
e2e_verified_at: Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" }),
|
|
564
|
+
// ISO date pattern
|
|
565
|
+
upstreams: Type.Array(PinnedUpstream)
|
|
566
|
+
},
|
|
567
|
+
{ additionalProperties: false }
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// src/manifest/knownGood.ts
|
|
573
|
+
var knownGood_exports = {};
|
|
574
|
+
__export(knownGood_exports, {
|
|
575
|
+
getPinnedVersion: () => getPinnedVersion,
|
|
576
|
+
loadKnownGood: () => loadKnownGood
|
|
577
|
+
});
|
|
578
|
+
function loadKnownGood(harnessedVer) {
|
|
579
|
+
if (_cache.has(harnessedVer)) return _cache.get(harnessedVer) ?? null;
|
|
580
|
+
const path = join(versionsDir(), `${harnessedVer}-known-good.yaml`);
|
|
581
|
+
if (!existsSync(path)) {
|
|
582
|
+
_cache.set(harnessedVer, null);
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
const raw = readFileSync(path, "utf8");
|
|
586
|
+
const parsed = parse(raw);
|
|
587
|
+
if (!Value.Check(KnownGoodV1, parsed)) {
|
|
588
|
+
const errs = [...Value.Errors(KnownGoodV1, parsed)].slice(0, 3);
|
|
589
|
+
throw new Error(
|
|
590
|
+
`${path} schema invalid: ${errs.map((e) => `${e.path} ${e.message}`).join("; ")}`
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
_cache.set(harnessedVer, parsed);
|
|
594
|
+
return parsed;
|
|
595
|
+
}
|
|
596
|
+
function getPinnedVersion(upstreamName, harnessedVer) {
|
|
597
|
+
const kg = loadKnownGood(harnessedVer);
|
|
598
|
+
if (!kg) return null;
|
|
599
|
+
const entry = kg.upstreams.find((u) => u.name === upstreamName);
|
|
600
|
+
return entry?.version ?? null;
|
|
601
|
+
}
|
|
602
|
+
var versionsDir, _cache;
|
|
603
|
+
var init_knownGood = __esm({
|
|
604
|
+
"src/manifest/knownGood.ts"() {
|
|
605
|
+
init_known_good_v1();
|
|
606
|
+
versionsDir = () => join(process.cwd(), "versions");
|
|
607
|
+
_cache = /* @__PURE__ */ new Map();
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// src/checkpoint/resume.ts
|
|
612
|
+
var resume_exports = {};
|
|
613
|
+
__export(resume_exports, {
|
|
614
|
+
runResume: () => runResume
|
|
615
|
+
});
|
|
616
|
+
async function runResume() {
|
|
617
|
+
const current = await readCurrentWorkflow();
|
|
618
|
+
if (!current) {
|
|
619
|
+
return { status: "no-paused-phase", error: "no .harnessed/current-workflow.json found" };
|
|
620
|
+
}
|
|
621
|
+
if (current.status !== "paused") {
|
|
622
|
+
return {
|
|
623
|
+
status: "no-paused-phase",
|
|
624
|
+
error: `workflow status is '${current.status}', not 'paused'`
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
if (!current.last_checkpoint_path) {
|
|
628
|
+
return { status: "corrupt", error: "last_checkpoint_path missing", path: "" };
|
|
629
|
+
}
|
|
630
|
+
const path = current.last_checkpoint_path;
|
|
631
|
+
let raw;
|
|
632
|
+
try {
|
|
633
|
+
raw = await readFile(path, "utf8");
|
|
634
|
+
} catch (e) {
|
|
635
|
+
return { status: "corrupt", error: `checkpoint missing: ${e.message}`, path };
|
|
636
|
+
}
|
|
637
|
+
let parsed;
|
|
638
|
+
try {
|
|
639
|
+
parsed = JSON.parse(raw);
|
|
640
|
+
} catch (e) {
|
|
641
|
+
return {
|
|
642
|
+
status: "corrupt",
|
|
643
|
+
error: `checkpoint JSON parse failed: ${e.message}`,
|
|
644
|
+
path
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
const v = parsed.schemaVersion ?? "";
|
|
648
|
+
const validated = branchOnSchemaVersion(v, {
|
|
649
|
+
v1: () => Value.Check(CheckpointV1, parsed) ? parsed : null,
|
|
650
|
+
unknown: () => null
|
|
651
|
+
});
|
|
652
|
+
if (!validated) {
|
|
653
|
+
const errs = [...Value.Errors(CheckpointV1, parsed)].map((e) => e.message).join("; ");
|
|
654
|
+
return { status: "corrupt", error: `checkpoint schema validation failed: ${errs}`, path };
|
|
655
|
+
}
|
|
656
|
+
const cwd = process.cwd();
|
|
657
|
+
const cwdWarn = validated.cwd !== cwd ? `\u26A0 checkpoint cwd '${validated.cwd}' \u2260 current cwd '${cwd}' \u2014 SDK session resume may fail (\xA7 1.3); fresh-session fallback` : void 0;
|
|
658
|
+
const sidHint = validated.session_id ? ` (session_id: ${validated.session_id} \u2014 SDK will redirect to original session)` : " (fresh session \u2014 context reloaded from checkpoint)";
|
|
659
|
+
const resumeHint = `\u2192 in Claude Code: /gsd-execute-phase ${validated.phase}${sidHint}`;
|
|
660
|
+
return { status: "ok", checkpoint: validated, ...cwdWarn ? { cwdWarn } : {}, resumeHint };
|
|
661
|
+
}
|
|
662
|
+
var init_resume = __esm({
|
|
663
|
+
"src/checkpoint/resume.ts"() {
|
|
664
|
+
init_schemaVersion();
|
|
665
|
+
init_schema();
|
|
666
|
+
init_state();
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// package.json
|
|
671
|
+
var package_default = {
|
|
672
|
+
version: "1.0.0"};
|
|
673
|
+
|
|
674
|
+
// src/manifest/errors.ts
|
|
675
|
+
function instancePathToKeyPath(instancePath) {
|
|
676
|
+
if (!instancePath || instancePath === "/") return [];
|
|
677
|
+
return instancePath.split("/").filter(Boolean).map((seg) => {
|
|
678
|
+
const n = Number(seg);
|
|
679
|
+
return Number.isInteger(n) && String(n) === seg ? n : seg;
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
function locateLineFromDoc(doc, lineCounter, instancePath) {
|
|
683
|
+
const path = instancePathToKeyPath(instancePath);
|
|
684
|
+
let node;
|
|
685
|
+
if (path.length === 0) {
|
|
686
|
+
node = doc.contents;
|
|
687
|
+
} else {
|
|
688
|
+
node = doc.getIn(path, true);
|
|
689
|
+
}
|
|
690
|
+
if (!node || typeof node !== "object") return { line: null, column: null };
|
|
691
|
+
const range = node.range;
|
|
692
|
+
if (!range) return { line: null, column: null };
|
|
693
|
+
const offset = range[0];
|
|
694
|
+
const pos = lineCounter.linePos(offset);
|
|
695
|
+
return { line: pos.line, column: pos.col };
|
|
696
|
+
}
|
|
697
|
+
function ajvErrorToFriendly(err2, file, doc, lineCounter) {
|
|
698
|
+
let path = err2.instancePath || "/";
|
|
699
|
+
const params = err2.params;
|
|
700
|
+
if (err2.keyword === "additionalProperties" && params && typeof params.additionalProperty === "string") {
|
|
701
|
+
path = `${path}/${params.additionalProperty}`;
|
|
702
|
+
} else if (err2.keyword === "required" && params && typeof params.missingProperty === "string") {
|
|
703
|
+
path = `${path}/${params.missingProperty}`;
|
|
704
|
+
}
|
|
705
|
+
let line = null;
|
|
706
|
+
let column = null;
|
|
707
|
+
if (doc && lineCounter) {
|
|
708
|
+
const lookupPath = err2.keyword === "required" ? err2.instancePath || "/" : path;
|
|
709
|
+
const loc = locateLineFromDoc(doc, lineCounter, lookupPath);
|
|
710
|
+
line = loc.line;
|
|
711
|
+
column = loc.column;
|
|
712
|
+
}
|
|
713
|
+
return {
|
|
714
|
+
file,
|
|
715
|
+
path,
|
|
716
|
+
message: err2.message ?? "unknown error",
|
|
717
|
+
line,
|
|
718
|
+
column,
|
|
719
|
+
keyword: err2.keyword
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
function yamlParseErrorToFriendly(err2, file) {
|
|
723
|
+
return {
|
|
724
|
+
file,
|
|
725
|
+
path: "/",
|
|
726
|
+
message: err2.message,
|
|
727
|
+
line: err2.linePos?.[0]?.line ?? null,
|
|
728
|
+
column: err2.linePos?.[0]?.col ?? null,
|
|
729
|
+
keyword: "yaml-parse"
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
var ApiVersion = Type.Literal("harnessed/v1");
|
|
733
|
+
var Kind = Type.Literal("Manifest");
|
|
734
|
+
var SpdxLicense = Type.Union([
|
|
735
|
+
Type.Literal("MIT"),
|
|
736
|
+
Type.Literal("Apache-2.0"),
|
|
737
|
+
Type.Literal("BSD-3-Clause"),
|
|
738
|
+
Type.Literal("ISC"),
|
|
739
|
+
Type.Literal("0BSD"),
|
|
740
|
+
Type.Literal("MIT-0"),
|
|
741
|
+
Type.Literal("anthropics-official")
|
|
742
|
+
]);
|
|
743
|
+
var LicenseSource = Type.Union([
|
|
744
|
+
Type.Literal("README"),
|
|
745
|
+
Type.Literal("registry"),
|
|
746
|
+
Type.Literal("none"),
|
|
747
|
+
Type.Literal("anthropics-official")
|
|
748
|
+
]);
|
|
749
|
+
var Upstream = Type.Object(
|
|
750
|
+
{
|
|
751
|
+
source: Type.String({ minLength: 1 }),
|
|
752
|
+
homepage: Type.String({ format: "uri" }),
|
|
753
|
+
repository: Type.String({ format: "uri" }),
|
|
754
|
+
license: SpdxLicense,
|
|
755
|
+
license_source: Type.Optional(LicenseSource),
|
|
756
|
+
notice: Type.String({ minLength: 1, maxLength: 500 })
|
|
757
|
+
},
|
|
758
|
+
{ additionalProperties: false }
|
|
759
|
+
);
|
|
760
|
+
var MetadataSchema = Type.Object(
|
|
761
|
+
{
|
|
762
|
+
name: Type.String({ pattern: "^[a-z0-9][a-z0-9-]*$", minLength: 1 }),
|
|
763
|
+
display_name: Type.Optional(Type.String()),
|
|
764
|
+
description: Type.String({ minLength: 1, maxLength: 120 }),
|
|
765
|
+
upstream: Upstream
|
|
766
|
+
},
|
|
767
|
+
{ additionalProperties: false }
|
|
768
|
+
);
|
|
769
|
+
var HookEvent = Type.Union([
|
|
770
|
+
Type.Literal("SessionStart"),
|
|
771
|
+
Type.Literal("UserPromptSubmit"),
|
|
772
|
+
Type.Literal("PreToolUse"),
|
|
773
|
+
Type.Literal("PostToolUse")
|
|
774
|
+
]);
|
|
775
|
+
var CcHookAdd = Type.Object(
|
|
776
|
+
{
|
|
777
|
+
method: Type.Literal("cc-hook-add"),
|
|
778
|
+
cmd: Type.String({ minLength: 1 }),
|
|
779
|
+
// audit-trail (the bash invocation registered)
|
|
780
|
+
// cwd/env required by lib/spawn.ts discriminated-union access (sister 6 method
|
|
781
|
+
// shape parity — even though cc-hook-add does not spawn, generic spawn helper
|
|
782
|
+
// expects these fields on the install union).
|
|
783
|
+
cwd: Type.Optional(Type.String()),
|
|
784
|
+
env: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
785
|
+
hook_event: HookEvent,
|
|
786
|
+
hook_matcher: Type.Optional(Type.String()),
|
|
787
|
+
hook_command: Type.String({ minLength: 1 }),
|
|
788
|
+
idempotent_check: Type.String({ minLength: 1 })
|
|
789
|
+
},
|
|
790
|
+
{ additionalProperties: false }
|
|
791
|
+
);
|
|
792
|
+
var GIT_REF_PATTERN = "^([a-f0-9]{7,40}|v?\\d+\\.\\d+\\.\\d+([.-][\\w.-]+)?)$";
|
|
793
|
+
var REPO_PATTERN = "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$";
|
|
794
|
+
var CcPluginMarketplace = Type.Object(
|
|
795
|
+
{
|
|
796
|
+
method: Type.Literal("cc-plugin-marketplace"),
|
|
797
|
+
cmd: Type.String({ minLength: 1 }),
|
|
798
|
+
cwd: Type.Optional(Type.String()),
|
|
799
|
+
env: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
800
|
+
args: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
801
|
+
git_ref: Type.String({ minLength: 1, pattern: GIT_REF_PATTERN }),
|
|
802
|
+
idempotent_check: Type.String({ minLength: 1 }),
|
|
803
|
+
// ADR 0005 — third-party marketplace structured metadata (optional).
|
|
804
|
+
marketplace_source: Type.Optional(
|
|
805
|
+
Type.Object(
|
|
806
|
+
{
|
|
807
|
+
source: Type.Literal("github"),
|
|
808
|
+
repo: Type.String({ pattern: REPO_PATTERN, minLength: 3 })
|
|
809
|
+
},
|
|
810
|
+
{ additionalProperties: false }
|
|
811
|
+
)
|
|
812
|
+
)
|
|
813
|
+
},
|
|
814
|
+
{ additionalProperties: false }
|
|
815
|
+
);
|
|
816
|
+
var GIT_REF_PATTERN2 = "^([a-f0-9]{7,40}|v?\\d+\\.\\d+\\.\\d+([.-][\\w.-]+)?)$";
|
|
817
|
+
var GitCloneWithSetup = Type.Object(
|
|
818
|
+
{
|
|
819
|
+
method: Type.Literal("git-clone-with-setup"),
|
|
820
|
+
cmd: Type.String({ minLength: 1 }),
|
|
821
|
+
cwd: Type.Optional(Type.String()),
|
|
822
|
+
env: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
823
|
+
args: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
824
|
+
git_ref: Type.String({ minLength: 1, pattern: GIT_REF_PATTERN2 }),
|
|
825
|
+
idempotent_check: Type.String({ minLength: 1 })
|
|
826
|
+
},
|
|
827
|
+
{ additionalProperties: false }
|
|
828
|
+
);
|
|
829
|
+
var McpHttpAdd = Type.Object(
|
|
830
|
+
{
|
|
831
|
+
method: Type.Literal("mcp-http-add"),
|
|
832
|
+
cmd: Type.String({ minLength: 1 }),
|
|
833
|
+
cwd: Type.Optional(Type.String()),
|
|
834
|
+
env: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
835
|
+
args: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
836
|
+
npm_version: Type.String({ minLength: 1 }),
|
|
837
|
+
idempotent_check: Type.String({ minLength: 1 })
|
|
838
|
+
},
|
|
839
|
+
{ additionalProperties: false }
|
|
840
|
+
);
|
|
841
|
+
var McpStdioAdd = Type.Object(
|
|
842
|
+
{
|
|
843
|
+
method: Type.Literal("mcp-stdio-add"),
|
|
844
|
+
cmd: Type.String({ minLength: 1 }),
|
|
845
|
+
cwd: Type.Optional(Type.String()),
|
|
846
|
+
env: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
847
|
+
args: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
848
|
+
npm_version: Type.String({ minLength: 1 }),
|
|
849
|
+
idempotent_check: Type.String({ minLength: 1 })
|
|
850
|
+
},
|
|
851
|
+
{ additionalProperties: false }
|
|
852
|
+
);
|
|
853
|
+
var NpmCli = Type.Object(
|
|
854
|
+
{
|
|
855
|
+
method: Type.Literal("npm-cli"),
|
|
856
|
+
cmd: Type.String({ minLength: 1 }),
|
|
857
|
+
cwd: Type.Optional(Type.String()),
|
|
858
|
+
env: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
859
|
+
args: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
860
|
+
npm_version: Type.String({ minLength: 1 }),
|
|
861
|
+
idempotent_check: Type.String({ minLength: 1 })
|
|
862
|
+
},
|
|
863
|
+
{ additionalProperties: false }
|
|
864
|
+
);
|
|
865
|
+
var NpxSkillInstaller = Type.Object(
|
|
866
|
+
{
|
|
867
|
+
method: Type.Literal("npx-skill-installer"),
|
|
868
|
+
cmd: Type.String({ minLength: 1 }),
|
|
869
|
+
cwd: Type.Optional(Type.String()),
|
|
870
|
+
env: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
871
|
+
args: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
872
|
+
npm_version: Type.String({ minLength: 1 }),
|
|
873
|
+
idempotent_check: Type.String({ minLength: 1 })
|
|
874
|
+
},
|
|
875
|
+
{ additionalProperties: false }
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
// src/manifest/schema/installMethods/index.ts
|
|
879
|
+
var branches = [
|
|
880
|
+
CcPluginMarketplace,
|
|
881
|
+
GitCloneWithSetup,
|
|
882
|
+
NpxSkillInstaller,
|
|
883
|
+
NpmCli,
|
|
884
|
+
McpStdioAdd,
|
|
885
|
+
McpHttpAdd,
|
|
886
|
+
CcHookAdd
|
|
887
|
+
];
|
|
888
|
+
var InstallSchema = {
|
|
889
|
+
type: "object",
|
|
890
|
+
discriminator: { propertyName: "method" },
|
|
891
|
+
required: ["method"],
|
|
892
|
+
properties: {
|
|
893
|
+
method: { type: "string" }
|
|
894
|
+
},
|
|
895
|
+
oneOf: branches
|
|
896
|
+
};
|
|
897
|
+
Type.Union([...branches]);
|
|
898
|
+
|
|
899
|
+
// src/manifest/schema/spec.ts
|
|
900
|
+
var TypeEnum = Type.Union([
|
|
901
|
+
Type.Literal("cc-plugin"),
|
|
902
|
+
Type.Literal("cc-skill-pack"),
|
|
903
|
+
Type.Literal("mcp-npm"),
|
|
904
|
+
Type.Literal("cli-npm"),
|
|
905
|
+
// Phase 2.4 W3 T3.1 (D-04 § 3.1 + R2.4.4 + B-22) — 5th type, 1:1 with install_type:'hook'.
|
|
906
|
+
// Lifecycle hooks registered to ~/.claude/settings.json (SessionStart / UserPromptSubmit /
|
|
907
|
+
// PreToolUse / PostToolUse) via the cc-hook-add install method.
|
|
908
|
+
Type.Literal("cc-hook")
|
|
909
|
+
]);
|
|
910
|
+
var ComponentType = Type.Union([
|
|
911
|
+
Type.Literal("command"),
|
|
912
|
+
Type.Literal("behavior-rule"),
|
|
913
|
+
Type.Literal("mcp-tool"),
|
|
914
|
+
Type.Literal("cli-binary")
|
|
915
|
+
]);
|
|
916
|
+
var Verify = Type.Object(
|
|
917
|
+
{
|
|
918
|
+
cmd: Type.String({ minLength: 1 }),
|
|
919
|
+
timeout_ms: Type.Optional(Type.Integer({ minimum: 100, maximum: 6e4 })),
|
|
920
|
+
expected_exit_code: Type.Optional(Type.Integer())
|
|
921
|
+
},
|
|
922
|
+
{ additionalProperties: false }
|
|
923
|
+
);
|
|
924
|
+
var Uninstall = Type.Object(
|
|
925
|
+
{
|
|
926
|
+
cmd: Type.String({ minLength: 1 }),
|
|
927
|
+
cleanup_paths: Type.Optional(Type.Array(Type.String()))
|
|
928
|
+
},
|
|
929
|
+
{ additionalProperties: false }
|
|
930
|
+
);
|
|
931
|
+
var Stability = Type.Union([
|
|
932
|
+
Type.Literal("stable"),
|
|
933
|
+
Type.Literal("beta"),
|
|
934
|
+
Type.Literal("unstable"),
|
|
935
|
+
Type.Literal("archived")
|
|
936
|
+
]);
|
|
937
|
+
var FallbackAction = Type.Union([
|
|
938
|
+
Type.Literal("warn"),
|
|
939
|
+
Type.Literal("block"),
|
|
940
|
+
Type.Literal("use_alternative")
|
|
941
|
+
]);
|
|
942
|
+
var UpstreamHealth = Type.Object(
|
|
943
|
+
{
|
|
944
|
+
stability: Stability,
|
|
945
|
+
last_check: Type.String({ format: "date" }),
|
|
946
|
+
last_known_good_version: Type.String({ minLength: 1 }),
|
|
947
|
+
fallback_action: FallbackAction,
|
|
948
|
+
alternative: Type.Optional(Type.String())
|
|
949
|
+
},
|
|
950
|
+
{ additionalProperties: false }
|
|
951
|
+
);
|
|
952
|
+
var Platform = Type.Union([Type.Literal("linux"), Type.Literal("darwin"), Type.Literal("win32")]);
|
|
953
|
+
var Signature = Type.Object(
|
|
954
|
+
{ sigstore_bundle: Type.String({ format: "uri" }) },
|
|
955
|
+
{ additionalProperties: false }
|
|
956
|
+
);
|
|
957
|
+
var TestedWithVersions = Type.Object(
|
|
958
|
+
{
|
|
959
|
+
cc_versions: Type.Optional(Type.Array(Type.String())),
|
|
960
|
+
node_versions: Type.Optional(Type.Array(Type.String()))
|
|
961
|
+
},
|
|
962
|
+
{ additionalProperties: false }
|
|
963
|
+
);
|
|
964
|
+
var Category = Type.Union([
|
|
965
|
+
Type.Literal("meta"),
|
|
966
|
+
Type.Literal("engineering"),
|
|
967
|
+
Type.Literal("design"),
|
|
968
|
+
Type.Literal("content"),
|
|
969
|
+
Type.Literal("testing"),
|
|
970
|
+
Type.Literal("search")
|
|
971
|
+
]);
|
|
972
|
+
var InstallType = Type.Union([
|
|
973
|
+
Type.Literal("skill"),
|
|
974
|
+
Type.Literal("mcp"),
|
|
975
|
+
Type.Literal("npm"),
|
|
976
|
+
Type.Literal("git"),
|
|
977
|
+
// Phase 2.4 W3 T3.1 (D-04 § 3.1 + R2.4.4 + B-22) — 5th install_type, 1:1 with TypeEnum:'cc-hook'.
|
|
978
|
+
Type.Literal("hook")
|
|
979
|
+
]);
|
|
980
|
+
var DecisionRules = Type.Object(
|
|
981
|
+
{
|
|
982
|
+
trigger: Type.Optional(Type.String({ minLength: 1 })),
|
|
983
|
+
default_expert: Type.Optional(Type.String({ minLength: 1 })),
|
|
984
|
+
arbitration_rule: Type.Optional(Type.String({ minLength: 1 })),
|
|
985
|
+
override_signals: Type.Optional(
|
|
986
|
+
Type.Array(
|
|
987
|
+
Type.Object(
|
|
988
|
+
{
|
|
989
|
+
phrase: Type.String({ minLength: 1 }),
|
|
990
|
+
use: Type.String({ minLength: 1 })
|
|
991
|
+
},
|
|
992
|
+
{ additionalProperties: false }
|
|
993
|
+
)
|
|
994
|
+
)
|
|
995
|
+
),
|
|
996
|
+
// Phase 2.3 W2 T2.5 — CD-3 negative-space hint mirror (B-17 per-manifest hint
|
|
997
|
+
// redundant guard layer; SSOT remains routing/decision_rules.yaml — D-04 lead).
|
|
998
|
+
// Additive optional; existing manifests unchanged (A7 守恒).
|
|
999
|
+
do_not_use_when: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { minItems: 1 })),
|
|
1000
|
+
if_rejected_use: Type.Optional(Type.String({ minLength: 1 }))
|
|
1001
|
+
},
|
|
1002
|
+
{ additionalProperties: false }
|
|
1003
|
+
);
|
|
1004
|
+
var Phase = Type.Union([
|
|
1005
|
+
Type.Literal("discuss"),
|
|
1006
|
+
Type.Literal("plan"),
|
|
1007
|
+
Type.Literal("execute"),
|
|
1008
|
+
Type.Literal("verify")
|
|
1009
|
+
]);
|
|
1010
|
+
var Triggers = Type.Object(
|
|
1011
|
+
{
|
|
1012
|
+
complexity_threshold: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
1013
|
+
tdd_required: Type.Optional(Type.Boolean()),
|
|
1014
|
+
brainstorming_required: Type.Optional(Type.Boolean())
|
|
1015
|
+
},
|
|
1016
|
+
{ additionalProperties: false }
|
|
1017
|
+
);
|
|
1018
|
+
var ProvidedUnit = Type.Object(
|
|
1019
|
+
{
|
|
1020
|
+
id: Type.String({ minLength: 1 }),
|
|
1021
|
+
// routing-addressable, <org>-<repo>-<unit>
|
|
1022
|
+
component_type: ComponentType
|
|
1023
|
+
// reuse existing union
|
|
1024
|
+
},
|
|
1025
|
+
{ additionalProperties: false }
|
|
1026
|
+
);
|
|
1027
|
+
var SpecSchema = Type.Object(
|
|
1028
|
+
{
|
|
1029
|
+
type: TypeEnum,
|
|
1030
|
+
component_type: ComponentType,
|
|
1031
|
+
install: Type.Unsafe(InstallSchema),
|
|
1032
|
+
verify: Verify,
|
|
1033
|
+
uninstall: Uninstall,
|
|
1034
|
+
upstream_health: UpstreamHealth,
|
|
1035
|
+
signed_by: Type.String({ pattern: "^[a-zA-Z0-9-]+$", minLength: 1 }),
|
|
1036
|
+
signature: Type.Optional(Signature),
|
|
1037
|
+
platforms: Type.Array(Platform, { minItems: 1, uniqueItems: true }),
|
|
1038
|
+
tested_with_versions: Type.Optional(TestedWithVersions),
|
|
1039
|
+
mutually_exclusive_with: Type.Optional(Type.Array(Type.String())),
|
|
1040
|
+
category: Category,
|
|
1041
|
+
install_type: InstallType,
|
|
1042
|
+
decision_rules: Type.Optional(DecisionRules),
|
|
1043
|
+
// ADR 0009 errata (phase 1.5 T5.5) — mattpocock phase routing schema.
|
|
1044
|
+
phase: Type.Optional(Phase),
|
|
1045
|
+
triggers: Type.Optional(Triggers),
|
|
1046
|
+
// ADR 0010 errata (phase 2.1 T1.3) — bundle-install `provides` field.
|
|
1047
|
+
provides: Type.Optional(Type.Array(ProvidedUnit, { minItems: 2, uniqueItems: true }))
|
|
1048
|
+
},
|
|
1049
|
+
{ additionalProperties: false }
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
// src/manifest/schema/index.ts
|
|
1053
|
+
var ManifestBase = Type.Object(
|
|
1054
|
+
{
|
|
1055
|
+
apiVersion: ApiVersion,
|
|
1056
|
+
kind: Kind,
|
|
1057
|
+
metadata: MetadataSchema,
|
|
1058
|
+
spec: SpecSchema
|
|
1059
|
+
},
|
|
1060
|
+
{ additionalProperties: false }
|
|
1061
|
+
);
|
|
1062
|
+
var matrix = {
|
|
1063
|
+
"cc-plugin": ["cc-plugin-marketplace"],
|
|
1064
|
+
"cc-skill-pack": ["cc-plugin-marketplace", "git-clone-with-setup", "npx-skill-installer"],
|
|
1065
|
+
"mcp-npm": ["mcp-stdio-add", "mcp-http-add"],
|
|
1066
|
+
"cli-npm": ["npm-cli"]
|
|
1067
|
+
};
|
|
1068
|
+
var matrixConstraints = Object.entries(matrix).map(([typeValue, allowedMethods]) => ({
|
|
1069
|
+
if: {
|
|
1070
|
+
type: "object",
|
|
1071
|
+
properties: {
|
|
1072
|
+
spec: {
|
|
1073
|
+
type: "object",
|
|
1074
|
+
properties: { type: { const: typeValue } },
|
|
1075
|
+
required: ["type"]
|
|
1076
|
+
}
|
|
1077
|
+
},
|
|
1078
|
+
required: ["spec"]
|
|
1079
|
+
},
|
|
1080
|
+
// biome-ignore lint/suspicious/noThenProperty: JSON Schema `then` keyword (conditional schemas), not a thenable.
|
|
1081
|
+
then: {
|
|
1082
|
+
type: "object",
|
|
1083
|
+
properties: {
|
|
1084
|
+
spec: {
|
|
1085
|
+
type: "object",
|
|
1086
|
+
properties: {
|
|
1087
|
+
install: {
|
|
1088
|
+
type: "object",
|
|
1089
|
+
properties: { method: { enum: allowedMethods } },
|
|
1090
|
+
required: ["method"]
|
|
1091
|
+
}
|
|
1092
|
+
},
|
|
1093
|
+
required: ["install"]
|
|
1094
|
+
}
|
|
1095
|
+
},
|
|
1096
|
+
required: ["spec"]
|
|
1097
|
+
}
|
|
1098
|
+
}));
|
|
1099
|
+
var ManifestSchema = {
|
|
1100
|
+
...ManifestBase,
|
|
1101
|
+
$id: "https://harnessed.dev/schemas/manifest.v1.schema.json",
|
|
1102
|
+
title: "harnessed Manifest v1",
|
|
1103
|
+
description: "Per ADR 0001. Strict mode (additionalProperties: false everywhere).",
|
|
1104
|
+
allOf: matrixConstraints
|
|
1105
|
+
};
|
|
1106
|
+
var PATTERNS = [
|
|
1107
|
+
{
|
|
1108
|
+
label: "$(...)",
|
|
1109
|
+
hint: "POSIX command substitution",
|
|
1110
|
+
test: (s) => /\$\(/.test(s)
|
|
1111
|
+
},
|
|
1112
|
+
{
|
|
1113
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal pattern label, not a JS template
|
|
1114
|
+
label: "${...}",
|
|
1115
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: literal example in user-facing message
|
|
1116
|
+
hint: "variable expansion (v0.2 will whitelist `${secret:KEY}`)",
|
|
1117
|
+
test: (s) => /\$\{/.test(s)
|
|
1118
|
+
},
|
|
1119
|
+
{
|
|
1120
|
+
label: "backtick",
|
|
1121
|
+
hint: "old-style command substitution",
|
|
1122
|
+
test: (s) => /`/.test(s)
|
|
1123
|
+
}
|
|
1124
|
+
];
|
|
1125
|
+
function lineOf(node, lineCounter) {
|
|
1126
|
+
if (!node?.range) return null;
|
|
1127
|
+
const offset = node.range[0];
|
|
1128
|
+
return lineCounter.linePos(offset).line;
|
|
1129
|
+
}
|
|
1130
|
+
function checkCmdString(cmd) {
|
|
1131
|
+
for (const pat of PATTERNS) {
|
|
1132
|
+
if (pat.test(cmd)) return { label: pat.label, hint: pat.hint };
|
|
1133
|
+
}
|
|
1134
|
+
return null;
|
|
1135
|
+
}
|
|
1136
|
+
function checkScalarCmd(doc, lineCounter, path, filename) {
|
|
1137
|
+
const node = doc.getIn(path, true);
|
|
1138
|
+
if (!node || !isScalar(node)) return null;
|
|
1139
|
+
const value = node.value;
|
|
1140
|
+
if (typeof value !== "string") return null;
|
|
1141
|
+
for (const pat of PATTERNS) {
|
|
1142
|
+
if (pat.test(value)) {
|
|
1143
|
+
return {
|
|
1144
|
+
file: filename,
|
|
1145
|
+
path: `/${path.join("/")}`,
|
|
1146
|
+
message: `shell escape detected: '${pat.label}' (${pat.hint}) at ${path.join(".")} \u2014 v0.1 forbids dynamic shell evaluation; v0.2 will use \`requires_secret\` + \`\${secret:KEY}\` for env placeholders`,
|
|
1147
|
+
line: lineOf(node, lineCounter),
|
|
1148
|
+
column: null,
|
|
1149
|
+
keyword: "security"
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return null;
|
|
1154
|
+
}
|
|
1155
|
+
function checkSecurityViolations(doc, filename, lineCounter) {
|
|
1156
|
+
const errors = [];
|
|
1157
|
+
const cmdPaths = [
|
|
1158
|
+
["spec", "install", "cmd"],
|
|
1159
|
+
["spec", "verify", "cmd"],
|
|
1160
|
+
["spec", "uninstall", "cmd"]
|
|
1161
|
+
];
|
|
1162
|
+
for (const p4 of cmdPaths) {
|
|
1163
|
+
const err2 = checkScalarCmd(doc, lineCounter, p4, filename);
|
|
1164
|
+
if (err2) errors.push(err2);
|
|
1165
|
+
}
|
|
1166
|
+
const cleanupNode = doc.getIn(["spec", "uninstall", "cleanup_paths"], true);
|
|
1167
|
+
if (cleanupNode && isSeq(cleanupNode)) {
|
|
1168
|
+
cleanupNode.items.forEach((item, idx) => {
|
|
1169
|
+
if (!isScalar(item)) return;
|
|
1170
|
+
const value = item.value;
|
|
1171
|
+
if (typeof value !== "string") return;
|
|
1172
|
+
for (const pat of PATTERNS) {
|
|
1173
|
+
if (pat.test(value)) {
|
|
1174
|
+
errors.push({
|
|
1175
|
+
file: filename,
|
|
1176
|
+
path: `/spec/uninstall/cleanup_paths/${idx}`,
|
|
1177
|
+
message: `shell escape detected: '${pat.label}' (${pat.hint}) at spec.uninstall.cleanup_paths[${idx}] \u2014 v0.1 forbids dynamic shell evaluation`,
|
|
1178
|
+
line: lineOf(item, lineCounter),
|
|
1179
|
+
column: null,
|
|
1180
|
+
keyword: "security"
|
|
1181
|
+
});
|
|
1182
|
+
break;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
return errors;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// src/manifest/validate.ts
|
|
1191
|
+
var addFormats = ajvFormatsNs.default;
|
|
1192
|
+
var ajv = addFormats(
|
|
1193
|
+
new Ajv({
|
|
1194
|
+
strict: true,
|
|
1195
|
+
strictSchema: true,
|
|
1196
|
+
strictTypes: true,
|
|
1197
|
+
strictRequired: true,
|
|
1198
|
+
allErrors: true,
|
|
1199
|
+
discriminator: true,
|
|
1200
|
+
allowUnionTypes: false
|
|
1201
|
+
})
|
|
1202
|
+
);
|
|
1203
|
+
var _compiled = null;
|
|
1204
|
+
function getValidator() {
|
|
1205
|
+
if (!_compiled) {
|
|
1206
|
+
_compiled = ajv.compile(ManifestSchema);
|
|
1207
|
+
}
|
|
1208
|
+
return _compiled;
|
|
1209
|
+
}
|
|
1210
|
+
var INSTALL_TYPE_METHODS = {
|
|
1211
|
+
npm: ["npm-cli"],
|
|
1212
|
+
mcp: ["mcp-stdio-add", "mcp-http-add"],
|
|
1213
|
+
git: ["git-clone-with-setup"],
|
|
1214
|
+
skill: ["cc-plugin-marketplace", "npx-skill-installer"]
|
|
1215
|
+
};
|
|
1216
|
+
function checkInstallTypeMismatch(manifest, filename) {
|
|
1217
|
+
const spec = manifest.spec;
|
|
1218
|
+
const installType = spec.install_type;
|
|
1219
|
+
const method = spec.install?.method;
|
|
1220
|
+
if (!installType || !method) return [];
|
|
1221
|
+
const allowed = INSTALL_TYPE_METHODS[installType];
|
|
1222
|
+
if (!allowed || allowed.includes(method)) return [];
|
|
1223
|
+
return [
|
|
1224
|
+
{
|
|
1225
|
+
file: filename,
|
|
1226
|
+
path: "spec.install.method",
|
|
1227
|
+
message: `install_type '${installType}' is not compatible with install.method '${method}' (ADR 0007 1:N closure \u2014 expected one of: ${allowed.join(", ")})`,
|
|
1228
|
+
line: null,
|
|
1229
|
+
column: null,
|
|
1230
|
+
keyword: "install-type-mismatch"
|
|
1231
|
+
}
|
|
1232
|
+
];
|
|
1233
|
+
}
|
|
1234
|
+
function validateManifestFile(yamlSource, filename) {
|
|
1235
|
+
const lineCounter = new LineCounter();
|
|
1236
|
+
const doc = parseDocument(yamlSource, { lineCounter });
|
|
1237
|
+
if (doc.errors.length > 0) {
|
|
1238
|
+
return {
|
|
1239
|
+
ok: false,
|
|
1240
|
+
errors: doc.errors.map((e) => yamlParseErrorToFriendly(e, filename))
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
const securityErrors = checkSecurityViolations(doc, filename, lineCounter);
|
|
1244
|
+
if (securityErrors.length > 0) {
|
|
1245
|
+
return { ok: false, errors: securityErrors };
|
|
1246
|
+
}
|
|
1247
|
+
const data = doc.toJS();
|
|
1248
|
+
const validate = getValidator();
|
|
1249
|
+
if (!validate(data)) {
|
|
1250
|
+
const errs = validate.errors ?? [];
|
|
1251
|
+
return {
|
|
1252
|
+
ok: false,
|
|
1253
|
+
errors: errs.map((err2) => ajvErrorToFriendly(err2, filename, doc, lineCounter))
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
const manifest = data;
|
|
1257
|
+
const crossFieldErrors = checkInstallTypeMismatch(manifest, filename);
|
|
1258
|
+
if (crossFieldErrors.length > 0) {
|
|
1259
|
+
return { ok: false, errors: crossFieldErrors };
|
|
1260
|
+
}
|
|
1261
|
+
return { ok: true, manifest };
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// src/cli/lib/audit-helpers.ts
|
|
1265
|
+
init_origin_check();
|
|
1266
|
+
var SHELL_EVAL_MARKERS = /\$\(|\$\{|`/;
|
|
1267
|
+
var NPM_PKG_RE = /npm(?:\s+install\b|\s+i\b)(?:\s+(?:-g|--global))?\s+(\S+)/;
|
|
1268
|
+
var finding = (manifest, level, field, detail) => ({ manifest, level, field, detail });
|
|
1269
|
+
function auditOriginIntegrity(cwd) {
|
|
1270
|
+
const r = checkOrigin(cwd, { allowFork: false });
|
|
1271
|
+
if (r.status === "pass") return [];
|
|
1272
|
+
return [
|
|
1273
|
+
finding("project", r.status === "fail" ? "error" : "warn", "/git/remote/origin", r.detail)
|
|
1274
|
+
];
|
|
1275
|
+
}
|
|
1276
|
+
function auditInstallCmdIntegrity(m) {
|
|
1277
|
+
const out = [];
|
|
1278
|
+
const cmd = m.spec.install.cmd ?? "";
|
|
1279
|
+
if (SHELL_EVAL_MARKERS.test(cmd)) {
|
|
1280
|
+
out.push(
|
|
1281
|
+
finding(
|
|
1282
|
+
m.metadata.name,
|
|
1283
|
+
"error",
|
|
1284
|
+
"/spec/install/cmd",
|
|
1285
|
+
"install.cmd contains shell-eval marker $(/${/backtick (injection risk)"
|
|
1286
|
+
)
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
const upstream = m.metadata.upstream?.repository ?? "";
|
|
1290
|
+
const npmMatch = cmd.match(NPM_PKG_RE);
|
|
1291
|
+
if (npmMatch?.[1] && upstream.includes("github.com/")) {
|
|
1292
|
+
const declared = upstream.split("/").pop()?.replace(".git", "");
|
|
1293
|
+
if (declared && npmMatch[1] !== declared) {
|
|
1294
|
+
out.push(
|
|
1295
|
+
finding(
|
|
1296
|
+
m.metadata.name,
|
|
1297
|
+
"warn",
|
|
1298
|
+
"/spec/install/cmd",
|
|
1299
|
+
`install.cmd npm pkg '${npmMatch[1]}' \u2260 upstream '${declared}'`
|
|
1300
|
+
)
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
return out;
|
|
1305
|
+
}
|
|
1306
|
+
function auditProvenance() {
|
|
1307
|
+
const r = spawnSync("node", ["scripts/check-provenance.mjs"], { encoding: "utf8" });
|
|
1308
|
+
if (r.status === 0) return [];
|
|
1309
|
+
const detail = (r.stderr || r.stdout || "").trim().slice(0, 200);
|
|
1310
|
+
return [finding("project", "error", "/.harnessed/provenance", detail)];
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// src/cli/audit.ts
|
|
1314
|
+
var REPO_URL_PATTERN = /^https:\/\/[^\s]+\.git$/;
|
|
1315
|
+
var SIGNED_BY_PLACEHOLDERS = /* @__PURE__ */ new Set(["unsigned", "todo", "placeholder", "tbd", "unknown"]);
|
|
1316
|
+
var FORBIDDEN_GIT_REFS = /* @__PURE__ */ new Set(["HEAD", "main", "master"]);
|
|
1317
|
+
async function auditOne(yamlPath, preReadSrc) {
|
|
1318
|
+
const findings = [];
|
|
1319
|
+
const src = preReadSrc ?? await readFile(yamlPath, "utf8");
|
|
1320
|
+
const v = validateManifestFile(src, yamlPath);
|
|
1321
|
+
if (!v.ok) {
|
|
1322
|
+
return v.errors.map((e) => ({
|
|
1323
|
+
manifest: yamlPath,
|
|
1324
|
+
level: "error",
|
|
1325
|
+
field: e.path,
|
|
1326
|
+
detail: e.message
|
|
1327
|
+
}));
|
|
1328
|
+
}
|
|
1329
|
+
const m = v.manifest;
|
|
1330
|
+
const repo = m.metadata.upstream.repository;
|
|
1331
|
+
if (!REPO_URL_PATTERN.test(repo)) {
|
|
1332
|
+
findings.push({
|
|
1333
|
+
manifest: yamlPath,
|
|
1334
|
+
level: "warn",
|
|
1335
|
+
field: "/metadata/upstream/repository",
|
|
1336
|
+
detail: `repository '${repo}' should be https://...git`
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
const sig = m.spec.signed_by;
|
|
1340
|
+
if (SIGNED_BY_PLACEHOLDERS.has(sig.toLowerCase())) {
|
|
1341
|
+
findings.push({
|
|
1342
|
+
manifest: yamlPath,
|
|
1343
|
+
level: "warn",
|
|
1344
|
+
field: "/spec/signed_by",
|
|
1345
|
+
detail: `signed_by '${sig}' looks like a placeholder`
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
const install = m.spec.install;
|
|
1349
|
+
if ("git_ref" in install && typeof install.git_ref === "string") {
|
|
1350
|
+
if (FORBIDDEN_GIT_REFS.has(install.git_ref)) {
|
|
1351
|
+
findings.push({
|
|
1352
|
+
manifest: yamlPath,
|
|
1353
|
+
level: "error",
|
|
1354
|
+
field: "/spec/install/git_ref",
|
|
1355
|
+
detail: `git_ref '${install.git_ref}' is a moving ref (HEAD/main/master) \u2014 pin to SHA or tag`
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return findings;
|
|
1360
|
+
}
|
|
1361
|
+
function registerAudit(program2) {
|
|
1362
|
+
program2.command("audit").description("Second-line manifest self-consistency audit (manifest + runtime layers)").option(
|
|
1363
|
+
"--skip-runtime",
|
|
1364
|
+
"skip runtime-layer checks (origin tamper / provenance) \u2014 manifest only"
|
|
1365
|
+
).action(async (opts) => {
|
|
1366
|
+
const root = process.cwd();
|
|
1367
|
+
const dirs = ["manifests/tools", "manifests/skill-packs"];
|
|
1368
|
+
const yamls = [];
|
|
1369
|
+
for (const d of dirs) {
|
|
1370
|
+
try {
|
|
1371
|
+
const entries = await readdir(join(root, d));
|
|
1372
|
+
for (const f of entries) if (f.endsWith(".yaml")) yamls.push(resolve(root, d, f));
|
|
1373
|
+
} catch {
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
yamls.sort();
|
|
1377
|
+
const findings = [];
|
|
1378
|
+
const validManifests = [];
|
|
1379
|
+
for (const y of yamls) {
|
|
1380
|
+
const src = await readFile(y, "utf8");
|
|
1381
|
+
const v = validateManifestFile(src, y);
|
|
1382
|
+
if (v.ok) validManifests.push({ path: y, m: v.manifest });
|
|
1383
|
+
findings.push(...await auditOne(y, src));
|
|
1384
|
+
}
|
|
1385
|
+
if (!opts.skipRuntime) {
|
|
1386
|
+
findings.push(...auditOriginIntegrity(root));
|
|
1387
|
+
for (const { m } of validManifests) findings.push(...auditInstallCmdIntegrity(m));
|
|
1388
|
+
findings.push(...auditProvenance());
|
|
1389
|
+
}
|
|
1390
|
+
const byManifest = /* @__PURE__ */ new Map();
|
|
1391
|
+
for (const f of findings) {
|
|
1392
|
+
const arr = byManifest.get(f.manifest) ?? [];
|
|
1393
|
+
arr.push(f);
|
|
1394
|
+
byManifest.set(f.manifest, arr);
|
|
1395
|
+
}
|
|
1396
|
+
let errorCount = 0;
|
|
1397
|
+
for (const y of yamls) {
|
|
1398
|
+
const fs = byManifest.get(y) ?? [];
|
|
1399
|
+
if (fs.length === 0) {
|
|
1400
|
+
console.log(`\u2713 ${y}`);
|
|
1401
|
+
} else {
|
|
1402
|
+
for (const f of fs) {
|
|
1403
|
+
const mark = f.level === "error" ? "\u2717" : "\u26A0";
|
|
1404
|
+
console.log(`${mark} ${y}
|
|
1405
|
+
${f.field}: ${f.detail}`);
|
|
1406
|
+
if (f.level === "error") errorCount++;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
for (const [mname, fs] of byManifest) {
|
|
1411
|
+
if (yamls.includes(mname)) continue;
|
|
1412
|
+
for (const f of fs) {
|
|
1413
|
+
const mark = f.level === "error" ? "\u2717" : "\u26A0";
|
|
1414
|
+
console.log(`${mark} [${mname}] ${f.field}: ${f.detail}`);
|
|
1415
|
+
if (f.level === "error") errorCount++;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
console.log(
|
|
1419
|
+
`
|
|
1420
|
+
audited ${yamls.length} manifest${yamls.length === 1 ? "" : "s"} \u2014 ${findings.length} finding${findings.length === 1 ? "" : "s"} (${errorCount} error${errorCount === 1 ? "" : "s"})`
|
|
1421
|
+
);
|
|
1422
|
+
process.exit(errorCount > 0 ? 1 : 0);
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
var AUDIT_PATH = ".harnessed/audit.log";
|
|
1426
|
+
var REDACT_PATTERNS = [
|
|
1427
|
+
[/api[_-]?key\s*[:=]\s*\S+/gi, "api_key=[REDACTED]"],
|
|
1428
|
+
[/\btoken\s*[:=]\s*\S+/gi, "token=[REDACTED]"],
|
|
1429
|
+
[/\bpassword\s*[:=]\s*\S+/gi, "password=[REDACTED]"],
|
|
1430
|
+
[/Authorization:\s*Bearer\s+\S+/gi, "Authorization: Bearer [REDACTED]"],
|
|
1431
|
+
[/\b(sk-|pk-|gh_|ghp_|ya29\.|AIza)[A-Za-z0-9_\-.]{4,}/g, "[REDACTED]"]
|
|
1432
|
+
];
|
|
1433
|
+
function redact(s) {
|
|
1434
|
+
return REDACT_PATTERNS.reduce((acc, [re, rep]) => acc.replace(re, rep), s);
|
|
1435
|
+
}
|
|
1436
|
+
function redactRecord(r) {
|
|
1437
|
+
return { ...r, task_excerpt: redact(r.task_excerpt) };
|
|
1438
|
+
}
|
|
1439
|
+
function renderHumanTable(records) {
|
|
1440
|
+
const header = `${"ts".padEnd(19)} | ${"phase".padEnd(6)} | ${"category".padEnd(11)} | ${"matched_rule_id".padEnd(20)} | outcome`;
|
|
1441
|
+
const sep = `${"-".repeat(19)}-+-${"-".repeat(6)}-+-${"-".repeat(11)}-+-${"-".repeat(20)}-+--------`;
|
|
1442
|
+
console.log(header);
|
|
1443
|
+
console.log(sep);
|
|
1444
|
+
for (const r of records) {
|
|
1445
|
+
const ts = r.ts.slice(0, 19);
|
|
1446
|
+
const phase = r.phase.padEnd(6);
|
|
1447
|
+
const cat = r.category.padEnd(11);
|
|
1448
|
+
const rule = (r.matched_rule_id ?? "null").padEnd(20);
|
|
1449
|
+
console.log(`${ts} | ${phase} | ${cat} | ${rule} | ${r.outcome}`);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
function pipeToJq(filterExpr, lines) {
|
|
1453
|
+
return new Promise((resolve8, reject) => {
|
|
1454
|
+
const child = spawn("jq", [filterExpr], {
|
|
1455
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
1456
|
+
windowsHide: true
|
|
1457
|
+
});
|
|
1458
|
+
child.on("error", (err2) => {
|
|
1459
|
+
const e = err2;
|
|
1460
|
+
if (e.code === "ENOENT") {
|
|
1461
|
+
console.error("\u2717 jq not found in PATH \u2014 run: harnessed doctor");
|
|
1462
|
+
resolve8(1);
|
|
1463
|
+
} else {
|
|
1464
|
+
reject(err2);
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
child.on("close", (code) => resolve8(code ?? 0));
|
|
1468
|
+
child.stdin.write(lines.join("\n"));
|
|
1469
|
+
child.stdin.end();
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
function registerAuditLog(program2) {
|
|
1473
|
+
program2.command("audit-log").description("Query routing audit log (.harnessed/audit.log) with optional jq filter (R10.1)").option("--filter <expr>", `jq filter expression (e.g. '.category=="engineering"')`).option("--tail <n>", "show N most recent records (default 50)", "50").option("--head <n>", "show N oldest records (--head takes priority over --tail)").option("--reverse", "flip output order").option("--json", "output full 12-field JSON instead of human table").action(
|
|
1474
|
+
async (opts) => {
|
|
1475
|
+
const tailN = opts.tail !== void 0 ? Number(opts.tail) : 50;
|
|
1476
|
+
if (Number.isNaN(tailN) || tailN < 1) {
|
|
1477
|
+
console.error("\u2717 --tail must be a positive integer");
|
|
1478
|
+
process.exit(2);
|
|
1479
|
+
}
|
|
1480
|
+
const headN = opts.head !== void 0 ? Number(opts.head) : void 0;
|
|
1481
|
+
if (headN !== void 0 && (Number.isNaN(headN) || headN < 1)) {
|
|
1482
|
+
console.error("\u2717 --head must be a positive integer");
|
|
1483
|
+
process.exit(2);
|
|
1484
|
+
}
|
|
1485
|
+
if (!existsSync(AUDIT_PATH)) {
|
|
1486
|
+
console.log("no audit records found (.harnessed/audit.log does not exist)");
|
|
1487
|
+
process.exit(0);
|
|
1488
|
+
}
|
|
1489
|
+
const raw = readFileSync(AUDIT_PATH, "utf8");
|
|
1490
|
+
const lines = raw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
1491
|
+
if (lines.length === 0) {
|
|
1492
|
+
console.log("no audit records found (audit.log is empty)");
|
|
1493
|
+
process.exit(0);
|
|
1494
|
+
}
|
|
1495
|
+
let records = [];
|
|
1496
|
+
for (const line of lines) {
|
|
1497
|
+
try {
|
|
1498
|
+
records.push(JSON.parse(line));
|
|
1499
|
+
} catch {
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
if (headN !== void 0) {
|
|
1503
|
+
records = records.slice(0, headN);
|
|
1504
|
+
} else {
|
|
1505
|
+
records = records.slice(-tailN);
|
|
1506
|
+
}
|
|
1507
|
+
if (opts.reverse) records = records.reverse();
|
|
1508
|
+
records = records.map(redactRecord);
|
|
1509
|
+
if (opts.filter) {
|
|
1510
|
+
const redactedLines = records.map((r) => JSON.stringify(r));
|
|
1511
|
+
const exitCode = await pipeToJq(opts.filter, redactedLines);
|
|
1512
|
+
process.exit(exitCode);
|
|
1513
|
+
}
|
|
1514
|
+
if (opts.json) {
|
|
1515
|
+
for (const r of records) {
|
|
1516
|
+
console.log(JSON.stringify(r, null, 2));
|
|
1517
|
+
}
|
|
1518
|
+
} else {
|
|
1519
|
+
renderHumanTable(records);
|
|
1520
|
+
}
|
|
1521
|
+
process.exit(0);
|
|
1522
|
+
}
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
function registerBackupList(program2) {
|
|
1526
|
+
const backup2 = program2.command("backup").description("Backup snapshot operations");
|
|
1527
|
+
backup2.command("list").description("List backup snapshots under .harnessed-backup/").action(async () => {
|
|
1528
|
+
const root = resolve(process.cwd(), ".harnessed-backup");
|
|
1529
|
+
let dirs;
|
|
1530
|
+
try {
|
|
1531
|
+
dirs = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
1532
|
+
} catch {
|
|
1533
|
+
console.log("no backups found (.harnessed-backup/ absent)");
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
if (dirs.length === 0) {
|
|
1537
|
+
console.log("no backups found (.harnessed-backup/ empty)");
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
for (const ts of dirs) {
|
|
1541
|
+
try {
|
|
1542
|
+
const meta = JSON.parse(
|
|
1543
|
+
await readFile(join(root, ts, "metadata.json"), "utf8")
|
|
1544
|
+
);
|
|
1545
|
+
console.log(
|
|
1546
|
+
`${ts} ${meta.manifest} (${meta.files.length} file${meta.files.length === 1 ? "" : "s"})`
|
|
1547
|
+
);
|
|
1548
|
+
} catch {
|
|
1549
|
+
console.log(`${ts} (metadata.json missing or unreadable)`);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
console.log(`
|
|
1553
|
+
${dirs.length} snapshot${dirs.length === 1 ? "" : "s"} total`);
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
function checkNodeVersion() {
|
|
1557
|
+
const v = process.versions.node;
|
|
1558
|
+
const major = Number.parseInt(v.split(".")[0] ?? "0", 10);
|
|
1559
|
+
return major >= 22 ? { name: "node \u2265 22", status: "pass", message: `node ${v}` } : {
|
|
1560
|
+
name: "node \u2265 22",
|
|
1561
|
+
status: "fail",
|
|
1562
|
+
message: `node ${v} (need \u2265 22)`,
|
|
1563
|
+
fix: "nvm install 22 && nvm use 22"
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
async function checkMcpScope() {
|
|
1567
|
+
const projectMcp = join(process.cwd(), ".mcp.json");
|
|
1568
|
+
const userClaude = join(homedir(), ".claude.json");
|
|
1569
|
+
let projectExists = false;
|
|
1570
|
+
try {
|
|
1571
|
+
await readFile(projectMcp, "utf8");
|
|
1572
|
+
projectExists = true;
|
|
1573
|
+
} catch {
|
|
1574
|
+
}
|
|
1575
|
+
let userHasMcp = false;
|
|
1576
|
+
try {
|
|
1577
|
+
const raw = await readFile(userClaude, "utf8");
|
|
1578
|
+
const parsed = JSON.parse(raw);
|
|
1579
|
+
userHasMcp = !!parsed.mcpServers && Object.keys(parsed.mcpServers).length > 0;
|
|
1580
|
+
} catch {
|
|
1581
|
+
}
|
|
1582
|
+
if (userHasMcp) {
|
|
1583
|
+
return {
|
|
1584
|
+
name: "mcp scope = project",
|
|
1585
|
+
status: "fail",
|
|
1586
|
+
message: `~/.claude.json has user-scope mcpServers (CC #54803 risk)`,
|
|
1587
|
+
fix: "remove user-scope entries; re-add via `claude mcp add --scope project ...`"
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
return {
|
|
1591
|
+
name: "mcp scope = project",
|
|
1592
|
+
status: "pass",
|
|
1593
|
+
message: projectExists ? "project .mcp.json present" : "no MCP servers installed"
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
function checkJq() {
|
|
1597
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
1598
|
+
const r = spawnSync(finder, ["jq"], { encoding: "utf8" });
|
|
1599
|
+
if (r.status === 0 && r.stdout.trim().length > 0) {
|
|
1600
|
+
return {
|
|
1601
|
+
name: "jq present",
|
|
1602
|
+
status: "pass",
|
|
1603
|
+
message: r.stdout.split(/\r?\n/)[0]?.trim() ?? "jq found"
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
const fix = process.platform === "win32" ? "winget install jqlang.jq (or: scoop install jq)" : process.platform === "darwin" ? "brew install jq" : "apt-get install jq (or: dnf install jq)";
|
|
1607
|
+
return { name: "jq present", status: "fail", message: "jq not found in PATH", fix };
|
|
1608
|
+
}
|
|
1609
|
+
function checkWinBash() {
|
|
1610
|
+
if (process.platform !== "win32") {
|
|
1611
|
+
return { name: "bash flavor (win)", status: "pass", message: "skipped (non-Windows)" };
|
|
1612
|
+
}
|
|
1613
|
+
const where = spawnSync("where", ["bash"], { encoding: "utf8" });
|
|
1614
|
+
const firstBash = (where.stdout ?? "").split(/\r?\n/)[0]?.trim() ?? "(not found)";
|
|
1615
|
+
if (where.status !== 0 || !firstBash || firstBash === "(not found)") {
|
|
1616
|
+
return {
|
|
1617
|
+
name: "bash flavor (win)",
|
|
1618
|
+
status: "fail",
|
|
1619
|
+
message: "no bash on PATH",
|
|
1620
|
+
fix: "install Git for Windows (Git Bash) and ensure it is on PATH"
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
const probe = spawnSync("bash", ["-c", "echo $WSL_DISTRO_NAME"], { encoding: "utf8" });
|
|
1624
|
+
const distro = (probe.stdout ?? "").trim();
|
|
1625
|
+
if (distro.length > 0) {
|
|
1626
|
+
return {
|
|
1627
|
+
name: "bash flavor (win)",
|
|
1628
|
+
status: "fail",
|
|
1629
|
+
message: `WSL bash (${distro}) \u2014 ralph-loop subagent fork breaks under WSL`,
|
|
1630
|
+
fix: "reorder PATH so Git Bash precedes WSL bash.exe (Settings \u2192 System \u2192 Environment Variables)"
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
return { name: "bash flavor (win)", status: "pass", message: `${firstBash} (Git Bash / native)` };
|
|
1634
|
+
}
|
|
1635
|
+
async function checkOriginUrl() {
|
|
1636
|
+
const { checkOrigin: checkOrigin2 } = await Promise.resolve().then(() => (init_origin_check(), origin_check_exports));
|
|
1637
|
+
const r = checkOrigin2(process.cwd(), { allowFork: true });
|
|
1638
|
+
return { name: "origin URL", status: r.status, message: r.detail, fix: r.fix };
|
|
1639
|
+
}
|
|
1640
|
+
async function checkGstackPrefix() {
|
|
1641
|
+
const { probeGstackPrefix: probeGstackPrefix2 } = await Promise.resolve().then(() => (init_probe_gstack(), probe_gstack_exports));
|
|
1642
|
+
const r = probeGstackPrefix2();
|
|
1643
|
+
return { name: "gstack prefix", status: r.status, message: r.detail, fix: r.fix };
|
|
1644
|
+
}
|
|
1645
|
+
async function checkDeprecations2() {
|
|
1646
|
+
return (await Promise.resolve().then(() => (init_check_deprecations(), check_deprecations_exports))).checkDeprecations();
|
|
1647
|
+
}
|
|
1648
|
+
async function checkTokenBudget2() {
|
|
1649
|
+
return (await Promise.resolve().then(() => (init_check_token_budget(), check_token_budget_exports))).checkTokenBudget();
|
|
1650
|
+
}
|
|
1651
|
+
function registerDoctor(program2) {
|
|
1652
|
+
program2.command("doctor").description(
|
|
1653
|
+
"Preflight checks (Node / MCP scope / jq / Win bash / origin URL / gstack prefix / deprecations / token budget)"
|
|
1654
|
+
).option("--json", "output JSON instead of human-readable").action(async (opts) => {
|
|
1655
|
+
const [mcpScope, originUrl, gstackPrefix, deprecations, tokenBudget] = await Promise.all([
|
|
1656
|
+
checkMcpScope(),
|
|
1657
|
+
checkOriginUrl(),
|
|
1658
|
+
checkGstackPrefix(),
|
|
1659
|
+
// ← Phase 3.2 W1 T1.5 ADD 6th check (D-01 PROBE)
|
|
1660
|
+
checkDeprecations2(),
|
|
1661
|
+
// ← Phase 3.3 W1 T1.7 ADD 7th check (D-02 DOCTOR-ONLY-WARN)
|
|
1662
|
+
checkTokenBudget2()
|
|
1663
|
+
// ← Phase 3.4 W1 T1.2 ADD 8th check (D-03 + D-04 DOCTOR WARN)
|
|
1664
|
+
]);
|
|
1665
|
+
const results = [
|
|
1666
|
+
checkNodeVersion(),
|
|
1667
|
+
mcpScope,
|
|
1668
|
+
checkJq(),
|
|
1669
|
+
checkWinBash(),
|
|
1670
|
+
originUrl,
|
|
1671
|
+
gstackPrefix,
|
|
1672
|
+
deprecations,
|
|
1673
|
+
tokenBudget
|
|
1674
|
+
];
|
|
1675
|
+
const hasFail = results.some((r) => r.status === "fail");
|
|
1676
|
+
const hasWarn = results.some((r) => r.status === "warn");
|
|
1677
|
+
if (opts.json) {
|
|
1678
|
+
console.log(
|
|
1679
|
+
JSON.stringify(
|
|
1680
|
+
{ checks: results, summary: hasFail ? "fail" : hasWarn ? "warn" : "pass" },
|
|
1681
|
+
null,
|
|
1682
|
+
2
|
|
1683
|
+
)
|
|
1684
|
+
);
|
|
1685
|
+
} else {
|
|
1686
|
+
for (const r of results) {
|
|
1687
|
+
const mark = r.status === "pass" ? "\u2713" : r.status === "warn" ? "\u26A0" : "\u2717";
|
|
1688
|
+
console.log(`${mark} ${r.name} \u2014 ${r.message}`);
|
|
1689
|
+
if (r.status !== "pass" && r.fix) console.log(` fix: ${r.fix}`);
|
|
1690
|
+
}
|
|
1691
|
+
console.log(
|
|
1692
|
+
hasFail ? "\nsome checks failed (see fix hints above)" : hasWarn ? "\nall checks ok (with warnings \u2014 see hints above)" : "\nall checks passed"
|
|
1693
|
+
);
|
|
1694
|
+
}
|
|
1695
|
+
process.exit(hasFail ? 1 : 0);
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// src/routing/completionSchema.ts
|
|
1700
|
+
var COMPLETION_SCHEMA = {
|
|
1701
|
+
type: "object",
|
|
1702
|
+
properties: {
|
|
1703
|
+
status: { type: "string", enum: ["COMPLETE", "PARTIAL", "BLOCKED"] },
|
|
1704
|
+
phase: { type: "string", enum: ["01-clarify", "02-code", "03-test", "04-deliver"] },
|
|
1705
|
+
summary: { type: "string" },
|
|
1706
|
+
blockers: { type: "array", items: { type: "string" } }
|
|
1707
|
+
},
|
|
1708
|
+
required: ["status", "phase"]
|
|
1709
|
+
};
|
|
1710
|
+
|
|
1711
|
+
// src/routing/systemPrompt.ts
|
|
1712
|
+
`## RULE: subagent COMPLETE marker
|
|
1713
|
+
When you spawn a subagent and it returns a final message:
|
|
1714
|
+
1. **DO NOT summarize, paraphrase, or interpret the subagent's final message**
|
|
1715
|
+
2. **DO NOT skip or omit the COMPLETE marker**
|
|
1716
|
+
3. The subagent will emit \`<promise>COMPLETE</promise>\` (exact verbatim XML wrapper) when done
|
|
1717
|
+
4. You MUST verbatim grep \`<promise>COMPLETE</promise>\` from final message \u2192 if present, treat task as done
|
|
1718
|
+
5. If \`<promise>COMPLETE</promise>\` absent \u2192 re-spawn subagent (max 20 iterations); after max, throw VerbatimCompleteFailError
|
|
1719
|
+
|
|
1720
|
+
## skills fail-fast handling
|
|
1721
|
+
- SkillNotInstalledError \u2192 print user-friendly fix command (e.g., "Run: harnessed install <name>")
|
|
1722
|
+
- RestartRequiredError \u2192 print "\u8BF7 exit + restart Claude Code \u8BA9 plugin \u751F\u6548"
|
|
1723
|
+
|
|
1724
|
+
## \u515C\u5E95 max-iterations
|
|
1725
|
+
- max-iterations 20 (external ralph-loop) \xD7 maxTurns 50 (internal AgentDefinition) = 1000 round-trips worst case
|
|
1726
|
+
|
|
1727
|
+
## Completion signal (dual-signal \u2014 emit BOTH)
|
|
1728
|
+
If \`outputFormat: { type: 'json_schema' }\` is set on this query, emit a final-turn output conforming to the schema (status/phase/summary/blockers \u2014 keys: ${Object.keys(COMPLETION_SCHEMA.properties).join(", ")}).
|
|
1729
|
+
AND emit \`<promise>COMPLETE</promise>\` (FALLBACK signal \u2014 required regardless of structured output presence, for inner-layer subagent completion detection per RESEARCH \xA7 1.3).
|
|
1730
|
+
`;
|
|
1731
|
+
var COMPLETE_INSTRUCTION = `## CRITICAL RULE: COMPLETE marker
|
|
1732
|
+
When your task is done, emit the exact verbatim XML wrapper
|
|
1733
|
+
\`<promise>COMPLETE</promise>\` on its own line and nothing else after it. Do
|
|
1734
|
+
NOT emit any other variant \u2014 not "completed", not "DONE", not bare \`COMPLETE\`,
|
|
1735
|
+
not "\u2705". The parent agent will verbatim grep \`<promise>COMPLETE</promise>\` \u2014
|
|
1736
|
+
any deviation fails the round-trip and forces a re-spawn (max 20 iterations).
|
|
1737
|
+
`;
|
|
1738
|
+
|
|
1739
|
+
// src/routing/agentDefinition.ts
|
|
1740
|
+
var SkillNotInstalledError = class extends Error {
|
|
1741
|
+
constructor(skill) {
|
|
1742
|
+
super(`Skill not installed: ${skill}. Run: harnessed install ${skill} --apply`);
|
|
1743
|
+
this.skill = skill;
|
|
1744
|
+
this.name = "SkillNotInstalledError";
|
|
1745
|
+
}
|
|
1746
|
+
skill;
|
|
1747
|
+
};
|
|
1748
|
+
var InvalidDecisionError = class extends Error {
|
|
1749
|
+
constructor(message) {
|
|
1750
|
+
super(message);
|
|
1751
|
+
this.name = "InvalidDecisionError";
|
|
1752
|
+
}
|
|
1753
|
+
};
|
|
1754
|
+
var MissingSkillsError = class extends Error {
|
|
1755
|
+
constructor(missing) {
|
|
1756
|
+
super(`Required skills missing: ${missing.join(", ")}`);
|
|
1757
|
+
this.missing = missing;
|
|
1758
|
+
this.name = "MissingSkillsError";
|
|
1759
|
+
}
|
|
1760
|
+
missing;
|
|
1761
|
+
};
|
|
1762
|
+
var RestartRequiredError = class extends Error {
|
|
1763
|
+
constructor(hint) {
|
|
1764
|
+
super(hint);
|
|
1765
|
+
this.hint = hint;
|
|
1766
|
+
this.name = "RestartRequiredError";
|
|
1767
|
+
}
|
|
1768
|
+
hint;
|
|
1769
|
+
};
|
|
1770
|
+
var KARPATHY_BASELINE = `## \u5FC3\u6CD5 (always-on baseline)
|
|
1771
|
+
- Think Before Coding: read, plan, then write \u2014 never write before understanding.
|
|
1772
|
+
- Simplicity First: minimum effective code; avoid unnecessary abstraction.
|
|
1773
|
+
- Surgical Changes: small atomic edits; keep history clean.
|
|
1774
|
+
- Goal-Driven Execution: each step earns its place by satisfying a goal.
|
|
1775
|
+
`;
|
|
1776
|
+
async function createAgent(task, decision, opts = {}) {
|
|
1777
|
+
if (!decision.primary_expert) {
|
|
1778
|
+
throw new InvalidDecisionError("decision.primary_expert is required (got null)");
|
|
1779
|
+
}
|
|
1780
|
+
const skillsRoot = opts.skillsRoot ?? join(homedir(), ".claude", "skills");
|
|
1781
|
+
const requested = decision.required_skills ?? (decision.primary_expert ? [decision.primary_expert] : []);
|
|
1782
|
+
const isInstalled2 = (name) => existsSync(join(skillsRoot, name, "SKILL.md"));
|
|
1783
|
+
const missing = requested.filter((s) => !isInstalled2(s));
|
|
1784
|
+
if (missing.length === requested.length && requested.length > 0) {
|
|
1785
|
+
throw new MissingSkillsError(missing);
|
|
1786
|
+
}
|
|
1787
|
+
if (missing.length > 0) {
|
|
1788
|
+
throw new SkillNotInstalledError(missing[0]);
|
|
1789
|
+
}
|
|
1790
|
+
const promptBody = `${KARPATHY_BASELINE}
|
|
1791
|
+
## \u4EFB\u52A1
|
|
1792
|
+
${task.task}
|
|
1793
|
+
|
|
1794
|
+
${COMPLETE_INSTRUCTION}`;
|
|
1795
|
+
return {
|
|
1796
|
+
description: `Routing-engine spawned subagent (${decision.category}) \u2014 primary: ${decision.primary_expert}`,
|
|
1797
|
+
prompt: promptBody,
|
|
1798
|
+
tools: ["Read", "Grep", "Glob", "Bash", "Edit", "Write"],
|
|
1799
|
+
disallowedTools: decision.forbidden_skills?.map((s) => `Skill(${s})`),
|
|
1800
|
+
model: opts.modelOverride ?? process.env.HARNESSED_AGENT_MODEL ?? "inherit",
|
|
1801
|
+
skills: requested,
|
|
1802
|
+
mcpServers: void 0,
|
|
1803
|
+
memory: "project",
|
|
1804
|
+
maxTurns: opts.maxTurnsOverride ?? 50,
|
|
1805
|
+
background: false,
|
|
1806
|
+
effort: opts.effortOverride ?? "medium",
|
|
1807
|
+
permissionMode: opts.permissionModeOverride ?? "default",
|
|
1808
|
+
// v1.1 errata (ADR 0009 § Decision Errata 1) — `initialPrompt` carries the
|
|
1809
|
+
// task body for the plugin-main-thread upgrade path;
|
|
1810
|
+
// `criticalSystemReminder_EXPERIMENTAL` re-asserts the verbatim
|
|
1811
|
+
// <promise>COMPLETE</promise> contract at the system-prompt layer.
|
|
1812
|
+
initialPrompt: task.task,
|
|
1813
|
+
criticalSystemReminder_EXPERIMENTAL: COMPLETE_INSTRUCTION
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
// src/routing/dag.ts
|
|
1818
|
+
function resolveDag(nodes) {
|
|
1819
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
1820
|
+
const adj = /* @__PURE__ */ new Map();
|
|
1821
|
+
const indegree = /* @__PURE__ */ new Map();
|
|
1822
|
+
for (const id of ids) {
|
|
1823
|
+
adj.set(id, []);
|
|
1824
|
+
indegree.set(id, 0);
|
|
1825
|
+
}
|
|
1826
|
+
for (const node of nodes) {
|
|
1827
|
+
for (const dep of node.deps) {
|
|
1828
|
+
if (!ids.has(dep)) continue;
|
|
1829
|
+
adj.get(dep)?.push(node.id);
|
|
1830
|
+
indegree.set(node.id, (indegree.get(node.id) ?? 0) + 1);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
const queue = [];
|
|
1834
|
+
for (const [id, deg] of indegree) {
|
|
1835
|
+
if (deg === 0) queue.push(id);
|
|
1836
|
+
}
|
|
1837
|
+
queue.sort();
|
|
1838
|
+
const order = [];
|
|
1839
|
+
while (queue.length > 0) {
|
|
1840
|
+
const id = queue.shift();
|
|
1841
|
+
order.push(id);
|
|
1842
|
+
let exposed = false;
|
|
1843
|
+
for (const dependent of adj.get(id) ?? []) {
|
|
1844
|
+
const next = (indegree.get(dependent) ?? 0) - 1;
|
|
1845
|
+
indegree.set(dependent, next);
|
|
1846
|
+
if (next === 0) {
|
|
1847
|
+
queue.push(dependent);
|
|
1848
|
+
exposed = true;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
if (exposed) queue.sort();
|
|
1852
|
+
}
|
|
1853
|
+
if (order.length !== ids.size) {
|
|
1854
|
+
const cycle = [];
|
|
1855
|
+
for (const [id, deg] of indegree) {
|
|
1856
|
+
if (deg > 0) cycle.push(id);
|
|
1857
|
+
}
|
|
1858
|
+
cycle.sort();
|
|
1859
|
+
return { ok: false, cycle };
|
|
1860
|
+
}
|
|
1861
|
+
return { ok: true, order };
|
|
1862
|
+
}
|
|
1863
|
+
var Domain = Type.Union([
|
|
1864
|
+
Type.Literal("meta"),
|
|
1865
|
+
Type.Literal("engineering"),
|
|
1866
|
+
Type.Literal("design"),
|
|
1867
|
+
Type.Literal("content"),
|
|
1868
|
+
Type.Literal("testing"),
|
|
1869
|
+
Type.Literal("search")
|
|
1870
|
+
]);
|
|
1871
|
+
var HitPolicy = Type.Union([Type.Literal("P"), Type.Literal("F"), Type.Literal("U")]);
|
|
1872
|
+
var Str1 = Type.String({ minLength: 1 });
|
|
1873
|
+
var ObjStrict = (p4) => Type.Object(p4, { additionalProperties: false });
|
|
1874
|
+
var RuleSchema = ObjStrict({
|
|
1875
|
+
id: Str1,
|
|
1876
|
+
priority: Type.Integer({ minimum: 0 }),
|
|
1877
|
+
domain: Domain,
|
|
1878
|
+
when: Type.Record(Type.String(), Type.Unknown()),
|
|
1879
|
+
decision: Type.Record(Type.String(), Type.Unknown())
|
|
1880
|
+
});
|
|
1881
|
+
var PhaseEntrySchema = ObjStrict({
|
|
1882
|
+
skills: Type.Array(Str1, { minItems: 1 }),
|
|
1883
|
+
triggers: Type.Array(Str1, { minItems: 1 })
|
|
1884
|
+
});
|
|
1885
|
+
var MattpocockPhasesSchema = ObjStrict({
|
|
1886
|
+
discuss: PhaseEntrySchema,
|
|
1887
|
+
plan: PhaseEntrySchema,
|
|
1888
|
+
execute: PhaseEntrySchema,
|
|
1889
|
+
verify: PhaseEntrySchema
|
|
1890
|
+
});
|
|
1891
|
+
var DecisionRulesFileSchema = ObjStrict({
|
|
1892
|
+
// v2 additive (D1.5-10): accept v1 OR v2 — schema stays backward compatible.
|
|
1893
|
+
version: Type.Union([Type.Literal(1), Type.Literal(2)]),
|
|
1894
|
+
hit_policy: HitPolicy,
|
|
1895
|
+
rules: Type.Array(RuleSchema, { minItems: 1 }),
|
|
1896
|
+
// v2 — optional mattpocock_phases map; absent in v1 files.
|
|
1897
|
+
mattpocock_phases: Type.Optional(MattpocockPhasesSchema),
|
|
1898
|
+
fallback_supervisor: Type.Optional(ObjStrict({ trigger: Str1, llm: Str1 })),
|
|
1899
|
+
deprecated: Type.Optional(Type.Array(ObjStrict({ id: Str1, reason: Str1, fallback: Str1 })))
|
|
1900
|
+
});
|
|
1901
|
+
var ajv2 = new Ajv({ strict: true, allErrors: true, allowUnionTypes: false });
|
|
1902
|
+
var _compiled2 = null;
|
|
1903
|
+
function getValidator2() {
|
|
1904
|
+
if (!_compiled2) _compiled2 = ajv2.compile(DecisionRulesFileSchema);
|
|
1905
|
+
return _compiled2;
|
|
1906
|
+
}
|
|
1907
|
+
function scanShellInjection(node, path = "") {
|
|
1908
|
+
if (typeof node === "string") {
|
|
1909
|
+
const hit = checkCmdString(node);
|
|
1910
|
+
return hit ? `${path || "<root>"}: ${hit.label} (${hit.hint})` : null;
|
|
1911
|
+
}
|
|
1912
|
+
if (Array.isArray(node)) {
|
|
1913
|
+
for (let i = 0; i < node.length; i++) {
|
|
1914
|
+
const v = scanShellInjection(node[i], `${path}[${i}]`);
|
|
1915
|
+
if (v) return v;
|
|
1916
|
+
}
|
|
1917
|
+
return null;
|
|
1918
|
+
}
|
|
1919
|
+
if (node && typeof node === "object") {
|
|
1920
|
+
for (const [k, v] of Object.entries(node)) {
|
|
1921
|
+
const hit = scanShellInjection(v, path ? `${path}.${k}` : k);
|
|
1922
|
+
if (hit) return hit;
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
return null;
|
|
1926
|
+
}
|
|
1927
|
+
function loadDecisionRules(yamlPath) {
|
|
1928
|
+
const doc = parseDocument(readFileSync(yamlPath, "utf8"));
|
|
1929
|
+
if (doc.errors.length > 0) {
|
|
1930
|
+
throw new Error(`decision_rules yaml parse error: ${doc.errors[0]?.message}`);
|
|
1931
|
+
}
|
|
1932
|
+
const data = doc.toJS();
|
|
1933
|
+
const validate = getValidator2();
|
|
1934
|
+
if (!validate(data)) {
|
|
1935
|
+
const e = validate.errors?.[0];
|
|
1936
|
+
throw new Error(`decision_rules schema invalid: ${e?.instancePath || "/"} ${e?.message}`);
|
|
1937
|
+
}
|
|
1938
|
+
const inj = scanShellInjection(data);
|
|
1939
|
+
if (inj) throw new Error(`decision_rules security violation: ${inj}`);
|
|
1940
|
+
return data;
|
|
1941
|
+
}
|
|
1942
|
+
function arbitrate(rules, task) {
|
|
1943
|
+
const matches = rules.filter((r) => matchesWhen(r.when, task));
|
|
1944
|
+
const [top, second] = [...matches].sort((a, b) => b.priority - a.priority);
|
|
1945
|
+
if (!top) return null;
|
|
1946
|
+
if (second && second.priority === top.priority) return null;
|
|
1947
|
+
return top;
|
|
1948
|
+
}
|
|
1949
|
+
var ARRAY_TRIGGER_FIELDS = /* @__PURE__ */ new Set(["keywords", "signals", "override_keywords"]);
|
|
1950
|
+
function taskHas(task, needle, extraKey) {
|
|
1951
|
+
const n = needle.toLowerCase();
|
|
1952
|
+
const prompt = typeof task.prompt === "string" ? task.prompt.toLowerCase() : "";
|
|
1953
|
+
if (prompt.includes(n)) return true;
|
|
1954
|
+
const sources = [];
|
|
1955
|
+
if (Array.isArray(task.signals)) sources.push(...task.signals);
|
|
1956
|
+
if (extraKey && extraKey !== "signals" && Array.isArray(task[extraKey])) {
|
|
1957
|
+
sources.push(...task[extraKey]);
|
|
1958
|
+
}
|
|
1959
|
+
return sources.some((s) => typeof s === "string" && s.toLowerCase().includes(n));
|
|
1960
|
+
}
|
|
1961
|
+
function matchesWhen(when, task) {
|
|
1962
|
+
for (const [k, v] of Object.entries(when)) {
|
|
1963
|
+
if (ARRAY_TRIGGER_FIELDS.has(k) && Array.isArray(v)) {
|
|
1964
|
+
if (!v.some((kw) => typeof kw === "string" && taskHas(task, kw, k))) return false;
|
|
1965
|
+
continue;
|
|
1966
|
+
}
|
|
1967
|
+
if (Array.isArray(v)) {
|
|
1968
|
+
if (!v.includes(task[k])) return false;
|
|
1969
|
+
continue;
|
|
1970
|
+
}
|
|
1971
|
+
if (task[k] !== v) return false;
|
|
1972
|
+
}
|
|
1973
|
+
return true;
|
|
1974
|
+
}
|
|
1975
|
+
var AUDIT_PATH2 = ".harnessed/audit.log";
|
|
1976
|
+
Type.Object(
|
|
1977
|
+
{
|
|
1978
|
+
ts: Type.String(),
|
|
1979
|
+
phase: Type.String(),
|
|
1980
|
+
task_excerpt: Type.String(),
|
|
1981
|
+
task_sha1: Type.String(),
|
|
1982
|
+
matched_rule_id: Type.Union([Type.String(), Type.Null()]),
|
|
1983
|
+
primary_expert: Type.Union([Type.String(), Type.Null()]),
|
|
1984
|
+
secondary_expert: Type.Union([Type.String(), Type.Null()]),
|
|
1985
|
+
category: Type.String(),
|
|
1986
|
+
route_layer: Type.String(),
|
|
1987
|
+
outcome: Type.String(),
|
|
1988
|
+
session_id: Type.Union([Type.String(), Type.Null()]),
|
|
1989
|
+
iter_count: Type.Union([Type.Number(), Type.Null()])
|
|
1990
|
+
},
|
|
1991
|
+
{ additionalProperties: false }
|
|
1992
|
+
);
|
|
1993
|
+
function buildAuditRecord(task, decision, matched, ctx) {
|
|
1994
|
+
return {
|
|
1995
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1996
|
+
phase: task.phaseId ?? "unknown",
|
|
1997
|
+
task_excerpt: task.task.slice(0, 200),
|
|
1998
|
+
task_sha1: createHash("sha1").update(task.task).digest("hex"),
|
|
1999
|
+
matched_rule_id: matched?.id ?? null,
|
|
2000
|
+
primary_expert: decision.primary_expert ?? null,
|
|
2001
|
+
secondary_expert: decision.secondary_expert ?? null,
|
|
2002
|
+
category: decision.category,
|
|
2003
|
+
route_layer: ctx.routeLayer,
|
|
2004
|
+
outcome: ctx.outcome,
|
|
2005
|
+
session_id: ctx.sessionId ?? null,
|
|
2006
|
+
iter_count: ctx.iterCount
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
function emitAuditRecord(record) {
|
|
2010
|
+
mkdirSync(dirname(AUDIT_PATH2), { recursive: true });
|
|
2011
|
+
appendFileSync(AUDIT_PATH2, `${JSON.stringify(record)}
|
|
2012
|
+
`);
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
// src/audit/hook.ts
|
|
2016
|
+
function emitAudit(task, decision, matched, outcome, sessionId) {
|
|
2017
|
+
emitAuditRecord(
|
|
2018
|
+
buildAuditRecord(task, decision, matched, {
|
|
2019
|
+
outcome,
|
|
2020
|
+
routeLayer: matched ? "L1-keyword" : "L3-fallback",
|
|
2021
|
+
sessionId,
|
|
2022
|
+
iterCount: null
|
|
2023
|
+
// Phase 4.3 YAGNI (ralphLoopWrap returns string only; defer v0.5+ per RESEARCH § 7 Q2)
|
|
2024
|
+
})
|
|
2025
|
+
);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
// src/checkpoint/engineHook.ts
|
|
2029
|
+
init_schemaVersion();
|
|
2030
|
+
init_state();
|
|
2031
|
+
init_template();
|
|
2032
|
+
async function activatePhase(phaseId) {
|
|
2033
|
+
const checkpointPath = `.harnessed/checkpoints/${phaseId}.json`;
|
|
2034
|
+
await activate(phaseId, checkpointPath);
|
|
2035
|
+
return { checkpointPath };
|
|
2036
|
+
}
|
|
2037
|
+
async function completePhase(ctx) {
|
|
2038
|
+
if (ctx.phaseId === "unknown") {
|
|
2039
|
+
console.error(
|
|
2040
|
+
'[harnessed] WARN engineHook: phaseId="unknown" \u2014 checkpoint paths fall back to .harnessed/checkpoints/unknown.json (Karpathy fail-loud non-blocking; W-04 mitigation)'
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
2043
|
+
writeCheckpoint({
|
|
2044
|
+
schemaVersion: SCHEMA_VERSIONS.checkpoint,
|
|
2045
|
+
phase: ctx.phaseId,
|
|
2046
|
+
status: "complete",
|
|
2047
|
+
last_task: ctx.lastTask ?? "engine.runRouting complete",
|
|
2048
|
+
key_decisions: ctx.keyDecisions ?? [],
|
|
2049
|
+
canonical_refs: ctx.canonicalRefs ?? [],
|
|
2050
|
+
...ctx.sessionId ? { session_id: ctx.sessionId } : {},
|
|
2051
|
+
cwd: process.cwd(),
|
|
2052
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2053
|
+
archive_path: `.harnessed/archive/phase-${ctx.phaseId}/`
|
|
2054
|
+
});
|
|
2055
|
+
await complete();
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// src/routing/lib/promiseExtract.ts
|
|
2059
|
+
var PROMISE_PATTERN = /<promise>([^<]+)<\/promise>/;
|
|
2060
|
+
function extractPromise(text) {
|
|
2061
|
+
const match2 = text.match(PROMISE_PATTERN);
|
|
2062
|
+
return match2 ? match2[1] : null;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// src/routing/lib/ralphLoop.ts
|
|
2066
|
+
function isComplete(output) {
|
|
2067
|
+
try {
|
|
2068
|
+
const env = JSON.parse(output);
|
|
2069
|
+
if (env.subtype === "success" && env.structured_output?.status === "COMPLETE") return true;
|
|
2070
|
+
if (extractPromise(env.text ?? env.result ?? "") === "COMPLETE") return true;
|
|
2071
|
+
return false;
|
|
2072
|
+
} catch {
|
|
2073
|
+
return extractPromise(output) === "COMPLETE";
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
var MaxIterationsExceededError = class extends Error {
|
|
2077
|
+
constructor(iterations) {
|
|
2078
|
+
super(`ralph-loop max-iterations exceeded after ${iterations} attempts`);
|
|
2079
|
+
this.iterations = iterations;
|
|
2080
|
+
this.name = "MaxIterationsExceededError";
|
|
2081
|
+
}
|
|
2082
|
+
iterations;
|
|
2083
|
+
};
|
|
2084
|
+
var VerbatimCompleteFailError = class extends Error {
|
|
2085
|
+
constructor(lastMessage) {
|
|
2086
|
+
super("subagent final message lacked verbatim <promise>COMPLETE</promise> (F33 P1 mitigation)");
|
|
2087
|
+
this.lastMessage = lastMessage;
|
|
2088
|
+
this.name = "VerbatimCompleteFailError";
|
|
2089
|
+
}
|
|
2090
|
+
lastMessage;
|
|
2091
|
+
};
|
|
2092
|
+
async function ralphLoopWrap(spawn9, maxIter) {
|
|
2093
|
+
let last = "";
|
|
2094
|
+
let sessionId;
|
|
2095
|
+
for (let i = 0; i < maxIter; i++) {
|
|
2096
|
+
last = await spawn9(sessionId, (id) => {
|
|
2097
|
+
sessionId = id;
|
|
2098
|
+
});
|
|
2099
|
+
if (isComplete(last)) return last;
|
|
2100
|
+
}
|
|
2101
|
+
throw new MaxIterationsExceededError(maxIter);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// src/routing/lib/sdkReconcile.ts
|
|
2105
|
+
function toSdkAgentDefinition(def) {
|
|
2106
|
+
return {
|
|
2107
|
+
description: def.description,
|
|
2108
|
+
prompt: def.prompt,
|
|
2109
|
+
...def.tools ? { tools: def.tools } : {},
|
|
2110
|
+
...def.disallowedTools ? { disallowedTools: def.disallowedTools } : {},
|
|
2111
|
+
...def.model ? { model: def.model } : {}
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
function injectFactoryInternalFields(def, basePrompt) {
|
|
2115
|
+
const parts = [basePrompt];
|
|
2116
|
+
if (def.skills?.length) parts.push(`## Available skills
|
|
2117
|
+
- ${def.skills.join("\n- ")}`);
|
|
2118
|
+
if (def.mcpServers && Object.keys(def.mcpServers).length) {
|
|
2119
|
+
parts.push(`## MCP servers
|
|
2120
|
+
${Object.keys(def.mcpServers).join(", ")}`);
|
|
2121
|
+
}
|
|
2122
|
+
if (def.memory) parts.push(`## Memory
|
|
2123
|
+
${def.memory}`);
|
|
2124
|
+
if (def.maxTurns) parts.push(`## Turn budget
|
|
2125
|
+
${def.maxTurns} turns max.`);
|
|
2126
|
+
if (def.background) parts.push(`## Background
|
|
2127
|
+
fire-and-forget`);
|
|
2128
|
+
if (def.effort) parts.push(`## Effort
|
|
2129
|
+
${def.effort}`);
|
|
2130
|
+
if (def.permissionMode) parts.push(`## Permission mode
|
|
2131
|
+
${def.permissionMode}`);
|
|
2132
|
+
if (def.initialPrompt) parts.push(`## Initial prompt
|
|
2133
|
+
${def.initialPrompt}`);
|
|
2134
|
+
if (def.criticalSystemReminder_EXPERIMENTAL) {
|
|
2135
|
+
parts.push(`## CRITICAL
|
|
2136
|
+
${def.criticalSystemReminder_EXPERIMENTAL}`);
|
|
2137
|
+
}
|
|
2138
|
+
return parts.join("\n\n");
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// src/routing/lib/sdkSpawn.ts
|
|
2142
|
+
var SpawnFailError = class extends Error {
|
|
2143
|
+
constructor(lastMessage) {
|
|
2144
|
+
super("sdkSpawn produced no result message");
|
|
2145
|
+
this.lastMessage = lastMessage;
|
|
2146
|
+
this.name = "SpawnFailError";
|
|
2147
|
+
}
|
|
2148
|
+
lastMessage;
|
|
2149
|
+
};
|
|
2150
|
+
async function sdkSpawn(def, opts) {
|
|
2151
|
+
const sdkDef = toSdkAgentDefinition(def);
|
|
2152
|
+
const injectedPrompt = injectFactoryInternalFields(def, def.initialPrompt ?? def.prompt);
|
|
2153
|
+
const queryOptions = {
|
|
2154
|
+
allowedTools: ["Read", "Edit", "Write", "Grep", "Glob", "Bash", "Task"],
|
|
2155
|
+
agents: { [opts.expertName]: sdkDef },
|
|
2156
|
+
// PRIMARY signal (B-02 / B-07 SC3) — structured output via json_schema.
|
|
2157
|
+
outputFormat: { type: "json_schema", schema: COMPLETION_SCHEMA }
|
|
2158
|
+
};
|
|
2159
|
+
if (opts.resumeSessionId) queryOptions.resume = opts.resumeSessionId;
|
|
2160
|
+
const q = query({ prompt: injectedPrompt, options: queryOptions });
|
|
2161
|
+
let result;
|
|
2162
|
+
for await (const msg of q) {
|
|
2163
|
+
if (msg.type === "system" && msg.subtype === "init") {
|
|
2164
|
+
opts.onSessionId?.(msg.session_id);
|
|
2165
|
+
}
|
|
2166
|
+
if (msg.type === "result") {
|
|
2167
|
+
result = msg;
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
if (!result) throw new SpawnFailError();
|
|
2171
|
+
const structuredOutput = "structured_output" in result ? result.structured_output : void 0;
|
|
2172
|
+
const subtype = result.subtype;
|
|
2173
|
+
const text = "result" in result ? result.result : void 0;
|
|
2174
|
+
const envelope = {
|
|
2175
|
+
subtype,
|
|
2176
|
+
...structuredOutput ? { structured_output: structuredOutput } : {},
|
|
2177
|
+
...text != null ? { text, result: text } : {}
|
|
2178
|
+
};
|
|
2179
|
+
return JSON.stringify(envelope);
|
|
2180
|
+
}
|
|
2181
|
+
function isInstalled(skill, root) {
|
|
2182
|
+
return existsSync(join(root, skill, "SKILL.md"));
|
|
2183
|
+
}
|
|
2184
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2185
|
+
async function ensureSkillsInstalled(required, skillsRoot) {
|
|
2186
|
+
for (const name of required) {
|
|
2187
|
+
if (isInstalled(name, skillsRoot)) continue;
|
|
2188
|
+
await sleep(500);
|
|
2189
|
+
let retries = 3;
|
|
2190
|
+
while (retries-- > 0 && !isInstalled(name, skillsRoot)) await sleep(300);
|
|
2191
|
+
if (!isInstalled(name, skillsRoot)) {
|
|
2192
|
+
throw new RestartRequiredError(
|
|
2193
|
+
`Skill ${name} still missing after retry \u2014 please exit + restart Claude Code so the plugin takes effect.`
|
|
2194
|
+
);
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// src/routing/semanticRouter.ts
|
|
2200
|
+
var DEFAULT_SEMANTIC_THRESHOLD = 0.85;
|
|
2201
|
+
async function match(prompt, threshold = DEFAULT_SEMANTIC_THRESHOLD) {
|
|
2202
|
+
return { matched: false, rule: null, confidence: 0 };
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// src/routing/engine.ts
|
|
2206
|
+
var defaultSpawn = (def, ctx) => sdkSpawn(def, ctx);
|
|
2207
|
+
function buildDecision(matched) {
|
|
2208
|
+
if (!matched) {
|
|
2209
|
+
return {
|
|
2210
|
+
matched_rule_id: null,
|
|
2211
|
+
primary_expert: null,
|
|
2212
|
+
secondary_expert: null,
|
|
2213
|
+
category: "meta"
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
return {
|
|
2217
|
+
matched_rule_id: matched.id,
|
|
2218
|
+
primary_expert: matched.decision.primary_expert ?? null,
|
|
2219
|
+
secondary_expert: matched.decision.secondary_expert ?? null,
|
|
2220
|
+
category: matched.domain,
|
|
2221
|
+
forbidden_skills: matched.decision.forbidden,
|
|
2222
|
+
required_skills: matched.decision.required_skills
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
async function runRouting(task, opts = {}) {
|
|
2226
|
+
const rulesPath = opts.rulesPath ?? join("routing", "decision_rules.yaml");
|
|
2227
|
+
const skillsRoot = opts.skillsRoot ?? join(homedir(), ".claude", "skills");
|
|
2228
|
+
const maxIter = opts.maxIterations ?? 20;
|
|
2229
|
+
const userSpawn = opts.spawn;
|
|
2230
|
+
if (opts.dagNodes && opts.dagNodes.length > 0) {
|
|
2231
|
+
const dag = resolveDag(opts.dagNodes);
|
|
2232
|
+
if (!dag.ok) {
|
|
2233
|
+
const error = new InvalidDecisionError(
|
|
2234
|
+
`skill dependency cycle: ${dag.cycle.join(" \u2192 ")} (see ADR 0009 \xA7 DAG resolver)`
|
|
2235
|
+
);
|
|
2236
|
+
return { ok: false, phase: "arbitrate", error };
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
let rules;
|
|
2240
|
+
try {
|
|
2241
|
+
rules = loadDecisionRules(rulesPath).rules;
|
|
2242
|
+
} catch (error) {
|
|
2243
|
+
return { ok: false, phase: "arbitrate", error };
|
|
2244
|
+
}
|
|
2245
|
+
const taskCtx = {
|
|
2246
|
+
task_type: task.task_type,
|
|
2247
|
+
...task.override_keywords ? { override_keywords: task.override_keywords } : {}
|
|
2248
|
+
};
|
|
2249
|
+
const matched = arbitrate(rules, taskCtx);
|
|
2250
|
+
const decision = buildDecision(matched);
|
|
2251
|
+
if (!matched) {
|
|
2252
|
+
const semantic = await match(task.task, opts.semanticThreshold);
|
|
2253
|
+
void semantic.rule;
|
|
2254
|
+
if (opts.fallbackSupervisor) {
|
|
2255
|
+
const result = await opts.fallbackSupervisor(task);
|
|
2256
|
+
return { ok: true, result, matchedRule: null };
|
|
2257
|
+
}
|
|
2258
|
+
return {
|
|
2259
|
+
ok: false,
|
|
2260
|
+
phase: "arbitrate",
|
|
2261
|
+
error: new InvalidDecisionError("no rule matched and no fallbackSupervisor provided")
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
try {
|
|
2265
|
+
await ensureSkillsInstalled(decision.required_skills ?? [], skillsRoot);
|
|
2266
|
+
} catch (error) {
|
|
2267
|
+
if (error instanceof RestartRequiredError || error instanceof SkillNotInstalledError || error instanceof MissingSkillsError) {
|
|
2268
|
+
return { ok: false, phase: "install", error };
|
|
2269
|
+
}
|
|
2270
|
+
return { ok: false, phase: "install", error };
|
|
2271
|
+
}
|
|
2272
|
+
let agentDef;
|
|
2273
|
+
try {
|
|
2274
|
+
agentDef = await createAgent(task, decision, { ...opts.agentOpts, skillsRoot });
|
|
2275
|
+
} catch (error) {
|
|
2276
|
+
if (error instanceof SkillNotInstalledError || error instanceof MissingSkillsError) {
|
|
2277
|
+
return { ok: false, phase: "install", error };
|
|
2278
|
+
}
|
|
2279
|
+
if (error instanceof InvalidDecisionError) {
|
|
2280
|
+
return { ok: false, phase: "arbitrate", error };
|
|
2281
|
+
}
|
|
2282
|
+
return { ok: false, phase: "spawn", error };
|
|
2283
|
+
}
|
|
2284
|
+
const phaseId = task.phaseId ?? "unknown";
|
|
2285
|
+
await activatePhase(phaseId);
|
|
2286
|
+
const expertName = matched.decision.primary_expert ?? "unknown";
|
|
2287
|
+
let capturedSessionId;
|
|
2288
|
+
const wrappedSpawn = async (resumeSessionId, onSessionIdInner) => userSpawn ? userSpawn(agentDef) : defaultSpawn(agentDef, {
|
|
2289
|
+
expertName,
|
|
2290
|
+
...resumeSessionId ? { resumeSessionId } : {},
|
|
2291
|
+
onSessionId: (id) => {
|
|
2292
|
+
capturedSessionId = id;
|
|
2293
|
+
onSessionIdInner?.(id);
|
|
2294
|
+
}
|
|
2295
|
+
});
|
|
2296
|
+
try {
|
|
2297
|
+
const result = await ralphLoopWrap(wrappedSpawn, maxIter);
|
|
2298
|
+
await completePhase({ phaseId, sessionId: capturedSessionId, status: "complete" });
|
|
2299
|
+
emitAudit(task, decision, matched, "complete", capturedSessionId);
|
|
2300
|
+
return { ok: true, result, matchedRule: matched };
|
|
2301
|
+
} catch (error) {
|
|
2302
|
+
if (error instanceof MaxIterationsExceededError) {
|
|
2303
|
+
emitAudit(task, decision, matched, "max-iter", capturedSessionId);
|
|
2304
|
+
return { aborted: true, reason: error.message };
|
|
2305
|
+
}
|
|
2306
|
+
if (error instanceof VerbatimCompleteFailError) {
|
|
2307
|
+
emitAudit(task, decision, matched, "verbatim-fail", capturedSessionId);
|
|
2308
|
+
return { ok: false, phase: "verbatim", error };
|
|
2309
|
+
}
|
|
2310
|
+
emitAudit(task, decision, matched, "spawn-err", capturedSessionId);
|
|
2311
|
+
return { ok: false, phase: "spawn", error };
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
var ModelTier = Type.Union([
|
|
2315
|
+
Type.Literal("haiku"),
|
|
2316
|
+
Type.Literal("sonnet"),
|
|
2317
|
+
Type.Literal("opus"),
|
|
2318
|
+
Type.Literal("inherit")
|
|
2319
|
+
// B-10 override 逃生口
|
|
2320
|
+
]);
|
|
2321
|
+
var PhaseEntry = Type.Object(
|
|
2322
|
+
{
|
|
2323
|
+
id: Type.String({ minLength: 1 }),
|
|
2324
|
+
// e.g. '01-clarify'
|
|
2325
|
+
name: Type.String({ minLength: 1 }),
|
|
2326
|
+
upstream: Type.String({ minLength: 1 }),
|
|
2327
|
+
// e.g. 'superpowers brainstorming'
|
|
2328
|
+
model: ModelTier,
|
|
2329
|
+
// 必填 (B-08)
|
|
2330
|
+
skills: Type.Optional(Type.Array(Type.String())),
|
|
2331
|
+
max_iterations: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
2332
|
+
// Phase 3.2 W1 T1.7 — JINJA-templated invokes string (D-02, W-02 orchestrator fix unconditional extend).
|
|
2333
|
+
invokes: Type.Optional(Type.String())
|
|
2334
|
+
},
|
|
2335
|
+
{ additionalProperties: false }
|
|
2336
|
+
);
|
|
2337
|
+
var PhasesSchema = Type.Object(
|
|
2338
|
+
{
|
|
2339
|
+
workflow: Type.String({ minLength: 1 }),
|
|
2340
|
+
// e.g. 'execute-task'
|
|
2341
|
+
phases: Type.Array(PhaseEntry, { minItems: 1 }),
|
|
2342
|
+
// Phase 3.2 W1 T1.7 — CEO veto halt directive (D-04 PUSH, W-02 orchestrator fix unconditional extend).
|
|
2343
|
+
on_veto: Type.Optional(Type.String({ pattern: "^halt_workflow$" }))
|
|
2344
|
+
},
|
|
2345
|
+
{ additionalProperties: false }
|
|
2346
|
+
);
|
|
2347
|
+
|
|
2348
|
+
// src/workflow/loadPhases.ts
|
|
2349
|
+
var PhasesValidationError = class extends Error {
|
|
2350
|
+
constructor(errors) {
|
|
2351
|
+
super(`phases.yaml validation failed (${errors.length} error${errors.length === 1 ? "" : "s"})`);
|
|
2352
|
+
this.errors = errors;
|
|
2353
|
+
this.name = "PhasesValidationError";
|
|
2354
|
+
}
|
|
2355
|
+
errors;
|
|
2356
|
+
};
|
|
2357
|
+
function loadPhases(yamlPath, vars) {
|
|
2358
|
+
const raw = readFileSync(yamlPath, "utf8");
|
|
2359
|
+
const parsed = parse(raw);
|
|
2360
|
+
if (!Value.Check(PhasesSchema, parsed)) {
|
|
2361
|
+
throw new PhasesValidationError([...Value.Errors(PhasesSchema, parsed)]);
|
|
2362
|
+
}
|
|
2363
|
+
return parsed;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
// src/cli/lib/validateFlags.ts
|
|
2367
|
+
function validateNonInteractiveFlags(raw, cmdName) {
|
|
2368
|
+
if (raw.nonInteractive && !raw.apply && !raw.dryRun) {
|
|
2369
|
+
console.error(
|
|
2370
|
+
`error: --non-interactive requires --apply or --dry-run
|
|
2371
|
+
fix: 'harnessed ${cmdName} --non-interactive --dry-run' or '--apply'`
|
|
2372
|
+
);
|
|
2373
|
+
process.exit(2);
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
// src/cli/execute-task.ts
|
|
2378
|
+
function registerExecuteTask(program2) {
|
|
2379
|
+
program2.command("execute-task").description("Run execute-task workflow (4-phase chain \u2192 ralph-loop COMPLETE)").requiredOption("--task <text>", "task description (required)").option("--workflow <name>", "workflow name", "execute-task").option("--apply", "execute the spawn (default: dry-run preview)").option("--dry-run", "force dry-run (overrides --apply if both set)").option("--non-interactive", "CI / scripts \u2014 requires --apply or --dry-run").option("--model <model>", "subagent model: 'haiku' | 'sonnet' | 'opus'").option("--model-tier <tier>", "override: 'inherit' bypasses per-phase phase.model (B-10)").option("--max-iterations <n>", "ralph-loop max iter (default 20)", (v) => parseInt(v, 10)).action(async (raw) => {
|
|
2380
|
+
validateNonInteractiveFlags(raw, "execute-task --task <text>");
|
|
2381
|
+
if (!raw.task) {
|
|
2382
|
+
console.error("error: --task <text> is required");
|
|
2383
|
+
process.exit(2);
|
|
2384
|
+
}
|
|
2385
|
+
const workflowName = raw.workflow ?? "execute-task";
|
|
2386
|
+
let phases;
|
|
2387
|
+
try {
|
|
2388
|
+
phases = loadPhases(`workflows/${workflowName}/phases.yaml`);
|
|
2389
|
+
} catch (error) {
|
|
2390
|
+
console.error(
|
|
2391
|
+
`error: failed to load workflows/${workflowName}/phases.yaml \u2014 ${error.message}`
|
|
2392
|
+
);
|
|
2393
|
+
process.exit(2);
|
|
2394
|
+
}
|
|
2395
|
+
if (raw.modelTier === "inherit") {
|
|
2396
|
+
phases = {
|
|
2397
|
+
...phases,
|
|
2398
|
+
phases: phases.phases.map((p4) => ({ ...p4, model: "inherit" }))
|
|
2399
|
+
};
|
|
2400
|
+
}
|
|
2401
|
+
const taskCtx = { task: raw.task, task_type: "execute-task" };
|
|
2402
|
+
const isDryRun = raw.dryRun === true || !raw.apply && !raw.nonInteractive;
|
|
2403
|
+
if (isDryRun) {
|
|
2404
|
+
console.log(
|
|
2405
|
+
JSON.stringify({ workflow: phases.workflow, phases: phases.phases, taskCtx }, null, 2)
|
|
2406
|
+
);
|
|
2407
|
+
process.exit(0);
|
|
2408
|
+
}
|
|
2409
|
+
const result = await runRouting(taskCtx, {
|
|
2410
|
+
maxIterations: raw.maxIterations ?? 20,
|
|
2411
|
+
...raw.model ? { agentOpts: { modelOverride: raw.model } } : {}
|
|
2412
|
+
});
|
|
2413
|
+
if ("aborted" in result) {
|
|
2414
|
+
console.error(`aborted: ${result.reason}`);
|
|
2415
|
+
process.exit(2);
|
|
2416
|
+
}
|
|
2417
|
+
if ("ok" in result && result.ok === false) {
|
|
2418
|
+
console.error(`error: ${result.phase} \u2014 ${result.error.message}`);
|
|
2419
|
+
process.exit(1);
|
|
2420
|
+
}
|
|
2421
|
+
console.log(result.result);
|
|
2422
|
+
process.exit(0);
|
|
2423
|
+
});
|
|
2424
|
+
}
|
|
2425
|
+
var DURATION_RE = /^(\d+)([dhmw])$/;
|
|
2426
|
+
function parseDuration(s) {
|
|
2427
|
+
const m = DURATION_RE.exec(s);
|
|
2428
|
+
if (!m) return null;
|
|
2429
|
+
const n = Number.parseInt(m[1] ?? "0", 10);
|
|
2430
|
+
const unit = m[2];
|
|
2431
|
+
const ms = unit === "d" ? 864e5 : unit === "h" ? 36e5 : unit === "m" ? 6e4 : 6048e5;
|
|
2432
|
+
return n * ms;
|
|
2433
|
+
}
|
|
2434
|
+
async function dirSizeKb(dir) {
|
|
2435
|
+
let total = 0;
|
|
2436
|
+
const stack = [dir];
|
|
2437
|
+
while (stack.length > 0) {
|
|
2438
|
+
const cur = stack.pop();
|
|
2439
|
+
if (!cur) break;
|
|
2440
|
+
const entries = await readdir(cur, { withFileTypes: true });
|
|
2441
|
+
for (const e of entries) {
|
|
2442
|
+
const p4 = join(cur, e.name);
|
|
2443
|
+
if (e.isDirectory()) stack.push(p4);
|
|
2444
|
+
else if (e.isFile()) {
|
|
2445
|
+
const st = await stat(p4);
|
|
2446
|
+
total += st.size;
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
return Math.round(total / 1024);
|
|
2451
|
+
}
|
|
2452
|
+
function registerGc(program2) {
|
|
2453
|
+
program2.command("gc").description("Garbage-collect old backup snapshots (dry-run by default)").option("--older-than <duration>", "delete snapshots older than (e.g. 30d / 24h / 4w)", "30d").option("--keep-last <N>", "always keep the most recent N snapshots", "0").option("--apply", "actually delete (default: dry-run preview only)").option("--dry-run", "force dry-run (overrides --apply)").action(async (opts) => {
|
|
2454
|
+
const dryRun = opts.dryRun === true || opts.apply !== true;
|
|
2455
|
+
const olderMs = parseDuration(opts.olderThan ?? "30d");
|
|
2456
|
+
if (olderMs == null) {
|
|
2457
|
+
console.error(
|
|
2458
|
+
`error: invalid --older-than '${opts.olderThan}'
|
|
2459
|
+
fix: use format <N>{d|h|m|w} e.g. 30d / 24h / 60m / 4w`
|
|
2460
|
+
);
|
|
2461
|
+
process.exit(1);
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
const keepLast = Number.parseInt(opts.keepLast ?? "0", 10);
|
|
2465
|
+
const root = resolve(process.cwd(), ".harnessed-backup");
|
|
2466
|
+
let dirs;
|
|
2467
|
+
try {
|
|
2468
|
+
dirs = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
2469
|
+
} catch {
|
|
2470
|
+
console.log("no backups found (.harnessed-backup/ absent) \u2014 nothing to gc");
|
|
2471
|
+
return;
|
|
2472
|
+
}
|
|
2473
|
+
const cutoff = Date.now() - olderMs;
|
|
2474
|
+
const kept = new Set(dirs.slice(-keepLast));
|
|
2475
|
+
const candidates = [];
|
|
2476
|
+
for (const ts of dirs) {
|
|
2477
|
+
if (kept.has(ts)) continue;
|
|
2478
|
+
const path = join(root, ts);
|
|
2479
|
+
const iso = ts.replace(/T(\d{2})-(\d{2})-(\d{2})/, "T$1:$2:$3");
|
|
2480
|
+
const t = Date.parse(iso);
|
|
2481
|
+
if (Number.isNaN(t) || t > cutoff) continue;
|
|
2482
|
+
let manifest = "(unknown)";
|
|
2483
|
+
try {
|
|
2484
|
+
const meta = JSON.parse(
|
|
2485
|
+
await readFile(join(path, "metadata.json"), "utf8")
|
|
2486
|
+
);
|
|
2487
|
+
manifest = meta.manifest;
|
|
2488
|
+
} catch {
|
|
2489
|
+
}
|
|
2490
|
+
const sizeKb = await dirSizeKb(path);
|
|
2491
|
+
candidates.push({ ts, path, manifest, sizeKb });
|
|
2492
|
+
}
|
|
2493
|
+
if (candidates.length === 0) {
|
|
2494
|
+
console.log(`no snapshots older than ${opts.olderThan} (kept ${kept.size} most-recent)`);
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
const totalKb = candidates.reduce((a, c) => a + c.sizeKb, 0);
|
|
2498
|
+
const verb = dryRun ? "would delete" : "deleting";
|
|
2499
|
+
console.log(`${verb} ${candidates.length} snapshot(s), ~${totalKb} KB total:`);
|
|
2500
|
+
for (const c of candidates) {
|
|
2501
|
+
console.log(` ${c.ts} ${c.manifest} (${c.sizeKb} KB)`);
|
|
2502
|
+
if (!dryRun) await rm(c.path, { recursive: true, force: true });
|
|
2503
|
+
}
|
|
2504
|
+
if (dryRun) console.log("\n(dry-run \u2014 re-run with --apply to actually delete)");
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
2507
|
+
var HOME_DIR = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
2508
|
+
function mirrorPath(target, scope, backupDir) {
|
|
2509
|
+
const root = scope === "HOME" ? HOME_DIR : ".";
|
|
2510
|
+
const rel = root ? relative(root, target) : target;
|
|
2511
|
+
if (!rel || rel.startsWith("..")) {
|
|
2512
|
+
const flat = createHash("sha1").update(target).digest("hex").slice(0, 16);
|
|
2513
|
+
return join(backupDir, scope, flat);
|
|
2514
|
+
}
|
|
2515
|
+
return join(backupDir, scope, rel);
|
|
2516
|
+
}
|
|
2517
|
+
function detectEol(buf) {
|
|
2518
|
+
return buf.includes("\r\n") ? "crlf" : "lf";
|
|
2519
|
+
}
|
|
2520
|
+
async function backup(plan, ctx) {
|
|
2521
|
+
const filename = ctx.manifest.metadata.name;
|
|
2522
|
+
const backupId = (/* @__PURE__ */ new Date()).toISOString().replace(/:/g, "-");
|
|
2523
|
+
const backupDir = join(ctx.cwd, ".harnessed-backup", backupId);
|
|
2524
|
+
try {
|
|
2525
|
+
await mkdir(backupDir, { recursive: true });
|
|
2526
|
+
} catch (err2) {
|
|
2527
|
+
return {
|
|
2528
|
+
ok: false,
|
|
2529
|
+
error: {
|
|
2530
|
+
file: filename,
|
|
2531
|
+
path: "/",
|
|
2532
|
+
message: `failed to create backup dir ${backupDir}: ${err2.message}`,
|
|
2533
|
+
line: null,
|
|
2534
|
+
column: null,
|
|
2535
|
+
keyword: "backup-mkdir-failed"
|
|
2536
|
+
}
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
const entries = [];
|
|
2540
|
+
for (const file of plan.files) {
|
|
2541
|
+
let buf;
|
|
2542
|
+
try {
|
|
2543
|
+
buf = await readFile(file.target);
|
|
2544
|
+
} catch (err2) {
|
|
2545
|
+
const code = err2.code;
|
|
2546
|
+
if (code === "ENOENT" && file.oldText === "") {
|
|
2547
|
+
entries.push({
|
|
2548
|
+
target: file.target,
|
|
2549
|
+
backup: "",
|
|
2550
|
+
// sentinel: no backup written; rollback should unlink target
|
|
2551
|
+
sha1: "",
|
|
2552
|
+
eol: "lf"
|
|
2553
|
+
// moot for non-existent file; default to lf
|
|
2554
|
+
});
|
|
2555
|
+
continue;
|
|
2556
|
+
}
|
|
2557
|
+
return {
|
|
2558
|
+
ok: false,
|
|
2559
|
+
error: {
|
|
2560
|
+
file: filename,
|
|
2561
|
+
path: file.target,
|
|
2562
|
+
message: `failed to read original file for backup: ${err2.message}`,
|
|
2563
|
+
line: null,
|
|
2564
|
+
column: null,
|
|
2565
|
+
keyword: "backup-read-failed"
|
|
2566
|
+
}
|
|
2567
|
+
};
|
|
2568
|
+
}
|
|
2569
|
+
const sha1 = createHash("sha1").update(buf).digest("hex");
|
|
2570
|
+
const eol = detectEol(buf);
|
|
2571
|
+
const dest = mirrorPath(file.target, file.scope, backupDir);
|
|
2572
|
+
try {
|
|
2573
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
2574
|
+
await writeFile(dest, buf);
|
|
2575
|
+
} catch (err2) {
|
|
2576
|
+
return {
|
|
2577
|
+
ok: false,
|
|
2578
|
+
error: {
|
|
2579
|
+
file: filename,
|
|
2580
|
+
path: dest,
|
|
2581
|
+
message: `failed to write backup copy: ${err2.message}`,
|
|
2582
|
+
line: null,
|
|
2583
|
+
column: null,
|
|
2584
|
+
keyword: "backup-write-failed"
|
|
2585
|
+
}
|
|
2586
|
+
};
|
|
2587
|
+
}
|
|
2588
|
+
entries.push({ target: file.target, backup: dest, sha1, eol });
|
|
2589
|
+
}
|
|
2590
|
+
const metadata = {
|
|
2591
|
+
installer: filename,
|
|
2592
|
+
manifest: filename,
|
|
2593
|
+
timestamp: backupId,
|
|
2594
|
+
files: entries
|
|
2595
|
+
};
|
|
2596
|
+
const metadataPath = join(backupDir, "metadata.json");
|
|
2597
|
+
try {
|
|
2598
|
+
await writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}
|
|
2599
|
+
`, "utf8");
|
|
2600
|
+
} catch (err2) {
|
|
2601
|
+
return {
|
|
2602
|
+
ok: false,
|
|
2603
|
+
error: {
|
|
2604
|
+
file: filename,
|
|
2605
|
+
path: metadataPath,
|
|
2606
|
+
message: `failed to write metadata.json: ${err2.message}`,
|
|
2607
|
+
line: null,
|
|
2608
|
+
column: null,
|
|
2609
|
+
keyword: "backup-metadata-failed"
|
|
2610
|
+
}
|
|
2611
|
+
};
|
|
2612
|
+
}
|
|
2613
|
+
return { ok: true, backupId, backupDir };
|
|
2614
|
+
}
|
|
2615
|
+
async function confirmAt(level, ctx) {
|
|
2616
|
+
if (level === "L4" && !ctx.opts.system) {
|
|
2617
|
+
if (!ctx.opts.nonInteractive) {
|
|
2618
|
+
p.note(
|
|
2619
|
+
"this method requires --system flag (e.g. global npm install affects machine PATH); pass --system to opt in.",
|
|
2620
|
+
"L4 system-wide install"
|
|
2621
|
+
);
|
|
2622
|
+
}
|
|
2623
|
+
return { proceed: false, reason: "flag-missing" };
|
|
2624
|
+
}
|
|
2625
|
+
if (ctx.opts.nonInteractive) {
|
|
2626
|
+
return { proceed: ctx.opts.apply };
|
|
2627
|
+
}
|
|
2628
|
+
if (level === "L1") {
|
|
2629
|
+
p.note("will write project-local files only (safe scope).", "L1 confirm");
|
|
2630
|
+
return { proceed: true };
|
|
2631
|
+
}
|
|
2632
|
+
if (level === "L2") {
|
|
2633
|
+
const ans2 = await p.confirm({ message: "Proceed with install?", initialValue: false });
|
|
2634
|
+
if (p.isCancel(ans2)) return { proceed: false, reason: "user-cancel" };
|
|
2635
|
+
return { proceed: ans2 === true };
|
|
2636
|
+
}
|
|
2637
|
+
if (level === "L3") {
|
|
2638
|
+
const first = await p.confirm({ message: "Proceed with install?", initialValue: false });
|
|
2639
|
+
if (p.isCancel(first)) return { proceed: false, reason: "user-cancel" };
|
|
2640
|
+
if (first !== true) return { proceed: false };
|
|
2641
|
+
const second = await p.confirm({
|
|
2642
|
+
message: "This affects other plugins (e.g. ~/.claude.json is shared user-scope state). Confirm again?",
|
|
2643
|
+
initialValue: false
|
|
2644
|
+
});
|
|
2645
|
+
if (p.isCancel(second)) return { proceed: false, reason: "user-cancel" };
|
|
2646
|
+
return { proceed: second === true };
|
|
2647
|
+
}
|
|
2648
|
+
const ans = await p.confirm({
|
|
2649
|
+
message: "System-wide install will modify global PATH. Proceed?",
|
|
2650
|
+
initialValue: false
|
|
2651
|
+
});
|
|
2652
|
+
if (p.isCancel(ans)) return { proceed: false, reason: "user-cancel" };
|
|
2653
|
+
return { proceed: ans === true };
|
|
2654
|
+
}
|
|
2655
|
+
var FOLD_THRESHOLD = 200;
|
|
2656
|
+
var FOLD_HEAD = 100;
|
|
2657
|
+
function colorize(line) {
|
|
2658
|
+
if (!pc.isColorSupported) return line;
|
|
2659
|
+
if (line.startsWith("+") && !line.startsWith("+++")) return pc.green(line);
|
|
2660
|
+
if (line.startsWith("-") && !line.startsWith("---")) return pc.red(line);
|
|
2661
|
+
if (line.startsWith("@@")) return pc.cyan(line);
|
|
2662
|
+
return line;
|
|
2663
|
+
}
|
|
2664
|
+
function diffOneFile(file) {
|
|
2665
|
+
const patch = createPatch(file.target, file.oldText, file.newText, "", "", {
|
|
2666
|
+
stripTrailingCr: true
|
|
2667
|
+
});
|
|
2668
|
+
const lines = patch.split("\n");
|
|
2669
|
+
let added = 0;
|
|
2670
|
+
let removed = 0;
|
|
2671
|
+
for (const ln of lines) {
|
|
2672
|
+
if (ln.startsWith("+") && !ln.startsWith("+++")) added++;
|
|
2673
|
+
else if (ln.startsWith("-") && !ln.startsWith("---")) removed++;
|
|
2674
|
+
}
|
|
2675
|
+
return { lines, added, removed };
|
|
2676
|
+
}
|
|
2677
|
+
function renderDiff(plan, ctx) {
|
|
2678
|
+
if (plan.files.length === 0) return "(no file changes)\n";
|
|
2679
|
+
const out = [];
|
|
2680
|
+
let totalAdded = 0;
|
|
2681
|
+
let totalRemoved = 0;
|
|
2682
|
+
for (const file of plan.files) {
|
|
2683
|
+
const { lines, added, removed } = diffOneFile(file);
|
|
2684
|
+
totalAdded += added;
|
|
2685
|
+
totalRemoved += removed;
|
|
2686
|
+
const folded = !ctx.opts.fullDiff && lines.length > FOLD_THRESHOLD;
|
|
2687
|
+
const visible = folded ? lines.slice(0, FOLD_HEAD) : lines;
|
|
2688
|
+
for (const ln of visible) out.push(colorize(ln));
|
|
2689
|
+
if (folded) {
|
|
2690
|
+
const more = lines.length - FOLD_HEAD;
|
|
2691
|
+
out.push(pc.dim(`... ${more} more lines (use --full-diff to expand)`));
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
const summary = `will modify ${plan.files.length} file${plan.files.length === 1 ? "" : "s"} (${totalAdded} added, ${totalRemoved} removed)`;
|
|
2695
|
+
out.push("");
|
|
2696
|
+
out.push(pc.bold(summary));
|
|
2697
|
+
return `${out.join("\n")}
|
|
2698
|
+
`;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
// src/installers/lib/err.ts
|
|
2702
|
+
function err(ctx, path, message, keyword) {
|
|
2703
|
+
return { file: ctx.manifest.metadata.name, path, message, line: null, column: null, keyword };
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
// src/installers/lib/preflight.ts
|
|
2707
|
+
var GIT_REF_SHAPE = /^([a-f0-9]{7,40}|v?\d+\.\d+\.\d+([.-][\w.-]+)?)$/;
|
|
2708
|
+
function preflight(ctx) {
|
|
2709
|
+
const errors = [];
|
|
2710
|
+
const filename = ctx.manifest.metadata.name;
|
|
2711
|
+
const spec = ctx.manifest.spec;
|
|
2712
|
+
const install = spec.install;
|
|
2713
|
+
const current = process.platform;
|
|
2714
|
+
if (!spec.platforms.includes(current)) {
|
|
2715
|
+
errors.push({
|
|
2716
|
+
file: filename,
|
|
2717
|
+
path: "/spec/platforms",
|
|
2718
|
+
message: `current platform '${current}' not in supported list [${spec.platforms.join(", ")}]; manifest declares it does not run here.`,
|
|
2719
|
+
line: null,
|
|
2720
|
+
column: null,
|
|
2721
|
+
keyword: "platform-mismatch",
|
|
2722
|
+
suggest: `add '${current}' to spec.platforms in upstream manifest if the installer is known to work there`
|
|
2723
|
+
});
|
|
2724
|
+
return { ok: false, errors, abortReason: "platform-mismatch" };
|
|
2725
|
+
}
|
|
2726
|
+
if ("git_ref" in install && typeof install.git_ref === "string") {
|
|
2727
|
+
if (!GIT_REF_SHAPE.test(install.git_ref)) {
|
|
2728
|
+
errors.push({
|
|
2729
|
+
file: filename,
|
|
2730
|
+
path: "/spec/install/git_ref",
|
|
2731
|
+
message: `git_ref '${install.git_ref}' does not match SHA(7-40 hex) or SemVer shape; schema should have caught this \u2014 preflight backstop.`,
|
|
2732
|
+
line: null,
|
|
2733
|
+
column: null,
|
|
2734
|
+
keyword: "git-ref-shape",
|
|
2735
|
+
suggest: "pin to a SHA (40 hex) or SemVer tag (e.g. v1.2.3); branch names like main/HEAD are forbidden"
|
|
2736
|
+
});
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
if (typeof install.idempotent_check !== "string" || install.idempotent_check.trim().length === 0) {
|
|
2740
|
+
errors.push({
|
|
2741
|
+
file: filename,
|
|
2742
|
+
path: "/spec/install/idempotent_check",
|
|
2743
|
+
message: "idempotent_check missing or empty; ADR 0004 contract 1 requires a check command for re-install detection",
|
|
2744
|
+
line: null,
|
|
2745
|
+
column: null,
|
|
2746
|
+
keyword: "idempotent-check-missing",
|
|
2747
|
+
suggest: "add a shell cmd that exits 0 iff the package is already installed (e.g. `npm ls -g <pkg>`)"
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
return { ok: errors.length === 0, errors };
|
|
2751
|
+
}
|
|
2752
|
+
var DEFAULT_STATE = { version: "1", installed: {} };
|
|
2753
|
+
function statePath(cwd) {
|
|
2754
|
+
return join(cwd, ".harnessed", "state.json");
|
|
2755
|
+
}
|
|
2756
|
+
async function readState(cwd) {
|
|
2757
|
+
const path = statePath(cwd);
|
|
2758
|
+
let raw;
|
|
2759
|
+
try {
|
|
2760
|
+
raw = await readFile(path, "utf8");
|
|
2761
|
+
} catch (err2) {
|
|
2762
|
+
if (err2.code === "ENOENT") {
|
|
2763
|
+
return { ...DEFAULT_STATE, installed: {} };
|
|
2764
|
+
}
|
|
2765
|
+
throw err2;
|
|
2766
|
+
}
|
|
2767
|
+
try {
|
|
2768
|
+
const parsed = JSON.parse(raw);
|
|
2769
|
+
if (parsed.version !== "1" || typeof parsed.installed !== "object") {
|
|
2770
|
+
return { ...DEFAULT_STATE, installed: {} };
|
|
2771
|
+
}
|
|
2772
|
+
return parsed;
|
|
2773
|
+
} catch {
|
|
2774
|
+
return { ...DEFAULT_STATE, installed: {} };
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
async function writeState(cwd, state) {
|
|
2778
|
+
const path = statePath(cwd);
|
|
2779
|
+
const tmp = `${path}.tmp`;
|
|
2780
|
+
await mkdir(dirname(path), { recursive: true });
|
|
2781
|
+
await writeFile(tmp, `${JSON.stringify(state, null, 2)}
|
|
2782
|
+
`, "utf8");
|
|
2783
|
+
await rename(tmp, path);
|
|
2784
|
+
}
|
|
2785
|
+
async function updateInstalled(cwd, name, version, manifestSha1) {
|
|
2786
|
+
const state = await readState(cwd);
|
|
2787
|
+
state.installed[name] = {
|
|
2788
|
+
version,
|
|
2789
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2790
|
+
manifestSha1
|
|
2791
|
+
};
|
|
2792
|
+
await writeState(cwd, state);
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
// src/installers/ccHookAdd.ts
|
|
2796
|
+
var installCcHookAdd = async (ctx) => {
|
|
2797
|
+
const install = ctx.manifest.spec.install;
|
|
2798
|
+
if (install.method !== "cc-hook-add") {
|
|
2799
|
+
return {
|
|
2800
|
+
ok: false,
|
|
2801
|
+
phase: "preflight",
|
|
2802
|
+
error: err(
|
|
2803
|
+
ctx,
|
|
2804
|
+
"/spec/install/method",
|
|
2805
|
+
`dispatch bug: ${install.method}`,
|
|
2806
|
+
"dispatch-mismatch"
|
|
2807
|
+
)
|
|
2808
|
+
};
|
|
2809
|
+
}
|
|
2810
|
+
const pre = preflight(ctx);
|
|
2811
|
+
if (!pre.ok) {
|
|
2812
|
+
if (pre.abortReason === "platform-mismatch")
|
|
2813
|
+
return { aborted: true, reason: "platform-mismatch" };
|
|
2814
|
+
const e = pre.errors[0] ?? err(ctx, "/", "preflight failed", "preflight");
|
|
2815
|
+
return { ok: false, phase: "preflight", error: e };
|
|
2816
|
+
}
|
|
2817
|
+
const settingsPath = join(homedir(), ".claude", "settings.json");
|
|
2818
|
+
let existing;
|
|
2819
|
+
try {
|
|
2820
|
+
existing = await readFile(settingsPath, "utf8");
|
|
2821
|
+
} catch {
|
|
2822
|
+
existing = null;
|
|
2823
|
+
}
|
|
2824
|
+
let settings;
|
|
2825
|
+
try {
|
|
2826
|
+
settings = JSON.parse(existing ?? "{}");
|
|
2827
|
+
} catch (e) {
|
|
2828
|
+
return {
|
|
2829
|
+
ok: false,
|
|
2830
|
+
phase: "preflight",
|
|
2831
|
+
error: err(
|
|
2832
|
+
ctx,
|
|
2833
|
+
"/",
|
|
2834
|
+
`malformed settings.json: ${e.message.slice(0, 200)}`,
|
|
2835
|
+
"settings-json-malformed"
|
|
2836
|
+
)
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
settings.hooks = settings.hooks ?? {};
|
|
2840
|
+
const ev = install.hook_event;
|
|
2841
|
+
const matcher = install.hook_matcher;
|
|
2842
|
+
const cmd = install.hook_command;
|
|
2843
|
+
settings.hooks[ev] = settings.hooks[ev] ?? [];
|
|
2844
|
+
if (settings.hooks[ev].some((h) => h.command === cmd && h.matcher === matcher)) {
|
|
2845
|
+
return { ok: true, backupId: "idempotent-skip", appliedFiles: [] };
|
|
2846
|
+
}
|
|
2847
|
+
settings.hooks[ev].push({ matcher, command: cmd });
|
|
2848
|
+
const newText = `${JSON.stringify(settings, null, 2)}
|
|
2849
|
+
`;
|
|
2850
|
+
const plan = {
|
|
2851
|
+
files: [{ target: settingsPath, scope: "HOME", oldText: existing ?? "", newText }]
|
|
2852
|
+
};
|
|
2853
|
+
process.stdout.write(renderDiff(plan, ctx));
|
|
2854
|
+
const conf = await confirmAt("L3", { ...ctx});
|
|
2855
|
+
if (!conf.proceed) {
|
|
2856
|
+
const reason = conf.reason === "flag-missing" ? "level-flag-missing" : "user-cancel";
|
|
2857
|
+
return { aborted: true, reason };
|
|
2858
|
+
}
|
|
2859
|
+
if (ctx.opts.dryRun) return { aborted: true, reason: "user-cancel" };
|
|
2860
|
+
const bk = await backup(plan, ctx);
|
|
2861
|
+
if (!bk.ok) return { ok: false, phase: "preflight", error: bk.error };
|
|
2862
|
+
await writeFile(settingsPath, newText);
|
|
2863
|
+
let verify;
|
|
2864
|
+
try {
|
|
2865
|
+
verify = JSON.parse(await readFile(settingsPath, "utf8"));
|
|
2866
|
+
} catch (e) {
|
|
2867
|
+
return {
|
|
2868
|
+
ok: false,
|
|
2869
|
+
phase: "verify",
|
|
2870
|
+
backupId: bk.backupId,
|
|
2871
|
+
error: err(
|
|
2872
|
+
ctx,
|
|
2873
|
+
"/spec/install/hook_command",
|
|
2874
|
+
`verify re-read fail: ${e.message.slice(0, 200)}`,
|
|
2875
|
+
"verify-failed"
|
|
2876
|
+
)
|
|
2877
|
+
};
|
|
2878
|
+
}
|
|
2879
|
+
if (!verify.hooks?.[ev]?.some((h) => h.command === cmd)) {
|
|
2880
|
+
return {
|
|
2881
|
+
ok: false,
|
|
2882
|
+
phase: "verify",
|
|
2883
|
+
backupId: bk.backupId,
|
|
2884
|
+
error: err(
|
|
2885
|
+
ctx,
|
|
2886
|
+
"/spec/install/hook_command",
|
|
2887
|
+
`hook '${cmd.slice(0, 80)}' missing in hooks.${ev}[] after write`,
|
|
2888
|
+
"verify-failed"
|
|
2889
|
+
)
|
|
2890
|
+
};
|
|
2891
|
+
}
|
|
2892
|
+
await updateInstalled(ctx.cwd, ctx.manifest.metadata.name, "", "");
|
|
2893
|
+
return { ok: true, backupId: bk.backupId, appliedFiles: [settingsPath] };
|
|
2894
|
+
};
|
|
2895
|
+
function runArgs(claudeArgs, cwd, timeoutMs = 15e3) {
|
|
2896
|
+
return new Promise((resolve8) => {
|
|
2897
|
+
const isWin = process.platform === "win32";
|
|
2898
|
+
const child = isWin ? spawn("cmd.exe", ["/c", "claude", ...claudeArgs], { cwd, windowsHide: true }) : spawn("claude", claudeArgs, { cwd, shell: false });
|
|
2899
|
+
let stderr = "";
|
|
2900
|
+
child.stderr?.setEncoding("utf8").on("data", (c) => {
|
|
2901
|
+
stderr += c;
|
|
2902
|
+
});
|
|
2903
|
+
const timer = setTimeout(() => {
|
|
2904
|
+
child.kill("SIGKILL");
|
|
2905
|
+
resolve8({ exitCode: -1, stderr: `${stderr}[timeout after ${timeoutMs}ms]` });
|
|
2906
|
+
}, timeoutMs);
|
|
2907
|
+
child.on("error", (e) => {
|
|
2908
|
+
clearTimeout(timer);
|
|
2909
|
+
resolve8({ exitCode: -1, stderr: `${stderr}${e.message}` });
|
|
2910
|
+
});
|
|
2911
|
+
child.on("close", (code) => {
|
|
2912
|
+
clearTimeout(timer);
|
|
2913
|
+
resolve8({ exitCode: code ?? -1, stderr });
|
|
2914
|
+
});
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
// src/installers/ccPluginMarketplace.ts
|
|
2919
|
+
function parseCmd(cmd) {
|
|
2920
|
+
const mktMatch = cmd.match(/(?:\/?plugin)\s+marketplace\s+add\s+(\S+)/i);
|
|
2921
|
+
const pluginMatch = cmd.match(/(?:\/?plugin)\s+install\s+(\S+)/i);
|
|
2922
|
+
if (!pluginMatch || pluginMatch[1] === void 0) return null;
|
|
2923
|
+
const pluginAtMkt = pluginMatch[1].replace(/[;&]+$/, "");
|
|
2924
|
+
if (!pluginAtMkt.includes("@")) return null;
|
|
2925
|
+
const mktRef = mktMatch && mktMatch[1] !== void 0 ? mktMatch[1] : null;
|
|
2926
|
+
return {
|
|
2927
|
+
marketplaceRef: mktRef,
|
|
2928
|
+
pluginAtMkt
|
|
2929
|
+
};
|
|
2930
|
+
}
|
|
2931
|
+
var installCcPluginMarketplace = async (ctx) => {
|
|
2932
|
+
const install = ctx.manifest.spec.install;
|
|
2933
|
+
if (install.method !== "cc-plugin-marketplace") {
|
|
2934
|
+
return {
|
|
2935
|
+
ok: false,
|
|
2936
|
+
phase: "preflight",
|
|
2937
|
+
error: err(
|
|
2938
|
+
ctx,
|
|
2939
|
+
"/spec/install/method",
|
|
2940
|
+
`installCcPluginMarketplace received non-cc-plugin-marketplace method '${install.method}' (dispatch bug)`,
|
|
2941
|
+
"dispatch-mismatch"
|
|
2942
|
+
)
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
const pre = preflight(ctx);
|
|
2946
|
+
if (!pre.ok) {
|
|
2947
|
+
if (pre.abortReason === "platform-mismatch")
|
|
2948
|
+
return { aborted: true, reason: "platform-mismatch" };
|
|
2949
|
+
const e = pre.errors[0] ?? err(ctx, "/", "preflight failed (no detail)", "preflight");
|
|
2950
|
+
return { ok: false, phase: "preflight", error: e };
|
|
2951
|
+
}
|
|
2952
|
+
const parsed = parseCmd(install.cmd);
|
|
2953
|
+
if (!parsed) {
|
|
2954
|
+
return {
|
|
2955
|
+
ok: false,
|
|
2956
|
+
phase: "preflight",
|
|
2957
|
+
error: {
|
|
2958
|
+
...err(
|
|
2959
|
+
ctx,
|
|
2960
|
+
"/spec/install/cmd",
|
|
2961
|
+
`cc-plugin-marketplace cmd must contain \`plugin install <plugin>@<marketplace>\` (parsed from: '${install.cmd.slice(0, 100)}')`,
|
|
2962
|
+
"cc-plugin-shape"
|
|
2963
|
+
),
|
|
2964
|
+
suggest: "see manifests/tools/ralph-loop.yaml or manifests/tools/superpowers.yaml for shape"
|
|
2965
|
+
}
|
|
2966
|
+
};
|
|
2967
|
+
}
|
|
2968
|
+
const pluginName = parsed.pluginAtMkt.split("@")[0];
|
|
2969
|
+
const installArgs = ["plugin", "install", parsed.pluginAtMkt, "--scope", "project"];
|
|
2970
|
+
const allArgs = [];
|
|
2971
|
+
if (parsed.marketplaceRef !== null) {
|
|
2972
|
+
allArgs.push(["plugin", "marketplace", "add", parsed.marketplaceRef]);
|
|
2973
|
+
}
|
|
2974
|
+
allArgs.push(installArgs);
|
|
2975
|
+
for (const argSet of allArgs) {
|
|
2976
|
+
for (const a of argSet) {
|
|
2977
|
+
const violation2 = checkCmdString(a);
|
|
2978
|
+
if (violation2) {
|
|
2979
|
+
return {
|
|
2980
|
+
ok: false,
|
|
2981
|
+
phase: "preflight",
|
|
2982
|
+
error: err(
|
|
2983
|
+
ctx,
|
|
2984
|
+
"/spec/install/cmd",
|
|
2985
|
+
`shell escape detected in constructed cc-plugin arg '${a.slice(0, 60)}': ${violation2.label} (${violation2.hint})`,
|
|
2986
|
+
"security-gate-bypass"
|
|
2987
|
+
)
|
|
2988
|
+
};
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
const settingsFile = `${ctx.cwd}/.claude/settings.json`;
|
|
2993
|
+
const newEntry = JSON.stringify({ enabledPlugins: { [parsed.pluginAtMkt]: true } }, null, 2);
|
|
2994
|
+
const plan = {
|
|
2995
|
+
files: [
|
|
2996
|
+
{
|
|
2997
|
+
target: settingsFile,
|
|
2998
|
+
scope: "PROJECT",
|
|
2999
|
+
oldText: "",
|
|
3000
|
+
newText: `// will be merged into .claude/settings.json enabledPlugins map by \`claude plugin install\`:
|
|
3001
|
+
${newEntry}
|
|
3002
|
+
`
|
|
3003
|
+
}
|
|
3004
|
+
]
|
|
3005
|
+
};
|
|
3006
|
+
process.stdout.write(renderDiff(plan, ctx));
|
|
3007
|
+
const conf = await confirmAt("L3", { ...ctx});
|
|
3008
|
+
if (!conf.proceed) {
|
|
3009
|
+
const reason = conf.reason === "flag-missing" ? "level-flag-missing" : "user-cancel";
|
|
3010
|
+
return { aborted: true, reason };
|
|
3011
|
+
}
|
|
3012
|
+
if (ctx.opts.dryRun) return { aborted: true, reason: "user-cancel" };
|
|
3013
|
+
const bk = await backup(plan, ctx);
|
|
3014
|
+
if (!bk.ok) return { ok: false, phase: "preflight", error: bk.error };
|
|
3015
|
+
let stepOneStderr = "";
|
|
3016
|
+
if (parsed.marketplaceRef !== null) {
|
|
3017
|
+
const r1 = await runArgs(
|
|
3018
|
+
["plugin", "marketplace", "add", parsed.marketplaceRef],
|
|
3019
|
+
install.cwd ?? ctx.cwd
|
|
3020
|
+
);
|
|
3021
|
+
stepOneStderr = r1.stderr;
|
|
3022
|
+
}
|
|
3023
|
+
const r2 = await runArgs(installArgs, install.cwd ?? ctx.cwd);
|
|
3024
|
+
if (r2.exitCode !== 0) {
|
|
3025
|
+
return {
|
|
3026
|
+
ok: false,
|
|
3027
|
+
phase: "spawn",
|
|
3028
|
+
backupId: bk.backupId,
|
|
3029
|
+
error: err(
|
|
3030
|
+
ctx,
|
|
3031
|
+
"/spec/install/cmd",
|
|
3032
|
+
`claude plugin install exited ${r2.exitCode}: ${r2.stderr.slice(0, 200)}${stepOneStderr ? ` | marketplace-add stderr: ${stepOneStderr.slice(0, 100)}` : ""}`,
|
|
3033
|
+
"install-failed"
|
|
3034
|
+
)
|
|
3035
|
+
};
|
|
3036
|
+
}
|
|
3037
|
+
const verifyShell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
|
|
3038
|
+
const verifyFlag = process.platform === "win32" ? "/c" : "-c";
|
|
3039
|
+
const verifyLine = `claude plugin list --json | grep -q ${pluginName}`;
|
|
3040
|
+
const violation = checkCmdString(verifyLine);
|
|
3041
|
+
if (violation) {
|
|
3042
|
+
return {
|
|
3043
|
+
ok: false,
|
|
3044
|
+
phase: "verify",
|
|
3045
|
+
backupId: bk.backupId,
|
|
3046
|
+
error: err(
|
|
3047
|
+
ctx,
|
|
3048
|
+
"/spec/verify/cmd",
|
|
3049
|
+
`verify shell escape: ${violation.label}`,
|
|
3050
|
+
"security-gate-bypass"
|
|
3051
|
+
)
|
|
3052
|
+
};
|
|
3053
|
+
}
|
|
3054
|
+
const vr = await new Promise((resolve8) => {
|
|
3055
|
+
const child = spawn(verifyShell, [verifyFlag, verifyLine], { cwd: ctx.cwd, windowsHide: true });
|
|
3056
|
+
let stderr = "";
|
|
3057
|
+
child.stderr?.setEncoding("utf8").on("data", (c) => {
|
|
3058
|
+
stderr += c;
|
|
3059
|
+
});
|
|
3060
|
+
const timer = setTimeout(() => {
|
|
3061
|
+
child.kill("SIGKILL");
|
|
3062
|
+
resolve8({ exitCode: -1, stderr: `${stderr}[timeout]` });
|
|
3063
|
+
}, 15e3);
|
|
3064
|
+
child.on("error", (e) => {
|
|
3065
|
+
clearTimeout(timer);
|
|
3066
|
+
resolve8({ exitCode: -1, stderr: e.message });
|
|
3067
|
+
});
|
|
3068
|
+
child.on("close", (code) => {
|
|
3069
|
+
clearTimeout(timer);
|
|
3070
|
+
resolve8({ exitCode: code ?? -1, stderr });
|
|
3071
|
+
});
|
|
3072
|
+
});
|
|
3073
|
+
if (vr.exitCode !== 0) {
|
|
3074
|
+
return {
|
|
3075
|
+
ok: false,
|
|
3076
|
+
phase: "verify",
|
|
3077
|
+
backupId: bk.backupId,
|
|
3078
|
+
error: err(
|
|
3079
|
+
ctx,
|
|
3080
|
+
"/spec/verify/cmd",
|
|
3081
|
+
`verify exit ${vr.exitCode}: ${vr.stderr.slice(0, 200)}`,
|
|
3082
|
+
"verify-failed"
|
|
3083
|
+
)
|
|
3084
|
+
};
|
|
3085
|
+
}
|
|
3086
|
+
await updateInstalled(ctx.cwd, ctx.manifest.metadata.name, install.git_ref, "");
|
|
3087
|
+
return { ok: true, backupId: bk.backupId, appliedFiles: [settingsFile] };
|
|
3088
|
+
};
|
|
3089
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
3090
|
+
async function spawnCmd(ctx, cmd, args) {
|
|
3091
|
+
const violation = checkCmdString(cmd);
|
|
3092
|
+
if (violation) {
|
|
3093
|
+
return {
|
|
3094
|
+
ok: false,
|
|
3095
|
+
phase: "preflight",
|
|
3096
|
+
error: {
|
|
3097
|
+
file: ctx.manifest.metadata.name,
|
|
3098
|
+
path: "/spec/install/cmd",
|
|
3099
|
+
message: `shell escape detected at spawn boundary: '${violation.label}' (${violation.hint}) \u2014 refusing to execute. v0.1 forbids dynamic shell evaluation; this is a defense-in-depth gate after schema validation.`,
|
|
3100
|
+
line: null,
|
|
3101
|
+
column: null,
|
|
3102
|
+
keyword: "security-gate-bypass"
|
|
3103
|
+
}
|
|
3104
|
+
};
|
|
3105
|
+
}
|
|
3106
|
+
const installCfg = ctx.manifest.spec.install;
|
|
3107
|
+
const verifyCfg = ctx.manifest.spec.verify;
|
|
3108
|
+
const timeoutMs = verifyCfg.timeout_ms ?? DEFAULT_TIMEOUT_MS;
|
|
3109
|
+
const env = { ...process.env, ...installCfg.env ?? {} };
|
|
3110
|
+
const cwd = installCfg.cwd ?? ctx.cwd;
|
|
3111
|
+
let child;
|
|
3112
|
+
if (process.platform === "win32") {
|
|
3113
|
+
child = spawn("cmd.exe", ["/c", cmd, ...args], { cwd, env, windowsHide: true });
|
|
3114
|
+
} else {
|
|
3115
|
+
const joined = args.length > 0 ? `${cmd} ${args.join(" ")}` : cmd;
|
|
3116
|
+
child = spawn("/bin/sh", ["-c", joined], { cwd, env });
|
|
3117
|
+
}
|
|
3118
|
+
let stdout2 = "";
|
|
3119
|
+
let stderr = "";
|
|
3120
|
+
child.stdout?.setEncoding("utf8").on("data", (chunk) => {
|
|
3121
|
+
stdout2 += chunk;
|
|
3122
|
+
});
|
|
3123
|
+
child.stderr?.setEncoding("utf8").on("data", (chunk) => {
|
|
3124
|
+
stderr += chunk;
|
|
3125
|
+
});
|
|
3126
|
+
return await new Promise((resolve8) => {
|
|
3127
|
+
const timer = setTimeout(() => {
|
|
3128
|
+
child.kill("SIGKILL");
|
|
3129
|
+
resolve8({
|
|
3130
|
+
ok: false,
|
|
3131
|
+
phase: "spawn",
|
|
3132
|
+
error: {
|
|
3133
|
+
file: ctx.manifest.metadata.name,
|
|
3134
|
+
path: "/spec/install/cmd",
|
|
3135
|
+
message: `spawn timed out after ${timeoutMs}ms (cmd: ${cmd}); partial stderr: ${stderr.slice(0, 200)}`,
|
|
3136
|
+
line: null,
|
|
3137
|
+
column: null,
|
|
3138
|
+
keyword: "spawn-timeout"
|
|
3139
|
+
}
|
|
3140
|
+
});
|
|
3141
|
+
}, timeoutMs);
|
|
3142
|
+
child.on("error", (err2) => {
|
|
3143
|
+
clearTimeout(timer);
|
|
3144
|
+
resolve8({
|
|
3145
|
+
ok: false,
|
|
3146
|
+
phase: "spawn",
|
|
3147
|
+
error: {
|
|
3148
|
+
file: ctx.manifest.metadata.name,
|
|
3149
|
+
path: "/spec/install/cmd",
|
|
3150
|
+
message: `spawn failed: ${err2.message}`,
|
|
3151
|
+
line: null,
|
|
3152
|
+
column: null,
|
|
3153
|
+
keyword: "spawn-error"
|
|
3154
|
+
}
|
|
3155
|
+
});
|
|
3156
|
+
});
|
|
3157
|
+
child.on("close", (code) => {
|
|
3158
|
+
clearTimeout(timer);
|
|
3159
|
+
resolve8({ ok: true, exitCode: code ?? -1, stdout: stdout2, stderr });
|
|
3160
|
+
});
|
|
3161
|
+
});
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
// src/installers/gitCloneWithSetup.ts
|
|
3165
|
+
function gitRevParseHead(cwd, timeoutMs = 1e4) {
|
|
3166
|
+
return new Promise((resolve8) => {
|
|
3167
|
+
const isWin = process.platform === "win32";
|
|
3168
|
+
const child = isWin ? spawn("cmd.exe", ["/c", "git", "rev-parse", "HEAD"], { cwd, windowsHide: true }) : spawn("git", ["rev-parse", "HEAD"], { cwd, shell: false });
|
|
3169
|
+
let stdout2 = "";
|
|
3170
|
+
child.stdout?.setEncoding("utf8").on("data", (c) => {
|
|
3171
|
+
stdout2 += c;
|
|
3172
|
+
});
|
|
3173
|
+
const timer = setTimeout(() => {
|
|
3174
|
+
child.kill("SIGKILL");
|
|
3175
|
+
resolve8({ sha: "", exit: -1 });
|
|
3176
|
+
}, timeoutMs);
|
|
3177
|
+
child.on("error", () => {
|
|
3178
|
+
clearTimeout(timer);
|
|
3179
|
+
resolve8({ sha: "", exit: -1 });
|
|
3180
|
+
});
|
|
3181
|
+
child.on("close", (code) => {
|
|
3182
|
+
clearTimeout(timer);
|
|
3183
|
+
resolve8({ sha: stdout2.trim(), exit: code ?? -1 });
|
|
3184
|
+
});
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
3187
|
+
function extractCloneTarget(cmd) {
|
|
3188
|
+
const idx = cmd.indexOf("git clone");
|
|
3189
|
+
if (idx < 0) return null;
|
|
3190
|
+
const tail = cmd.slice(idx + "git clone".length).trim();
|
|
3191
|
+
const tokens = tail.split(/\s+/);
|
|
3192
|
+
let i = 0;
|
|
3193
|
+
while (i < tokens.length) {
|
|
3194
|
+
const t = tokens[i];
|
|
3195
|
+
if (t === void 0 || !t.startsWith("-")) break;
|
|
3196
|
+
if (t === "--depth" || t === "--branch" || t === "-b") {
|
|
3197
|
+
i += 2;
|
|
3198
|
+
} else if (t.includes("=")) {
|
|
3199
|
+
i += 1;
|
|
3200
|
+
} else {
|
|
3201
|
+
i += 2;
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
const dest = tokens[i + 1];
|
|
3205
|
+
if (!dest || dest === "&&" || dest === ";" || dest === "|") return null;
|
|
3206
|
+
if (dest.startsWith("~/")) {
|
|
3207
|
+
const home = process.env.HOME ?? process.env.USERPROFILE;
|
|
3208
|
+
if (!home) return null;
|
|
3209
|
+
return `${home}${dest.slice(1)}`;
|
|
3210
|
+
}
|
|
3211
|
+
return dest;
|
|
3212
|
+
}
|
|
3213
|
+
var installGitCloneWithSetup = async (ctx) => {
|
|
3214
|
+
const install = ctx.manifest.spec.install;
|
|
3215
|
+
if (install.method !== "git-clone-with-setup") {
|
|
3216
|
+
return {
|
|
3217
|
+
ok: false,
|
|
3218
|
+
phase: "preflight",
|
|
3219
|
+
error: err(
|
|
3220
|
+
ctx,
|
|
3221
|
+
"/spec/install/method",
|
|
3222
|
+
`installGitCloneWithSetup received non-git-clone-with-setup method '${install.method}' (dispatch bug)`,
|
|
3223
|
+
"dispatch-mismatch"
|
|
3224
|
+
)
|
|
3225
|
+
};
|
|
3226
|
+
}
|
|
3227
|
+
const pre = preflight(ctx);
|
|
3228
|
+
if (!pre.ok) {
|
|
3229
|
+
if (pre.abortReason === "platform-mismatch")
|
|
3230
|
+
return { aborted: true, reason: "platform-mismatch" };
|
|
3231
|
+
const e = pre.errors[0] ?? err(ctx, "/", "preflight failed (no detail)", "preflight");
|
|
3232
|
+
return { ok: false, phase: "preflight", error: e };
|
|
3233
|
+
}
|
|
3234
|
+
if (!/^[a-f0-9]{7,40}$/.test(install.git_ref)) {
|
|
3235
|
+
return {
|
|
3236
|
+
ok: false,
|
|
3237
|
+
phase: "preflight",
|
|
3238
|
+
error: {
|
|
3239
|
+
...err(
|
|
3240
|
+
ctx,
|
|
3241
|
+
"/spec/install/git_ref",
|
|
3242
|
+
`git-clone-with-setup requires a full SHA git_ref (7-40 hex), got '${install.git_ref}' (ADR 0001 reproducibility \u2014 SHA is the only stable authority for git_rev-parse HEAD verification)`,
|
|
3243
|
+
"sha-required"
|
|
3244
|
+
),
|
|
3245
|
+
suggest: `pin git_ref to a 40-hex commit SHA from the upstream repo (e.g. \`git rev-parse <tag-or-branch>\` in the source)`
|
|
3246
|
+
}
|
|
3247
|
+
};
|
|
3248
|
+
}
|
|
3249
|
+
const cloneTarget = extractCloneTarget(install.cmd);
|
|
3250
|
+
if (!cloneTarget) {
|
|
3251
|
+
return {
|
|
3252
|
+
ok: false,
|
|
3253
|
+
phase: "preflight",
|
|
3254
|
+
error: {
|
|
3255
|
+
...err(
|
|
3256
|
+
ctx,
|
|
3257
|
+
"/spec/install/cmd",
|
|
3258
|
+
`git-clone-with-setup cmd does not contain a parseable \`git clone <url> <dest>\` invocation; D-15 SHA-verify requires an explicit destination directory`,
|
|
3259
|
+
"git-clone-shape"
|
|
3260
|
+
),
|
|
3261
|
+
suggest: "use `git clone [flags] <url> <dest>` with an explicit destination directory"
|
|
3262
|
+
}
|
|
3263
|
+
};
|
|
3264
|
+
}
|
|
3265
|
+
const name = ctx.manifest.metadata.name;
|
|
3266
|
+
const plan = {
|
|
3267
|
+
files: [
|
|
3268
|
+
{
|
|
3269
|
+
target: cloneTarget,
|
|
3270
|
+
scope: "HOME",
|
|
3271
|
+
oldText: "",
|
|
3272
|
+
newText: `// new directory created by: ${install.cmd}
|
|
3273
|
+
// pinned at git_ref ${install.git_ref}
|
|
3274
|
+
`
|
|
3275
|
+
}
|
|
3276
|
+
]
|
|
3277
|
+
};
|
|
3278
|
+
process.stdout.write(renderDiff(plan, ctx));
|
|
3279
|
+
const conf = await confirmAt("L2", { ...ctx});
|
|
3280
|
+
if (!conf.proceed) {
|
|
3281
|
+
const reason = conf.reason === "flag-missing" ? "level-flag-missing" : "user-cancel";
|
|
3282
|
+
return { aborted: true, reason };
|
|
3283
|
+
}
|
|
3284
|
+
if (ctx.opts.dryRun) return { aborted: true, reason: "user-cancel" };
|
|
3285
|
+
const bk = await backup(plan, ctx);
|
|
3286
|
+
if (!bk.ok) return { ok: false, phase: "preflight", error: bk.error };
|
|
3287
|
+
const sp = await spawnCmd(ctx, install.cmd, []);
|
|
3288
|
+
if (!("exitCode" in sp)) return { ...sp, backupId: bk.backupId };
|
|
3289
|
+
if (sp.exitCode !== 0) {
|
|
3290
|
+
return {
|
|
3291
|
+
ok: false,
|
|
3292
|
+
phase: "spawn",
|
|
3293
|
+
backupId: bk.backupId,
|
|
3294
|
+
error: err(
|
|
3295
|
+
ctx,
|
|
3296
|
+
"/spec/install/cmd",
|
|
3297
|
+
`git-clone-with-setup cmd exited ${sp.exitCode}: ${sp.stderr.slice(0, 200)}`,
|
|
3298
|
+
"install-failed"
|
|
3299
|
+
)
|
|
3300
|
+
};
|
|
3301
|
+
}
|
|
3302
|
+
const rp = await gitRevParseHead(cloneTarget);
|
|
3303
|
+
if (rp.exit !== 0 || !rp.sha) {
|
|
3304
|
+
return {
|
|
3305
|
+
ok: false,
|
|
3306
|
+
phase: "verify",
|
|
3307
|
+
backupId: bk.backupId,
|
|
3308
|
+
error: err(
|
|
3309
|
+
ctx,
|
|
3310
|
+
"/spec/install/git_ref",
|
|
3311
|
+
`git rev-parse HEAD failed in ${cloneTarget} (exit ${rp.exit}); cannot verify SHA pin '${install.git_ref}'`,
|
|
3312
|
+
"sha-mismatch"
|
|
3313
|
+
)
|
|
3314
|
+
};
|
|
3315
|
+
}
|
|
3316
|
+
if (!rp.sha.startsWith(install.git_ref)) {
|
|
3317
|
+
return {
|
|
3318
|
+
ok: false,
|
|
3319
|
+
phase: "verify",
|
|
3320
|
+
backupId: bk.backupId,
|
|
3321
|
+
error: err(
|
|
3322
|
+
ctx,
|
|
3323
|
+
"/spec/install/git_ref",
|
|
3324
|
+
`git_ref SHA mismatch: manifest pinned '${install.git_ref}' but HEAD is '${rp.sha}' in ${cloneTarget}`,
|
|
3325
|
+
"sha-mismatch"
|
|
3326
|
+
)
|
|
3327
|
+
};
|
|
3328
|
+
}
|
|
3329
|
+
const vr = await spawnCmd(ctx, ctx.manifest.spec.verify.cmd, []);
|
|
3330
|
+
if (!("exitCode" in vr)) return { ...vr, backupId: bk.backupId };
|
|
3331
|
+
const expected = ctx.manifest.spec.verify.expected_exit_code ?? 0;
|
|
3332
|
+
if (vr.exitCode !== expected) {
|
|
3333
|
+
return {
|
|
3334
|
+
ok: false,
|
|
3335
|
+
phase: "verify",
|
|
3336
|
+
backupId: bk.backupId,
|
|
3337
|
+
error: err(
|
|
3338
|
+
ctx,
|
|
3339
|
+
"/spec/verify/cmd",
|
|
3340
|
+
`verify exit ${vr.exitCode} \u2260 expected ${expected}: ${vr.stderr.slice(0, 200)}`,
|
|
3341
|
+
"verify-failed"
|
|
3342
|
+
)
|
|
3343
|
+
};
|
|
3344
|
+
}
|
|
3345
|
+
await updateInstalled(ctx.cwd, name, install.git_ref, "");
|
|
3346
|
+
return { ok: true, backupId: bk.backupId, appliedFiles: [cloneTarget] };
|
|
3347
|
+
};
|
|
3348
|
+
function resolveEnvVars(value) {
|
|
3349
|
+
const pattern = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
|
|
3350
|
+
let resolved = value;
|
|
3351
|
+
let match2;
|
|
3352
|
+
pattern.lastIndex = 0;
|
|
3353
|
+
while ((match2 = pattern.exec(value)) !== null) {
|
|
3354
|
+
const name = match2[1];
|
|
3355
|
+
if (name === void 0) continue;
|
|
3356
|
+
const v = process.env[name];
|
|
3357
|
+
if (v === void 0 || v === "") {
|
|
3358
|
+
return { ok: false, missing: name };
|
|
3359
|
+
}
|
|
3360
|
+
resolved = resolved.replace(match2[0], v);
|
|
3361
|
+
}
|
|
3362
|
+
return { ok: true, resolved };
|
|
3363
|
+
}
|
|
3364
|
+
function resolveHeaders(cmd) {
|
|
3365
|
+
const flat = [];
|
|
3366
|
+
const re = /--header\s+(?:"([^"]+)"|'([^']+)'|(\S+))/g;
|
|
3367
|
+
let m;
|
|
3368
|
+
while ((m = re.exec(cmd)) !== null) {
|
|
3369
|
+
const raw = m[1] ?? m[2] ?? m[3];
|
|
3370
|
+
if (raw === void 0 || raw.length === 0) continue;
|
|
3371
|
+
const res = resolveEnvVars(raw);
|
|
3372
|
+
if (!res.ok) return { ok: false, missing: res.missing };
|
|
3373
|
+
flat.push("--header", res.resolved);
|
|
3374
|
+
}
|
|
3375
|
+
return { ok: true, flat };
|
|
3376
|
+
}
|
|
3377
|
+
function extractUrl(cmd) {
|
|
3378
|
+
const m = cmd.match(/\bhttps?:\/\/\S+/);
|
|
3379
|
+
return m ? m[0] : null;
|
|
3380
|
+
}
|
|
3381
|
+
var installMcpHttpAdd = async (ctx) => {
|
|
3382
|
+
const install = ctx.manifest.spec.install;
|
|
3383
|
+
if (install.method !== "mcp-http-add") {
|
|
3384
|
+
return {
|
|
3385
|
+
ok: false,
|
|
3386
|
+
phase: "preflight",
|
|
3387
|
+
error: err(
|
|
3388
|
+
ctx,
|
|
3389
|
+
"/spec/install/method",
|
|
3390
|
+
`installMcpHttpAdd received non-mcp-http-add method '${install.method}' (dispatch bug)`,
|
|
3391
|
+
"dispatch-mismatch"
|
|
3392
|
+
)
|
|
3393
|
+
};
|
|
3394
|
+
}
|
|
3395
|
+
const pre = preflight(ctx);
|
|
3396
|
+
if (!pre.ok) {
|
|
3397
|
+
if (pre.abortReason === "platform-mismatch")
|
|
3398
|
+
return { aborted: true, reason: "platform-mismatch" };
|
|
3399
|
+
const e = pre.errors[0] ?? err(ctx, "/", "preflight failed (no detail)", "preflight");
|
|
3400
|
+
return { ok: false, phase: "preflight", error: e };
|
|
3401
|
+
}
|
|
3402
|
+
const name = ctx.manifest.metadata.name;
|
|
3403
|
+
const url = extractUrl(install.cmd);
|
|
3404
|
+
if (!url) {
|
|
3405
|
+
return {
|
|
3406
|
+
ok: false,
|
|
3407
|
+
phase: "preflight",
|
|
3408
|
+
error: err(
|
|
3409
|
+
ctx,
|
|
3410
|
+
"/spec/install/cmd",
|
|
3411
|
+
`mcp-http-add cmd missing http(s):// URL token (parsed from install.cmd: '${install.cmd.slice(0, 80)}')`,
|
|
3412
|
+
"http-url-missing"
|
|
3413
|
+
)
|
|
3414
|
+
};
|
|
3415
|
+
}
|
|
3416
|
+
const hdr = resolveHeaders(install.cmd);
|
|
3417
|
+
if (!hdr.ok) {
|
|
3418
|
+
return {
|
|
3419
|
+
ok: false,
|
|
3420
|
+
phase: "preflight",
|
|
3421
|
+
error: {
|
|
3422
|
+
...err(
|
|
3423
|
+
ctx,
|
|
3424
|
+
"/spec/install/cmd",
|
|
3425
|
+
`mcp-http-add --header references unset env var '${hdr.missing}'; set it (export ${hdr.missing}=...) or remove the header from the manifest`,
|
|
3426
|
+
"env-unset"
|
|
3427
|
+
),
|
|
3428
|
+
suggest: `export ${hdr.missing}=<value> && harnessed install <name>`
|
|
3429
|
+
}
|
|
3430
|
+
};
|
|
3431
|
+
}
|
|
3432
|
+
const addArgs = [
|
|
3433
|
+
"mcp",
|
|
3434
|
+
"add",
|
|
3435
|
+
"--scope",
|
|
3436
|
+
"project",
|
|
3437
|
+
"--transport",
|
|
3438
|
+
"http",
|
|
3439
|
+
...hdr.flat,
|
|
3440
|
+
name,
|
|
3441
|
+
url
|
|
3442
|
+
];
|
|
3443
|
+
for (const a of addArgs) {
|
|
3444
|
+
const violation2 = checkCmdString(a);
|
|
3445
|
+
if (violation2) {
|
|
3446
|
+
return {
|
|
3447
|
+
ok: false,
|
|
3448
|
+
phase: "preflight",
|
|
3449
|
+
error: err(
|
|
3450
|
+
ctx,
|
|
3451
|
+
"/spec/install/cmd",
|
|
3452
|
+
`shell escape detected in constructed mcp-http-add arg '${a.slice(0, 60)}': ${violation2.label} (${violation2.hint})`,
|
|
3453
|
+
"security-gate-bypass"
|
|
3454
|
+
)
|
|
3455
|
+
};
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
const mcpFile = `${ctx.cwd}/.mcp.json`;
|
|
3459
|
+
const headersObj = {};
|
|
3460
|
+
for (let i = 0; i < hdr.flat.length; i += 2) {
|
|
3461
|
+
const kv = hdr.flat[i + 1];
|
|
3462
|
+
if (!kv) continue;
|
|
3463
|
+
const ci = kv.indexOf(":");
|
|
3464
|
+
if (ci > 0) headersObj[kv.slice(0, ci).trim()] = kv.slice(ci + 1).trim();
|
|
3465
|
+
}
|
|
3466
|
+
const entry = Object.keys(headersObj).length > 0 ? { [name]: { type: "http", url, headers: headersObj } } : { [name]: { type: "http", url } };
|
|
3467
|
+
const newEntry = JSON.stringify(entry, null, 2);
|
|
3468
|
+
const plan = {
|
|
3469
|
+
files: [
|
|
3470
|
+
{
|
|
3471
|
+
target: mcpFile,
|
|
3472
|
+
scope: "PROJECT",
|
|
3473
|
+
oldText: "",
|
|
3474
|
+
newText: `// will be merged into .mcp.json mcpServers map by \`claude mcp add\`:
|
|
3475
|
+
${newEntry}
|
|
3476
|
+
`
|
|
3477
|
+
}
|
|
3478
|
+
]
|
|
3479
|
+
};
|
|
3480
|
+
process.stdout.write(renderDiff(plan, ctx));
|
|
3481
|
+
const conf = await confirmAt("L3", { ...ctx});
|
|
3482
|
+
if (!conf.proceed) {
|
|
3483
|
+
const reason = conf.reason === "flag-missing" ? "level-flag-missing" : "user-cancel";
|
|
3484
|
+
return { aborted: true, reason };
|
|
3485
|
+
}
|
|
3486
|
+
if (ctx.opts.dryRun) return { aborted: true, reason: "user-cancel" };
|
|
3487
|
+
const bk = await backup(plan, ctx);
|
|
3488
|
+
if (!bk.ok) return { ok: false, phase: "preflight", error: bk.error };
|
|
3489
|
+
const r = await runArgs(addArgs, install.cwd ?? ctx.cwd);
|
|
3490
|
+
if (r.exitCode !== 0) {
|
|
3491
|
+
return {
|
|
3492
|
+
ok: false,
|
|
3493
|
+
phase: "spawn",
|
|
3494
|
+
backupId: bk.backupId,
|
|
3495
|
+
error: err(
|
|
3496
|
+
ctx,
|
|
3497
|
+
"/spec/install/cmd",
|
|
3498
|
+
`claude mcp add (http) exited ${r.exitCode}: ${r.stderr.slice(0, 200)}`,
|
|
3499
|
+
"install-failed"
|
|
3500
|
+
)
|
|
3501
|
+
};
|
|
3502
|
+
}
|
|
3503
|
+
const verifyShell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
|
|
3504
|
+
const verifyFlag = process.platform === "win32" ? "/c" : "-c";
|
|
3505
|
+
const verifyLine = `claude mcp list | grep -q ${name}`;
|
|
3506
|
+
const violation = checkCmdString(verifyLine);
|
|
3507
|
+
if (violation) {
|
|
3508
|
+
return {
|
|
3509
|
+
ok: false,
|
|
3510
|
+
phase: "verify",
|
|
3511
|
+
backupId: bk.backupId,
|
|
3512
|
+
error: err(
|
|
3513
|
+
ctx,
|
|
3514
|
+
"/spec/verify/cmd",
|
|
3515
|
+
`verify shell escape: ${violation.label}`,
|
|
3516
|
+
"security-gate-bypass"
|
|
3517
|
+
)
|
|
3518
|
+
};
|
|
3519
|
+
}
|
|
3520
|
+
const vr = await new Promise((resolve8) => {
|
|
3521
|
+
const child = spawn(verifyShell, [verifyFlag, verifyLine], { cwd: ctx.cwd, windowsHide: true });
|
|
3522
|
+
let stderr = "";
|
|
3523
|
+
child.stderr?.setEncoding("utf8").on("data", (c) => {
|
|
3524
|
+
stderr += c;
|
|
3525
|
+
});
|
|
3526
|
+
const timer = setTimeout(() => {
|
|
3527
|
+
child.kill("SIGKILL");
|
|
3528
|
+
resolve8({ exitCode: -1, stderr: `${stderr}[timeout]` });
|
|
3529
|
+
}, 15e3);
|
|
3530
|
+
child.on("error", (e) => {
|
|
3531
|
+
clearTimeout(timer);
|
|
3532
|
+
resolve8({ exitCode: -1, stderr: e.message });
|
|
3533
|
+
});
|
|
3534
|
+
child.on("close", (code) => {
|
|
3535
|
+
clearTimeout(timer);
|
|
3536
|
+
resolve8({ exitCode: code ?? -1, stderr });
|
|
3537
|
+
});
|
|
3538
|
+
});
|
|
3539
|
+
if (vr.exitCode !== 0) {
|
|
3540
|
+
return {
|
|
3541
|
+
ok: false,
|
|
3542
|
+
phase: "verify",
|
|
3543
|
+
backupId: bk.backupId,
|
|
3544
|
+
error: err(
|
|
3545
|
+
ctx,
|
|
3546
|
+
"/spec/verify/cmd",
|
|
3547
|
+
`verify exit ${vr.exitCode}: ${vr.stderr.slice(0, 200)}`,
|
|
3548
|
+
"verify-failed"
|
|
3549
|
+
)
|
|
3550
|
+
};
|
|
3551
|
+
}
|
|
3552
|
+
await updateInstalled(ctx.cwd, name, install.npm_version, "");
|
|
3553
|
+
return { ok: true, backupId: bk.backupId, appliedFiles: [mcpFile] };
|
|
3554
|
+
};
|
|
3555
|
+
var installMcpStdioAdd = async (ctx) => {
|
|
3556
|
+
const install = ctx.manifest.spec.install;
|
|
3557
|
+
if (install.method !== "mcp-stdio-add") {
|
|
3558
|
+
return {
|
|
3559
|
+
ok: false,
|
|
3560
|
+
phase: "preflight",
|
|
3561
|
+
error: err(
|
|
3562
|
+
ctx,
|
|
3563
|
+
"/spec/install/method",
|
|
3564
|
+
`installMcpStdioAdd received non-mcp-stdio-add method '${install.method}' (dispatch bug)`,
|
|
3565
|
+
"dispatch-mismatch"
|
|
3566
|
+
)
|
|
3567
|
+
};
|
|
3568
|
+
}
|
|
3569
|
+
const pre = preflight(ctx);
|
|
3570
|
+
if (!pre.ok) {
|
|
3571
|
+
if (pre.abortReason === "platform-mismatch")
|
|
3572
|
+
return { aborted: true, reason: "platform-mismatch" };
|
|
3573
|
+
const e = pre.errors[0] ?? err(ctx, "/", "preflight failed (no detail)", "preflight");
|
|
3574
|
+
return { ok: false, phase: "preflight", error: e };
|
|
3575
|
+
}
|
|
3576
|
+
const name = ctx.manifest.metadata.name;
|
|
3577
|
+
const pkg = ctx.manifest.metadata.upstream.source;
|
|
3578
|
+
const ver = install.npm_version;
|
|
3579
|
+
const addArgs = [
|
|
3580
|
+
"mcp",
|
|
3581
|
+
"add",
|
|
3582
|
+
"--scope",
|
|
3583
|
+
"project",
|
|
3584
|
+
"--transport",
|
|
3585
|
+
"stdio",
|
|
3586
|
+
name,
|
|
3587
|
+
"--",
|
|
3588
|
+
"npx",
|
|
3589
|
+
"--yes",
|
|
3590
|
+
`${pkg}@${ver}`
|
|
3591
|
+
];
|
|
3592
|
+
for (const a of addArgs) {
|
|
3593
|
+
const violation2 = checkCmdString(a);
|
|
3594
|
+
if (violation2) {
|
|
3595
|
+
return {
|
|
3596
|
+
ok: false,
|
|
3597
|
+
phase: "preflight",
|
|
3598
|
+
error: err(
|
|
3599
|
+
ctx,
|
|
3600
|
+
"/spec/install/cmd",
|
|
3601
|
+
`shell escape detected in constructed mcp-add arg '${a.slice(0, 60)}': ${violation2.label} (${violation2.hint})`,
|
|
3602
|
+
"security-gate-bypass"
|
|
3603
|
+
)
|
|
3604
|
+
};
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
const mcpFile = `${ctx.cwd}/.mcp.json`;
|
|
3608
|
+
const newEntry = JSON.stringify(
|
|
3609
|
+
{ [name]: { type: "stdio", command: "npx", args: ["--yes", `${pkg}@${ver}`] } },
|
|
3610
|
+
null,
|
|
3611
|
+
2
|
|
3612
|
+
);
|
|
3613
|
+
const plan = {
|
|
3614
|
+
files: [
|
|
3615
|
+
{
|
|
3616
|
+
target: mcpFile,
|
|
3617
|
+
scope: "PROJECT",
|
|
3618
|
+
oldText: "",
|
|
3619
|
+
newText: `// will be merged into .mcp.json mcpServers map by \`claude mcp add\`:
|
|
3620
|
+
${newEntry}
|
|
3621
|
+
`
|
|
3622
|
+
}
|
|
3623
|
+
]
|
|
3624
|
+
};
|
|
3625
|
+
process.stdout.write(renderDiff(plan, ctx));
|
|
3626
|
+
const conf = await confirmAt("L3", { ...ctx});
|
|
3627
|
+
if (!conf.proceed) {
|
|
3628
|
+
const reason = conf.reason === "flag-missing" ? "level-flag-missing" : "user-cancel";
|
|
3629
|
+
return { aborted: true, reason };
|
|
3630
|
+
}
|
|
3631
|
+
if (ctx.opts.dryRun) return { aborted: true, reason: "user-cancel" };
|
|
3632
|
+
const bk = await backup(plan, ctx);
|
|
3633
|
+
if (!bk.ok) return { ok: false, phase: "preflight", error: bk.error };
|
|
3634
|
+
const r = await runArgs(addArgs, install.cwd ?? ctx.cwd);
|
|
3635
|
+
if (r.exitCode !== 0) {
|
|
3636
|
+
return {
|
|
3637
|
+
ok: false,
|
|
3638
|
+
phase: "spawn",
|
|
3639
|
+
backupId: bk.backupId,
|
|
3640
|
+
error: err(
|
|
3641
|
+
ctx,
|
|
3642
|
+
"/spec/install/cmd",
|
|
3643
|
+
`claude mcp add exited ${r.exitCode}: ${r.stderr.slice(0, 200)}`,
|
|
3644
|
+
"install-failed"
|
|
3645
|
+
)
|
|
3646
|
+
};
|
|
3647
|
+
}
|
|
3648
|
+
const verifyShell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
|
|
3649
|
+
const verifyFlag = process.platform === "win32" ? "/c" : "-c";
|
|
3650
|
+
const verifyLine = `claude mcp list | grep -q ${name}`;
|
|
3651
|
+
const violation = checkCmdString(verifyLine);
|
|
3652
|
+
if (violation) {
|
|
3653
|
+
return {
|
|
3654
|
+
ok: false,
|
|
3655
|
+
phase: "verify",
|
|
3656
|
+
backupId: bk.backupId,
|
|
3657
|
+
error: err(
|
|
3658
|
+
ctx,
|
|
3659
|
+
"/spec/verify/cmd",
|
|
3660
|
+
`verify shell escape: ${violation.label}`,
|
|
3661
|
+
"security-gate-bypass"
|
|
3662
|
+
)
|
|
3663
|
+
};
|
|
3664
|
+
}
|
|
3665
|
+
const vr = await new Promise((resolve8) => {
|
|
3666
|
+
const child = spawn(verifyShell, [verifyFlag, verifyLine], { cwd: ctx.cwd, windowsHide: true });
|
|
3667
|
+
let stderr = "";
|
|
3668
|
+
child.stderr?.setEncoding("utf8").on("data", (c) => {
|
|
3669
|
+
stderr += c;
|
|
3670
|
+
});
|
|
3671
|
+
const timer = setTimeout(() => {
|
|
3672
|
+
child.kill("SIGKILL");
|
|
3673
|
+
resolve8({ exitCode: -1, stderr: `${stderr}[timeout]` });
|
|
3674
|
+
}, 15e3);
|
|
3675
|
+
child.on("error", (e) => {
|
|
3676
|
+
clearTimeout(timer);
|
|
3677
|
+
resolve8({ exitCode: -1, stderr: e.message });
|
|
3678
|
+
});
|
|
3679
|
+
child.on("close", (code) => {
|
|
3680
|
+
clearTimeout(timer);
|
|
3681
|
+
resolve8({ exitCode: code ?? -1, stderr });
|
|
3682
|
+
});
|
|
3683
|
+
});
|
|
3684
|
+
if (vr.exitCode !== 0) {
|
|
3685
|
+
return {
|
|
3686
|
+
ok: false,
|
|
3687
|
+
phase: "verify",
|
|
3688
|
+
backupId: bk.backupId,
|
|
3689
|
+
error: err(
|
|
3690
|
+
ctx,
|
|
3691
|
+
"/spec/verify/cmd",
|
|
3692
|
+
`verify exit ${vr.exitCode}: ${vr.stderr.slice(0, 200)}`,
|
|
3693
|
+
"verify-failed"
|
|
3694
|
+
)
|
|
3695
|
+
};
|
|
3696
|
+
}
|
|
3697
|
+
await updateInstalled(ctx.cwd, name, ver, "");
|
|
3698
|
+
return { ok: true, backupId: bk.backupId, appliedFiles: [mcpFile] };
|
|
3699
|
+
};
|
|
3700
|
+
function detectLevel(cmd) {
|
|
3701
|
+
if (/\bnpm\s+install\s+-g\b/.test(cmd)) return "L4";
|
|
3702
|
+
if (/\bnpx\b/.test(cmd)) return "L1";
|
|
3703
|
+
return "L4";
|
|
3704
|
+
}
|
|
3705
|
+
var installNpmCli = async (ctx) => {
|
|
3706
|
+
const install = ctx.manifest.spec.install;
|
|
3707
|
+
if (install.method !== "npm-cli") {
|
|
3708
|
+
return {
|
|
3709
|
+
ok: false,
|
|
3710
|
+
phase: "preflight",
|
|
3711
|
+
error: err(
|
|
3712
|
+
ctx,
|
|
3713
|
+
"/spec/install/method",
|
|
3714
|
+
`installNpmCli received non-npm-cli method '${install.method}' (dispatch bug)`,
|
|
3715
|
+
"dispatch-mismatch"
|
|
3716
|
+
)
|
|
3717
|
+
};
|
|
3718
|
+
}
|
|
3719
|
+
const pre = preflight(ctx);
|
|
3720
|
+
if (!pre.ok) {
|
|
3721
|
+
if (pre.abortReason === "platform-mismatch")
|
|
3722
|
+
return { aborted: true, reason: "platform-mismatch" };
|
|
3723
|
+
const e = pre.errors[0] ?? err(ctx, "/", "preflight failed (no detail)", "preflight");
|
|
3724
|
+
return { ok: false, phase: "preflight", error: e };
|
|
3725
|
+
}
|
|
3726
|
+
let level = detectLevel(install.cmd);
|
|
3727
|
+
let cmd = install.cmd;
|
|
3728
|
+
const plan = { files: [] };
|
|
3729
|
+
process.stdout.write(renderDiff(plan, ctx));
|
|
3730
|
+
if (level === "L4")
|
|
3731
|
+
process.stdout.write(" (L4 system install \u2014 global PATH change; see cmd above)\n");
|
|
3732
|
+
const conf = await confirmAt(level, { ...ctx});
|
|
3733
|
+
if (!conf.proceed) {
|
|
3734
|
+
if (level === "L4" && conf.reason === "flag-missing" && !ctx.opts.nonInteractive) {
|
|
3735
|
+
const choice = await p.select({
|
|
3736
|
+
message: "L4 install requires --system. Choose:",
|
|
3737
|
+
options: [
|
|
3738
|
+
{ value: "retry", label: "Retry with --system flag (re-run command)" },
|
|
3739
|
+
{ value: "npx", label: "Downgrade to L1 npx ephemeral install (no global)" },
|
|
3740
|
+
{ value: "abort", label: "Abort install" }
|
|
3741
|
+
],
|
|
3742
|
+
initialValue: "abort"
|
|
3743
|
+
});
|
|
3744
|
+
if (p.isCancel(choice) || choice === "abort") return { aborted: true, reason: "user-cancel" };
|
|
3745
|
+
if (choice === "retry") return { aborted: true, reason: "level-flag-missing" };
|
|
3746
|
+
cmd = `npx --yes ${ctx.manifest.metadata.upstream.source}@${install.npm_version}`;
|
|
3747
|
+
level = "L1";
|
|
3748
|
+
const conf2 = await confirmAt("L1", { ...ctx});
|
|
3749
|
+
if (!conf2.proceed) return { aborted: true, reason: "user-cancel" };
|
|
3750
|
+
} else {
|
|
3751
|
+
const reason = conf.reason === "flag-missing" ? "level-flag-missing" : "user-cancel";
|
|
3752
|
+
return { aborted: true, reason };
|
|
3753
|
+
}
|
|
3754
|
+
}
|
|
3755
|
+
if (ctx.opts.dryRun) return { aborted: true, reason: "user-cancel" };
|
|
3756
|
+
const bk = await backup(plan, ctx);
|
|
3757
|
+
if (!bk.ok) return { ok: false, phase: "preflight", error: bk.error };
|
|
3758
|
+
const sp = await spawnCmd(ctx, cmd, []);
|
|
3759
|
+
if (!("exitCode" in sp)) return { ...sp, backupId: bk.backupId };
|
|
3760
|
+
if (sp.exitCode !== 0) {
|
|
3761
|
+
return {
|
|
3762
|
+
ok: false,
|
|
3763
|
+
phase: "spawn",
|
|
3764
|
+
backupId: bk.backupId,
|
|
3765
|
+
error: err(
|
|
3766
|
+
ctx,
|
|
3767
|
+
"/spec/install/cmd",
|
|
3768
|
+
`install cmd exited ${sp.exitCode}: ${sp.stderr.slice(0, 200)}`,
|
|
3769
|
+
"install-failed"
|
|
3770
|
+
)
|
|
3771
|
+
};
|
|
3772
|
+
}
|
|
3773
|
+
const vr = await spawnCmd(ctx, ctx.manifest.spec.verify.cmd, []);
|
|
3774
|
+
if (!("exitCode" in vr)) return { ...vr, backupId: bk.backupId };
|
|
3775
|
+
const expected = ctx.manifest.spec.verify.expected_exit_code ?? 0;
|
|
3776
|
+
if (vr.exitCode !== expected) {
|
|
3777
|
+
return {
|
|
3778
|
+
ok: false,
|
|
3779
|
+
phase: "verify",
|
|
3780
|
+
backupId: bk.backupId,
|
|
3781
|
+
error: err(
|
|
3782
|
+
ctx,
|
|
3783
|
+
"/spec/verify/cmd",
|
|
3784
|
+
`verify exit ${vr.exitCode} \u2260 expected ${expected}`,
|
|
3785
|
+
"verify-failed"
|
|
3786
|
+
)
|
|
3787
|
+
};
|
|
3788
|
+
}
|
|
3789
|
+
await updateInstalled(ctx.cwd, ctx.manifest.metadata.name, install.npm_version, "");
|
|
3790
|
+
return { ok: true, backupId: bk.backupId, appliedFiles: [] };
|
|
3791
|
+
};
|
|
3792
|
+
function extractSkillName(cmd, fallback) {
|
|
3793
|
+
const m = cmd.match(/\bskills(?:@\S+)?\s+add\s+(\S+)/i);
|
|
3794
|
+
if (!m || m[1] === void 0) return fallback;
|
|
3795
|
+
const ref = m[1];
|
|
3796
|
+
const seg = ref.split("/");
|
|
3797
|
+
return seg[seg.length - 1] ?? fallback;
|
|
3798
|
+
}
|
|
3799
|
+
var installNpxSkillInstaller = async (ctx) => {
|
|
3800
|
+
const install = ctx.manifest.spec.install;
|
|
3801
|
+
if (install.method !== "npx-skill-installer") {
|
|
3802
|
+
return {
|
|
3803
|
+
ok: false,
|
|
3804
|
+
phase: "preflight",
|
|
3805
|
+
error: err(
|
|
3806
|
+
ctx,
|
|
3807
|
+
"/spec/install/method",
|
|
3808
|
+
`installNpxSkillInstaller received non-npx-skill-installer method '${install.method}' (dispatch bug)`,
|
|
3809
|
+
"dispatch-mismatch"
|
|
3810
|
+
)
|
|
3811
|
+
};
|
|
3812
|
+
}
|
|
3813
|
+
const pre = preflight(ctx);
|
|
3814
|
+
if (!pre.ok) {
|
|
3815
|
+
if (pre.abortReason === "platform-mismatch")
|
|
3816
|
+
return { aborted: true, reason: "platform-mismatch" };
|
|
3817
|
+
const e = pre.errors[0] ?? err(ctx, "/", "preflight failed (no detail)", "preflight");
|
|
3818
|
+
return { ok: false, phase: "preflight", error: e };
|
|
3819
|
+
}
|
|
3820
|
+
if (!/\bskills@(?!latest\b)\S+/.test(install.cmd)) {
|
|
3821
|
+
return {
|
|
3822
|
+
ok: false,
|
|
3823
|
+
phase: "preflight",
|
|
3824
|
+
error: {
|
|
3825
|
+
...err(
|
|
3826
|
+
ctx,
|
|
3827
|
+
"/spec/install/cmd",
|
|
3828
|
+
`npx-skill-installer cmd must reference a pinned skills@<version> (got: '${install.cmd.slice(0, 100)}'); @latest is forbidden for reproducibility (ADR 0001)`,
|
|
3829
|
+
"skills-pin-required"
|
|
3830
|
+
),
|
|
3831
|
+
suggest: "change `skills@latest` \u2192 `skills@1.5.7` (current research-pinned stable)"
|
|
3832
|
+
}
|
|
3833
|
+
};
|
|
3834
|
+
}
|
|
3835
|
+
if (!/\B--copy\b/.test(install.cmd) || !/\B--global\b/.test(install.cmd)) {
|
|
3836
|
+
return {
|
|
3837
|
+
ok: false,
|
|
3838
|
+
phase: "preflight",
|
|
3839
|
+
error: {
|
|
3840
|
+
...err(
|
|
3841
|
+
ctx,
|
|
3842
|
+
"/spec/install/cmd",
|
|
3843
|
+
`npx-skill-installer cmd must include both \`--copy\` and \`--global\` flags (D2.1-5)`,
|
|
3844
|
+
"skills-flags-required"
|
|
3845
|
+
),
|
|
3846
|
+
suggest: "`--copy` materializes files (Windows symlink-safe); `--global` targets ~/.claude/skills/ (user scope)"
|
|
3847
|
+
}
|
|
3848
|
+
};
|
|
3849
|
+
}
|
|
3850
|
+
const name = ctx.manifest.metadata.name;
|
|
3851
|
+
const skillSegment = extractSkillName(install.cmd, name);
|
|
3852
|
+
const skillDir = join(homedir(), ".claude", "skills", skillSegment);
|
|
3853
|
+
const skillMdPath = join(skillDir, "SKILL.md");
|
|
3854
|
+
const plan = {
|
|
3855
|
+
files: [
|
|
3856
|
+
{
|
|
3857
|
+
target: skillMdPath,
|
|
3858
|
+
scope: "HOME",
|
|
3859
|
+
oldText: "",
|
|
3860
|
+
newText: `// new SKILL.md created by: ${install.cmd}
|
|
3861
|
+
`
|
|
3862
|
+
}
|
|
3863
|
+
]
|
|
3864
|
+
};
|
|
3865
|
+
process.stdout.write(renderDiff(plan, ctx));
|
|
3866
|
+
const conf = await confirmAt("L2", { ...ctx});
|
|
3867
|
+
if (!conf.proceed) {
|
|
3868
|
+
const reason = conf.reason === "flag-missing" ? "level-flag-missing" : "user-cancel";
|
|
3869
|
+
return { aborted: true, reason };
|
|
3870
|
+
}
|
|
3871
|
+
if (ctx.opts.dryRun) return { aborted: true, reason: "user-cancel" };
|
|
3872
|
+
const bk = await backup(plan, ctx);
|
|
3873
|
+
if (!bk.ok) return { ok: false, phase: "preflight", error: bk.error };
|
|
3874
|
+
const sp = await spawnCmd(ctx, install.cmd, []);
|
|
3875
|
+
if (!("exitCode" in sp)) return { ...sp, backupId: bk.backupId };
|
|
3876
|
+
if (sp.exitCode !== 0) {
|
|
3877
|
+
return {
|
|
3878
|
+
ok: false,
|
|
3879
|
+
phase: "spawn",
|
|
3880
|
+
backupId: bk.backupId,
|
|
3881
|
+
error: err(
|
|
3882
|
+
ctx,
|
|
3883
|
+
"/spec/install/cmd",
|
|
3884
|
+
`npx skills add exited ${sp.exitCode}: ${sp.stderr.slice(0, 200)}`,
|
|
3885
|
+
"install-failed"
|
|
3886
|
+
)
|
|
3887
|
+
};
|
|
3888
|
+
}
|
|
3889
|
+
try {
|
|
3890
|
+
await access(skillMdPath);
|
|
3891
|
+
} catch {
|
|
3892
|
+
return {
|
|
3893
|
+
ok: false,
|
|
3894
|
+
phase: "verify",
|
|
3895
|
+
backupId: bk.backupId,
|
|
3896
|
+
error: {
|
|
3897
|
+
...err(
|
|
3898
|
+
ctx,
|
|
3899
|
+
"/spec/verify/cmd",
|
|
3900
|
+
`npx skills add reported success but SKILL.md is missing at ${skillMdPath}; the skills CLI may have written to ~/.agents/ instead (D-02 bridge limitation) or the npm prefix is misconfigured`,
|
|
3901
|
+
"verify-failed"
|
|
3902
|
+
),
|
|
3903
|
+
suggest: `check if SKILL.md exists at ~/.agents/${skillSegment}/SKILL.md (skills CLI default on some systems); if so, copy or symlink it into ~/.claude/skills/${skillSegment}/`
|
|
3904
|
+
}
|
|
3905
|
+
};
|
|
3906
|
+
}
|
|
3907
|
+
const vr = await spawnCmd(ctx, ctx.manifest.spec.verify.cmd, []);
|
|
3908
|
+
if (!("exitCode" in vr)) return { ...vr, backupId: bk.backupId };
|
|
3909
|
+
const expected = ctx.manifest.spec.verify.expected_exit_code ?? 0;
|
|
3910
|
+
if (vr.exitCode !== expected) {
|
|
3911
|
+
return {
|
|
3912
|
+
ok: false,
|
|
3913
|
+
phase: "verify",
|
|
3914
|
+
backupId: bk.backupId,
|
|
3915
|
+
error: err(
|
|
3916
|
+
ctx,
|
|
3917
|
+
"/spec/verify/cmd",
|
|
3918
|
+
`manifest verify cmd exit ${vr.exitCode} \u2260 expected ${expected}: ${vr.stderr.slice(0, 200)}`,
|
|
3919
|
+
"verify-failed"
|
|
3920
|
+
)
|
|
3921
|
+
};
|
|
3922
|
+
}
|
|
3923
|
+
await updateInstalled(ctx.cwd, name, install.npm_version, "");
|
|
3924
|
+
return { ok: true, backupId: bk.backupId, appliedFiles: [skillMdPath] };
|
|
3925
|
+
};
|
|
3926
|
+
|
|
3927
|
+
// src/installers/index.ts
|
|
3928
|
+
var installers = {
|
|
3929
|
+
"npm-cli": installNpmCli,
|
|
3930
|
+
"mcp-stdio-add": installMcpStdioAdd,
|
|
3931
|
+
"cc-plugin-marketplace": installCcPluginMarketplace,
|
|
3932
|
+
"git-clone-with-setup": installGitCloneWithSetup,
|
|
3933
|
+
"npx-skill-installer": installNpxSkillInstaller,
|
|
3934
|
+
"mcp-http-add": installMcpHttpAdd,
|
|
3935
|
+
// Phase 2.4 W3 T3.1 (D-04 § 3.1) — 7th installer.
|
|
3936
|
+
"cc-hook-add": installCcHookAdd
|
|
3937
|
+
};
|
|
3938
|
+
function levelOf(manifest) {
|
|
3939
|
+
const method = manifest.spec.install.method;
|
|
3940
|
+
switch (method) {
|
|
3941
|
+
case "mcp-stdio-add":
|
|
3942
|
+
case "mcp-http-add":
|
|
3943
|
+
case "cc-plugin-marketplace":
|
|
3944
|
+
case "cc-hook-add":
|
|
3945
|
+
return "L3";
|
|
3946
|
+
case "git-clone-with-setup":
|
|
3947
|
+
case "npx-skill-installer":
|
|
3948
|
+
return "L2";
|
|
3949
|
+
case "npm-cli":
|
|
3950
|
+
return "L4";
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3953
|
+
async function runInstall(manifest, opts) {
|
|
3954
|
+
const installer = installers[manifest.spec.install.method];
|
|
3955
|
+
return installer({ manifest, opts, level: levelOf(manifest), cwd: process.cwd() });
|
|
3956
|
+
}
|
|
3957
|
+
|
|
3958
|
+
// src/cli/install.ts
|
|
3959
|
+
init_path_guard();
|
|
3960
|
+
function formatError(e) {
|
|
3961
|
+
const head = `error: ${e.message}`;
|
|
3962
|
+
const where = e.path && e.path !== "/" ? `
|
|
3963
|
+
at ${e.path}` : "";
|
|
3964
|
+
const tip = e.suggest ? `
|
|
3965
|
+
fix: ${e.suggest}` : "";
|
|
3966
|
+
return `${head}${where}${tip}`;
|
|
3967
|
+
}
|
|
3968
|
+
function registerInstall(program2) {
|
|
3969
|
+
program2.command("install <name>").description("Install an upstream (dry-run by default \u2014 pass --apply to execute)").option("--apply", "execute the install (default: dry-run preview only)").option("--dry-run", "force dry-run (overrides --apply if both are set)").option("--system", "allow L4 system-wide install (e.g. global npm install)").option("--non-interactive", "skip all prompts (CI / scripts) \u2014 requires --apply or --dry-run").option("--full-diff", "expand diffs longer than 200 lines").option("--no-color", "disable ANSI colors (auto-detected when piped)").option(
|
|
3970
|
+
"--known-good",
|
|
3971
|
+
"use known-good version lock from versions/<harnessed-ver>-known-good.yaml"
|
|
3972
|
+
).action(async (name, raw) => {
|
|
3973
|
+
validateNonInteractiveFlags(raw, "install <name>");
|
|
3974
|
+
const { resolveAlias: resolveAlias2 } = await Promise.resolve().then(() => (init_aliases(), aliases_exports));
|
|
3975
|
+
const resolvedName = resolveAlias2(name) ?? name;
|
|
3976
|
+
checkPathSafe(resolvedName);
|
|
3977
|
+
const manifestPath = resolve(process.cwd(), `manifests/tools/${resolvedName}.yaml`);
|
|
3978
|
+
const skillPackPath = resolve(process.cwd(), `manifests/skill-packs/${resolvedName}.yaml`);
|
|
3979
|
+
let yamlSrc;
|
|
3980
|
+
let chosenPath = manifestPath;
|
|
3981
|
+
try {
|
|
3982
|
+
yamlSrc = await readFile(manifestPath, "utf8");
|
|
3983
|
+
} catch {
|
|
3984
|
+
try {
|
|
3985
|
+
yamlSrc = await readFile(skillPackPath, "utf8");
|
|
3986
|
+
chosenPath = skillPackPath;
|
|
3987
|
+
} catch {
|
|
3988
|
+
console.error(
|
|
3989
|
+
`error: manifest '${resolvedName}' not found
|
|
3990
|
+
fix: ensure manifests/tools/${resolvedName}.yaml or manifests/skill-packs/${resolvedName}.yaml exists`
|
|
3991
|
+
);
|
|
3992
|
+
process.exit(1);
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
const v = validateManifestFile(yamlSrc, chosenPath);
|
|
3996
|
+
if (!v.ok) {
|
|
3997
|
+
for (const e of v.errors) console.error(`error: ${e.message} at ${e.path}`);
|
|
3998
|
+
console.error(` fix: run 'harnessed audit' to inspect manifest issues`);
|
|
3999
|
+
process.exit(1);
|
|
4000
|
+
}
|
|
4001
|
+
const opts = {
|
|
4002
|
+
apply: raw.apply === true,
|
|
4003
|
+
dryRun: raw.dryRun === true,
|
|
4004
|
+
system: raw.system === true,
|
|
4005
|
+
nonInteractive: raw.nonInteractive === true,
|
|
4006
|
+
fullDiff: raw.fullDiff === true,
|
|
4007
|
+
color: raw.color === false ? false : "auto"
|
|
4008
|
+
};
|
|
4009
|
+
if (raw.knownGood) {
|
|
4010
|
+
const { getPinnedVersion: getPinnedVersion2 } = await Promise.resolve().then(() => (init_knownGood(), knownGood_exports));
|
|
4011
|
+
const harnessedVer = package_default.version;
|
|
4012
|
+
const pinned = getPinnedVersion2(v.manifest.metadata.name, harnessedVer);
|
|
4013
|
+
if (pinned && v.manifest.spec.install.method === "npm-cli") {
|
|
4014
|
+
v.manifest.spec.install.npm_version = pinned;
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
4017
|
+
const result = await runInstall(v.manifest, opts);
|
|
4018
|
+
if ("aborted" in result) {
|
|
4019
|
+
console.error(`aborted: ${result.reason}`);
|
|
4020
|
+
process.exit(2);
|
|
4021
|
+
}
|
|
4022
|
+
if (result.ok) {
|
|
4023
|
+
const version = v.manifest.spec.install.method === "npm-cli" && "npm_version" in v.manifest.spec.install ? v.manifest.spec.install.npm_version : "";
|
|
4024
|
+
console.log(`installed ${v.manifest.metadata.name}${version ? `@${version}` : ""}`);
|
|
4025
|
+
process.exit(0);
|
|
4026
|
+
}
|
|
4027
|
+
console.error(formatError(result.error));
|
|
4028
|
+
process.exit(1);
|
|
4029
|
+
});
|
|
4030
|
+
}
|
|
4031
|
+
var PHASE_21 = /* @__PURE__ */ new Set([
|
|
4032
|
+
"cc-plugin-marketplace",
|
|
4033
|
+
"git-clone-with-setup",
|
|
4034
|
+
"npx-skill-installer",
|
|
4035
|
+
"mcp-http-add"
|
|
4036
|
+
]);
|
|
4037
|
+
async function listBaseManifests(cwd) {
|
|
4038
|
+
const out = [];
|
|
4039
|
+
for (const d of ["manifests/tools", "manifests/skill-packs"]) {
|
|
4040
|
+
try {
|
|
4041
|
+
const entries = await readdir(resolve(cwd, d));
|
|
4042
|
+
for (const f of entries.sort()) if (f.endsWith(".yaml")) out.push(resolve(cwd, d, f));
|
|
4043
|
+
} catch {
|
|
4044
|
+
}
|
|
4045
|
+
}
|
|
4046
|
+
return out;
|
|
4047
|
+
}
|
|
4048
|
+
function registerInstallBase(program2) {
|
|
4049
|
+
program2.command("install-base").description("Install the phase 1.3 base profile (auto-glob manifests; dry-run by default)").option("--apply", "execute the install (default: dry-run preview only)").option("--dry-run", "force dry-run (overrides --apply if both are set)").option("--non-interactive", "skip all prompts (CI / scripts) \u2014 requires --apply or --dry-run").action(async (raw) => {
|
|
4050
|
+
validateNonInteractiveFlags(raw, "install-base");
|
|
4051
|
+
const opts = {
|
|
4052
|
+
apply: raw.apply === true,
|
|
4053
|
+
dryRun: raw.dryRun === true,
|
|
4054
|
+
system: false,
|
|
4055
|
+
nonInteractive: raw.nonInteractive === true,
|
|
4056
|
+
fullDiff: false,
|
|
4057
|
+
color: "auto"
|
|
4058
|
+
};
|
|
4059
|
+
const installed = [];
|
|
4060
|
+
const skipped = [];
|
|
4061
|
+
const failed = [];
|
|
4062
|
+
for (const path of await listBaseManifests(process.cwd())) {
|
|
4063
|
+
let yamlSrc;
|
|
4064
|
+
try {
|
|
4065
|
+
yamlSrc = await readFile(path, "utf8");
|
|
4066
|
+
} catch (e) {
|
|
4067
|
+
failed.push({ name: path, reason: `read: ${e.message}` });
|
|
4068
|
+
continue;
|
|
4069
|
+
}
|
|
4070
|
+
const v = validateManifestFile(yamlSrc, path);
|
|
4071
|
+
if (!v.ok) {
|
|
4072
|
+
failed.push({ name: path, reason: `validate: ${v.errors[0]?.message ?? "unknown"}` });
|
|
4073
|
+
continue;
|
|
4074
|
+
}
|
|
4075
|
+
const name = v.manifest.metadata.name;
|
|
4076
|
+
const method = v.manifest.spec.install.method;
|
|
4077
|
+
if (PHASE_21.has(method)) {
|
|
4078
|
+
skipped.push({ name, reason: `deferred phase 2.1 (${method})` });
|
|
4079
|
+
continue;
|
|
4080
|
+
}
|
|
4081
|
+
const r = await runInstall(v.manifest, opts);
|
|
4082
|
+
if ("aborted" in r) skipped.push({ name, reason: `aborted: ${r.reason}` });
|
|
4083
|
+
else if (r.ok) installed.push(name);
|
|
4084
|
+
else failed.push({ name, reason: r.error.message });
|
|
4085
|
+
}
|
|
4086
|
+
console.log(
|
|
4087
|
+
`
|
|
4088
|
+
installed: ${installed.length} / skipped (deferred phase 2.1): ${skipped.length} / failed: ${failed.length}`
|
|
4089
|
+
);
|
|
4090
|
+
for (const i of installed) console.log(` installed ${i}`);
|
|
4091
|
+
for (const s of skipped) console.log(` skipped ${s.name} \u2014 ${s.reason}`);
|
|
4092
|
+
for (const f of failed) console.error(` failed ${f.name} \u2014 ${f.reason}`);
|
|
4093
|
+
if (failed.length > 0) process.exit(1);
|
|
4094
|
+
if (installed.length === 0) process.exit(2);
|
|
4095
|
+
process.exit(0);
|
|
4096
|
+
});
|
|
4097
|
+
}
|
|
4098
|
+
var QA = [
|
|
4099
|
+
{ q: "\u2460 \u662F\u771F reusable surface \u8FD8\u662F\u4E34\u65F6 wrapper?", f: "q1_reusable_surface" },
|
|
4100
|
+
{ q: "\u2461 \u4E0A\u6E38\u540D\u5B57 fit \u9879\u76EE shape \u5417? \u6709\u73B0\u6709\u547D\u540D\u51B2\u7A81\u5417?", f: "q2_name_fit" },
|
|
4101
|
+
{ q: "\u2462 \u4E0E\u5DF2\u88C5\u914D\u7EC4\u4EF6\u6709 overlap surface \u5417?", f: "q3_overlap" },
|
|
4102
|
+
{ q: "\u2463 \u662F import \u6982\u5FF5 (\u53EF\u63A7) \u8FD8\u662F import \u522B\u4EBA\u4EA7\u54C1\u8EAB\u4EFD (\u9AD8\u8026\u5408)?", f: "q4_concept_vs_identity" },
|
|
4103
|
+
{ q: "\u2464 user \u4E0D\u77E5 upstream \u8FD8\u80FD\u7406\u89E3\u8BE5\u88C5\u914D\u5417?", f: "q5_user_understanding" }
|
|
4104
|
+
];
|
|
4105
|
+
function basename(upstream) {
|
|
4106
|
+
return (upstream.split("/").pop() ?? upstream).replace(/\.git$/, "");
|
|
4107
|
+
}
|
|
4108
|
+
function registerManifestAdd(program2) {
|
|
4109
|
+
program2.command("manifest-add <upstream>").description("Add a new upstream adapter (EE-5 5-question merge gate, D-03 BOTH dry-run/apply)").option("--category <cat>", "manifest category (skill-packs | tools)", "skill-packs").option("--name <name>", "short adapter name (defaults to <upstream> basename)").option("--apply", "persist EE-5 answers (default: dry-run preview)").option("--dry-run", "force dry-run (overrides --apply if both set)").option("--non-interactive", "CI/scripts \u2014 requires --apply or --dry-run; WARN-only dry-run").action(async (upstream, raw) => {
|
|
4110
|
+
validateNonInteractiveFlags(raw, "manifest-add <upstream>");
|
|
4111
|
+
const name = raw.name ?? basename(upstream);
|
|
4112
|
+
const category = raw.category ?? "skill-packs";
|
|
4113
|
+
const outPath = `manifests/${category}/${name}.ee5-answers.json`;
|
|
4114
|
+
if (raw.nonInteractive) {
|
|
4115
|
+
console.warn(
|
|
4116
|
+
"[ee-5-gate] WARN: --non-interactive skips 5-question prompt (D-03 dry-run-only). plan-phase hard reject still applies."
|
|
4117
|
+
);
|
|
4118
|
+
console.log(`[manifest-add] dry-run preview for upstream: ${upstream} \u2192 ${outPath}`);
|
|
4119
|
+
process.exit(0);
|
|
4120
|
+
}
|
|
4121
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
4122
|
+
const payload = {
|
|
4123
|
+
upstream,
|
|
4124
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4125
|
+
author: process.env.USER ?? process.env.USERNAME ?? "unknown"
|
|
4126
|
+
};
|
|
4127
|
+
for (const { q, f } of QA) {
|
|
4128
|
+
const a = (await rl.question(`${q}
|
|
4129
|
+
> `)).trim();
|
|
4130
|
+
if (!a) {
|
|
4131
|
+
console.error("error: EE-5 gate requires non-empty answer");
|
|
4132
|
+
rl.close();
|
|
4133
|
+
process.exit(1);
|
|
4134
|
+
}
|
|
4135
|
+
payload[f] = a;
|
|
4136
|
+
}
|
|
4137
|
+
rl.close();
|
|
4138
|
+
if (raw.apply) {
|
|
4139
|
+
writeFileSync(outPath, `${JSON.stringify(payload, null, 2)}
|
|
4140
|
+
`, "utf8");
|
|
4141
|
+
console.log(`[manifest-add] EE-5 gate passed; wrote ${outPath}`);
|
|
4142
|
+
} else {
|
|
4143
|
+
console.log(`[manifest-add] EE-5 gate passed (dry-run); would write ${outPath}`);
|
|
4144
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
4145
|
+
}
|
|
4146
|
+
process.exit(0);
|
|
4147
|
+
});
|
|
4148
|
+
}
|
|
4149
|
+
|
|
4150
|
+
// src/cli/research.ts
|
|
4151
|
+
function registerResearch(program2) {
|
|
4152
|
+
program2.command("research").description("Run research workflow (search category sub-routing \u2192 spawn \u2192 verbatim COMPLETE)").requiredOption("--query <text>", "research prompt (required)").option("--apply", "execute the spawn (default: dry-run preview only)").option("--dry-run", "force dry-run (overrides --apply if both are set)").option("--non-interactive", "skip all prompts (CI / scripts) \u2014 requires --apply or --dry-run").option("--model <model>", "subagent model: 'haiku' | 'sonnet' | 'opus'").action(async (raw) => {
|
|
4153
|
+
validateNonInteractiveFlags(raw, "research --query <text>");
|
|
4154
|
+
if (!raw.query) {
|
|
4155
|
+
console.error("error: --query <text> is required");
|
|
4156
|
+
process.exit(2);
|
|
4157
|
+
}
|
|
4158
|
+
const taskCtx = { task: raw.query, task_type: "search" };
|
|
4159
|
+
if (raw.dryRun === true || !raw.apply && !raw.nonInteractive) {
|
|
4160
|
+
const preview = await runRouting(taskCtx, {
|
|
4161
|
+
skillsRoot: void 0,
|
|
4162
|
+
// Stub spawn — dry-run never reaches it; explicit COMPLETE keeps shape happy.
|
|
4163
|
+
spawn: async () => "dry-run preview\nCOMPLETE",
|
|
4164
|
+
maxIterations: 1,
|
|
4165
|
+
...raw.model ? { agentOpts: { modelOverride: raw.model } } : {}
|
|
4166
|
+
});
|
|
4167
|
+
if ("aborted" in preview) {
|
|
4168
|
+
console.error(`aborted: ${preview.reason}`);
|
|
4169
|
+
process.exit(2);
|
|
4170
|
+
}
|
|
4171
|
+
if ("ok" in preview && preview.ok === false) {
|
|
4172
|
+
console.error(`error: ${preview.phase} \u2014 ${preview.error.message}`);
|
|
4173
|
+
process.exit(1);
|
|
4174
|
+
}
|
|
4175
|
+
console.log(`[dry-run] matched_rule: ${preview.matchedRule?.id ?? "(fallback supervisor)"}`);
|
|
4176
|
+
console.log(`[dry-run] query: ${raw.query}`);
|
|
4177
|
+
console.log(" (use --apply to spawn the subagent and emit verbatim COMPLETE round-trip)");
|
|
4178
|
+
process.exit(0);
|
|
4179
|
+
}
|
|
4180
|
+
const result = await runRouting(taskCtx, {
|
|
4181
|
+
...raw.model ? { agentOpts: { modelOverride: raw.model } } : {}
|
|
4182
|
+
});
|
|
4183
|
+
if ("aborted" in result) {
|
|
4184
|
+
console.error(`aborted: ${result.reason}`);
|
|
4185
|
+
process.exit(2);
|
|
4186
|
+
}
|
|
4187
|
+
if ("ok" in result && result.ok === false) {
|
|
4188
|
+
console.error(`error: ${result.phase} \u2014 ${result.error.message}`);
|
|
4189
|
+
if (result.phase === "install") {
|
|
4190
|
+
console.error(` fix: 'harnessed install <skill> --apply' (see error above)`);
|
|
4191
|
+
}
|
|
4192
|
+
process.exit(1);
|
|
4193
|
+
}
|
|
4194
|
+
console.log(result.result);
|
|
4195
|
+
process.exit(0);
|
|
4196
|
+
});
|
|
4197
|
+
}
|
|
4198
|
+
|
|
4199
|
+
// src/cli/resume.ts
|
|
4200
|
+
function registerResume(program2) {
|
|
4201
|
+
program2.command("resume").description(
|
|
4202
|
+
"Reload checkpoint from paused workflow + print resume hint (D-03 \u2014 user invokes phase command manually)"
|
|
4203
|
+
).option("--json", "output JSON instead of human-readable").action(async (opts) => {
|
|
4204
|
+
const { runResume: runResume2 } = await Promise.resolve().then(() => (init_resume(), resume_exports));
|
|
4205
|
+
const r = await runResume2();
|
|
4206
|
+
if (opts.json) {
|
|
4207
|
+
console.log(JSON.stringify(r, null, 2));
|
|
4208
|
+
process.exit(r.status === "ok" ? 0 : 1);
|
|
4209
|
+
return;
|
|
4210
|
+
}
|
|
4211
|
+
if (r.status === "no-paused-phase") {
|
|
4212
|
+
console.error(`\u2717 ${r.error}`);
|
|
4213
|
+
process.exit(1);
|
|
4214
|
+
}
|
|
4215
|
+
if (r.status === "corrupt") {
|
|
4216
|
+
console.error(`\u2717 ${r.error}
|
|
4217
|
+
path: ${r.path}`);
|
|
4218
|
+
process.exit(1);
|
|
4219
|
+
}
|
|
4220
|
+
if (r.cwdWarn) console.error(r.cwdWarn);
|
|
4221
|
+
console.log(`phase: ${r.checkpoint.phase}`);
|
|
4222
|
+
console.log(`last_task: ${r.checkpoint.last_task}`);
|
|
4223
|
+
if (r.checkpoint.key_decisions.length) {
|
|
4224
|
+
console.log(`key_decisions: ${r.checkpoint.key_decisions.slice(0, 5).join(", ")}`);
|
|
4225
|
+
}
|
|
4226
|
+
if (r.checkpoint.canonical_refs.length) {
|
|
4227
|
+
console.log(`canonical_refs: ${r.checkpoint.canonical_refs.slice(0, 3).join(", ")}`);
|
|
4228
|
+
}
|
|
4229
|
+
console.log(r.resumeHint);
|
|
4230
|
+
process.exit(0);
|
|
4231
|
+
});
|
|
4232
|
+
}
|
|
4233
|
+
function normalizeEol(buf, eol) {
|
|
4234
|
+
const lf = buf.toString("utf8").replace(/\r\n/g, "\n");
|
|
4235
|
+
return Buffer.from(eol === "crlf" ? lf.replace(/\n/g, "\r\n") : lf, "utf8");
|
|
4236
|
+
}
|
|
4237
|
+
function registerRollback(program2) {
|
|
4238
|
+
program2.command("rollback <timestamp>").description("Restore files from a backup snapshot (preserves original LF/CRLF)").action(async (timestamp) => {
|
|
4239
|
+
const dir = resolve(process.cwd(), ".harnessed-backup", timestamp);
|
|
4240
|
+
const metaPath = join(dir, "metadata.json");
|
|
4241
|
+
let meta;
|
|
4242
|
+
try {
|
|
4243
|
+
meta = JSON.parse(await readFile(metaPath, "utf8"));
|
|
4244
|
+
} catch (err2) {
|
|
4245
|
+
console.error(
|
|
4246
|
+
`error: cannot read ${metaPath}: ${err2.message}
|
|
4247
|
+
fix: run 'harnessed backup list' to see available timestamps`
|
|
4248
|
+
);
|
|
4249
|
+
process.exit(1);
|
|
4250
|
+
return;
|
|
4251
|
+
}
|
|
4252
|
+
for (const entry of [...meta.files].reverse()) {
|
|
4253
|
+
if (entry.backup === "") {
|
|
4254
|
+
try {
|
|
4255
|
+
await unlink(entry.target);
|
|
4256
|
+
} catch (err2) {
|
|
4257
|
+
const code = err2.code;
|
|
4258
|
+
if (code !== "ENOENT") {
|
|
4259
|
+
console.error(`error: cannot unlink ${entry.target}: ${err2.message}`);
|
|
4260
|
+
process.exit(1);
|
|
4261
|
+
return;
|
|
4262
|
+
}
|
|
4263
|
+
}
|
|
4264
|
+
continue;
|
|
4265
|
+
}
|
|
4266
|
+
const buf = await readFile(entry.backup);
|
|
4267
|
+
const sha1 = createHash("sha1").update(buf).digest("hex");
|
|
4268
|
+
if (sha1 !== entry.sha1) {
|
|
4269
|
+
console.error(
|
|
4270
|
+
`error: backup checksum mismatch for ${entry.target} (expected ${entry.sha1.slice(0, 12)}, got ${sha1.slice(0, 12)})`
|
|
4271
|
+
);
|
|
4272
|
+
process.exit(1);
|
|
4273
|
+
return;
|
|
4274
|
+
}
|
|
4275
|
+
await writeFile(entry.target, normalizeEol(buf, entry.eol));
|
|
4276
|
+
}
|
|
4277
|
+
console.log(`restored ${meta.files.length} file(s) from ${timestamp}`);
|
|
4278
|
+
});
|
|
4279
|
+
}
|
|
4280
|
+
function registerStatus(program2) {
|
|
4281
|
+
program2.command("status").description("Show installed upstreams (from .harnessed/state.json)").action(async () => {
|
|
4282
|
+
const state = await readState(process.cwd());
|
|
4283
|
+
const names = Object.keys(state.installed).sort();
|
|
4284
|
+
if (names.length === 0) {
|
|
4285
|
+
console.log("no installs recorded (.harnessed/state.json absent or empty)");
|
|
4286
|
+
} else {
|
|
4287
|
+
for (const n of names) {
|
|
4288
|
+
const e = state.installed[n];
|
|
4289
|
+
if (!e) continue;
|
|
4290
|
+
console.log(`${n} @ ${e.version} (installed ${e.installedAt})`);
|
|
4291
|
+
}
|
|
4292
|
+
console.log(`
|
|
4293
|
+
${names.length} install${names.length === 1 ? "" : "s"} recorded`);
|
|
4294
|
+
}
|
|
4295
|
+
try {
|
|
4296
|
+
const isLocked = await lockfile.check(".harnessed", {
|
|
4297
|
+
lockfilePath: ".harnessed/.lock",
|
|
4298
|
+
stale: 1e4
|
|
4299
|
+
});
|
|
4300
|
+
if (isLocked) {
|
|
4301
|
+
const s = await stat(".harnessed/.lock");
|
|
4302
|
+
const ageMs = Date.now() - s.mtime.getTime();
|
|
4303
|
+
const stale = ageMs > 1e4;
|
|
4304
|
+
console.log(`
|
|
4305
|
+
lock: held (since ${s.mtime.toISOString()})${stale ? " \u2014 STALE" : ""}`);
|
|
4306
|
+
console.log(" to release: wait for process to finish or delete .harnessed/.lock");
|
|
4307
|
+
} else {
|
|
4308
|
+
console.log("\nlock: free");
|
|
4309
|
+
}
|
|
4310
|
+
} catch {
|
|
4311
|
+
}
|
|
4312
|
+
});
|
|
4313
|
+
}
|
|
4314
|
+
|
|
4315
|
+
// src/cli/uninstall.ts
|
|
4316
|
+
init_path_guard();
|
|
4317
|
+
|
|
4318
|
+
// src/uninstallers/lib/runOrPreview.ts
|
|
4319
|
+
function dryRunGate(ctx) {
|
|
4320
|
+
if (ctx.opts.dryRun) return { aborted: true, reason: "dry-run" };
|
|
4321
|
+
return null;
|
|
4322
|
+
}
|
|
4323
|
+
|
|
4324
|
+
// src/uninstallers/ccHookAdd.ts
|
|
4325
|
+
var uninstallCcHookAdd = async (ctx) => {
|
|
4326
|
+
const install = ctx.manifest.spec.install;
|
|
4327
|
+
if (install.method !== "cc-hook-add") {
|
|
4328
|
+
return { ok: false, phase: "preflight", error: `dispatch bug: ${install.method}` };
|
|
4329
|
+
}
|
|
4330
|
+
const abort = dryRunGate(ctx);
|
|
4331
|
+
if (abort) return abort;
|
|
4332
|
+
const settingsPath = join(homedir(), ".claude", "settings.json");
|
|
4333
|
+
let existing;
|
|
4334
|
+
try {
|
|
4335
|
+
existing = await readFile(settingsPath, "utf8");
|
|
4336
|
+
} catch {
|
|
4337
|
+
return { ok: true, removedPaths: [] };
|
|
4338
|
+
}
|
|
4339
|
+
let settings;
|
|
4340
|
+
try {
|
|
4341
|
+
settings = JSON.parse(existing);
|
|
4342
|
+
} catch (e) {
|
|
4343
|
+
return {
|
|
4344
|
+
ok: false,
|
|
4345
|
+
phase: "preflight",
|
|
4346
|
+
error: `malformed settings.json: ${e.message.slice(0, 200)}`
|
|
4347
|
+
};
|
|
4348
|
+
}
|
|
4349
|
+
const ev = install.hook_event;
|
|
4350
|
+
const cmd = install.hook_command;
|
|
4351
|
+
const matcher = install.hook_matcher;
|
|
4352
|
+
if (!settings.hooks?.[ev]) {
|
|
4353
|
+
return { ok: true, removedPaths: [] };
|
|
4354
|
+
}
|
|
4355
|
+
const before = settings.hooks[ev].length;
|
|
4356
|
+
settings.hooks[ev] = settings.hooks[ev].filter(
|
|
4357
|
+
(h) => !(h.command === cmd && h.matcher === matcher)
|
|
4358
|
+
);
|
|
4359
|
+
if (settings.hooks[ev].length === 0) delete settings.hooks[ev];
|
|
4360
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
4361
|
+
if (settings.hooks?.[ev]?.length === before || before === settings.hooks?.[ev]?.length) ;
|
|
4362
|
+
const newText = `${JSON.stringify(settings, null, 2)}
|
|
4363
|
+
`;
|
|
4364
|
+
await writeFile(settingsPath, newText);
|
|
4365
|
+
return { ok: true, removedPaths: [settingsPath] };
|
|
4366
|
+
};
|
|
4367
|
+
|
|
4368
|
+
// src/uninstallers/ccPluginMarketplace.ts
|
|
4369
|
+
function extractPluginAtMkt(cmd) {
|
|
4370
|
+
const m = cmd.match(/(?:\/?plugin)\s+install\s+(\S+)/i);
|
|
4371
|
+
if (!m || m[1] === void 0) return null;
|
|
4372
|
+
const pluginAtMkt = m[1].replace(/[;&]+$/, "");
|
|
4373
|
+
return pluginAtMkt.includes("@") ? pluginAtMkt : null;
|
|
4374
|
+
}
|
|
4375
|
+
var uninstallCcPluginMarketplace = async (ctx) => {
|
|
4376
|
+
const install = ctx.manifest.spec.install;
|
|
4377
|
+
if (install.method !== "cc-plugin-marketplace") {
|
|
4378
|
+
return { ok: false, phase: "preflight", error: `dispatch bug: ${install.method}` };
|
|
4379
|
+
}
|
|
4380
|
+
const abort = dryRunGate(ctx);
|
|
4381
|
+
if (abort) return abort;
|
|
4382
|
+
const pluginAtMkt = extractPluginAtMkt(install.cmd);
|
|
4383
|
+
if (!pluginAtMkt) {
|
|
4384
|
+
return {
|
|
4385
|
+
ok: false,
|
|
4386
|
+
phase: "preflight",
|
|
4387
|
+
error: `cc-plugin-marketplace cmd missing plugin install <plugin>@<marketplace>: '${install.cmd.slice(0, 80)}'`
|
|
4388
|
+
};
|
|
4389
|
+
}
|
|
4390
|
+
const r = await runArgs(["plugin", "uninstall", pluginAtMkt], ctx.cwd);
|
|
4391
|
+
if (r.exitCode !== 0) {
|
|
4392
|
+
return {
|
|
4393
|
+
ok: false,
|
|
4394
|
+
phase: "spawn",
|
|
4395
|
+
error: `claude plugin uninstall exited ${r.exitCode}: ${r.stderr.slice(0, 200)}`
|
|
4396
|
+
};
|
|
4397
|
+
}
|
|
4398
|
+
return { ok: true, removedPaths: [pluginAtMkt] };
|
|
4399
|
+
};
|
|
4400
|
+
function extractCloneTarget2(cmd) {
|
|
4401
|
+
const idx = cmd.indexOf("git clone");
|
|
4402
|
+
if (idx < 0) return null;
|
|
4403
|
+
const tail = cmd.slice(idx + "git clone".length).trim();
|
|
4404
|
+
const tokens = tail.split(/\s+/);
|
|
4405
|
+
let i = 0;
|
|
4406
|
+
while (i < tokens.length) {
|
|
4407
|
+
const t = tokens[i];
|
|
4408
|
+
if (t === void 0 || !t.startsWith("-")) break;
|
|
4409
|
+
if (t === "--depth" || t === "--branch" || t === "-b") {
|
|
4410
|
+
i += 2;
|
|
4411
|
+
} else if (t.includes("=")) {
|
|
4412
|
+
i += 1;
|
|
4413
|
+
} else {
|
|
4414
|
+
i += 2;
|
|
4415
|
+
}
|
|
4416
|
+
}
|
|
4417
|
+
const dest = tokens[i + 1];
|
|
4418
|
+
if (!dest || dest === "&&" || dest === ";" || dest === "|") return null;
|
|
4419
|
+
if (dest.startsWith("~/")) {
|
|
4420
|
+
const home = process.env.HOME ?? process.env.USERPROFILE;
|
|
4421
|
+
if (!home) return null;
|
|
4422
|
+
return `${home}${dest.slice(1)}`;
|
|
4423
|
+
}
|
|
4424
|
+
return dest;
|
|
4425
|
+
}
|
|
4426
|
+
var uninstallGitCloneWithSetup = async (ctx) => {
|
|
4427
|
+
const install = ctx.manifest.spec.install;
|
|
4428
|
+
if (install.method !== "git-clone-with-setup") {
|
|
4429
|
+
return { ok: false, phase: "preflight", error: `dispatch bug: ${install.method}` };
|
|
4430
|
+
}
|
|
4431
|
+
const abort = dryRunGate(ctx);
|
|
4432
|
+
if (abort) return abort;
|
|
4433
|
+
const cloneTarget = extractCloneTarget2(install.cmd);
|
|
4434
|
+
if (!cloneTarget) {
|
|
4435
|
+
return {
|
|
4436
|
+
ok: false,
|
|
4437
|
+
phase: "preflight",
|
|
4438
|
+
error: `git-clone-with-setup cmd missing parseable 'git clone <url> <dest>': '${install.cmd.slice(0, 80)}'`
|
|
4439
|
+
};
|
|
4440
|
+
}
|
|
4441
|
+
await rm(cloneTarget, { recursive: true, force: true, maxRetries: 3 });
|
|
4442
|
+
return { ok: true, removedPaths: [cloneTarget] };
|
|
4443
|
+
};
|
|
4444
|
+
|
|
4445
|
+
// src/uninstallers/mcpHttpAdd.ts
|
|
4446
|
+
var uninstallMcpHttpAdd = async (ctx) => {
|
|
4447
|
+
const install = ctx.manifest.spec.install;
|
|
4448
|
+
if (install.method !== "mcp-http-add") {
|
|
4449
|
+
return { ok: false, phase: "preflight", error: `dispatch bug: ${install.method}` };
|
|
4450
|
+
}
|
|
4451
|
+
const abort = dryRunGate(ctx);
|
|
4452
|
+
if (abort) return abort;
|
|
4453
|
+
const name = ctx.manifest.metadata.name;
|
|
4454
|
+
const r = await runArgs(["mcp", "remove", name], ctx.cwd);
|
|
4455
|
+
if (r.exitCode !== 0) {
|
|
4456
|
+
return {
|
|
4457
|
+
ok: false,
|
|
4458
|
+
phase: "spawn",
|
|
4459
|
+
error: `claude mcp remove exited ${r.exitCode}: ${r.stderr.slice(0, 200)}`
|
|
4460
|
+
};
|
|
4461
|
+
}
|
|
4462
|
+
return { ok: true, removedPaths: [name] };
|
|
4463
|
+
};
|
|
4464
|
+
|
|
4465
|
+
// src/uninstallers/mcpStdioAdd.ts
|
|
4466
|
+
var uninstallMcpStdioAdd = async (ctx) => {
|
|
4467
|
+
const install = ctx.manifest.spec.install;
|
|
4468
|
+
if (install.method !== "mcp-stdio-add") {
|
|
4469
|
+
return { ok: false, phase: "preflight", error: `dispatch bug: ${install.method}` };
|
|
4470
|
+
}
|
|
4471
|
+
const abort = dryRunGate(ctx);
|
|
4472
|
+
if (abort) return abort;
|
|
4473
|
+
const name = ctx.manifest.metadata.name;
|
|
4474
|
+
const r = await runArgs(["mcp", "remove", name], ctx.cwd);
|
|
4475
|
+
if (r.exitCode !== 0) {
|
|
4476
|
+
return {
|
|
4477
|
+
ok: false,
|
|
4478
|
+
phase: "spawn",
|
|
4479
|
+
error: `claude mcp remove exited ${r.exitCode}: ${r.stderr.slice(0, 200)}`
|
|
4480
|
+
};
|
|
4481
|
+
}
|
|
4482
|
+
return { ok: true, removedPaths: [name] };
|
|
4483
|
+
};
|
|
4484
|
+
var uninstallNpmCli = async (ctx) => {
|
|
4485
|
+
const install = ctx.manifest.spec.install;
|
|
4486
|
+
if (install.method !== "npm-cli") {
|
|
4487
|
+
return { ok: false, phase: "preflight", error: `dispatch bug: ${install.method}` };
|
|
4488
|
+
}
|
|
4489
|
+
if (/\bnpx\s+(--yes|-y)\b/.test(install.cmd)) {
|
|
4490
|
+
const name = ctx.manifest.metadata.name;
|
|
4491
|
+
console.warn(
|
|
4492
|
+
`ephemeral install: nothing to uninstall ('${name}' uses 'npx --yes' runtime-only invocation; no persistent install to remove)`
|
|
4493
|
+
);
|
|
4494
|
+
return { ok: true, removedPaths: [] };
|
|
4495
|
+
}
|
|
4496
|
+
const abort = dryRunGate(ctx);
|
|
4497
|
+
if (abort) return abort;
|
|
4498
|
+
const m = install.cmd.match(/npm\s+(?:install|i)\s+(?:-g\s+)?(\S+)/);
|
|
4499
|
+
const pkg = m?.[1] ?? ctx.manifest.metadata.upstream.source;
|
|
4500
|
+
const isWin = process.platform === "win32";
|
|
4501
|
+
const result = await new Promise((resolve8) => {
|
|
4502
|
+
const child = isWin ? spawn("cmd.exe", ["/c", "npm", "uninstall", "-g", pkg], { windowsHide: true }) : spawn("npm", ["uninstall", "-g", pkg], { shell: false });
|
|
4503
|
+
let stderr = "";
|
|
4504
|
+
child.stderr?.setEncoding("utf8").on("data", (c) => {
|
|
4505
|
+
stderr += c;
|
|
4506
|
+
});
|
|
4507
|
+
const timer = setTimeout(() => {
|
|
4508
|
+
child.kill("SIGKILL");
|
|
4509
|
+
resolve8({ exitCode: -1, stderr: `${stderr}[timeout]` });
|
|
4510
|
+
}, 3e4);
|
|
4511
|
+
child.on("error", (e) => {
|
|
4512
|
+
clearTimeout(timer);
|
|
4513
|
+
resolve8({ exitCode: -1, stderr: e.message });
|
|
4514
|
+
});
|
|
4515
|
+
child.on("close", (code) => {
|
|
4516
|
+
clearTimeout(timer);
|
|
4517
|
+
resolve8({ exitCode: code ?? -1, stderr });
|
|
4518
|
+
});
|
|
4519
|
+
});
|
|
4520
|
+
if (result.exitCode !== 0) {
|
|
4521
|
+
return {
|
|
4522
|
+
ok: false,
|
|
4523
|
+
phase: "spawn",
|
|
4524
|
+
error: `npm uninstall exited ${result.exitCode}: ${result.stderr.slice(0, 200)}`
|
|
4525
|
+
};
|
|
4526
|
+
}
|
|
4527
|
+
return { ok: true, removedPaths: [pkg] };
|
|
4528
|
+
};
|
|
4529
|
+
function extractSkillName2(cmd, fallback) {
|
|
4530
|
+
const m = cmd.match(/\bskills(?:@\S+)?\s+add\s+(\S+)/i);
|
|
4531
|
+
if (!m || m[1] === void 0) return fallback;
|
|
4532
|
+
const ref = m[1];
|
|
4533
|
+
const seg = ref.split("/");
|
|
4534
|
+
return seg[seg.length - 1] ?? fallback;
|
|
4535
|
+
}
|
|
4536
|
+
var uninstallNpxSkillInstaller = async (ctx) => {
|
|
4537
|
+
const install = ctx.manifest.spec.install;
|
|
4538
|
+
if (install.method !== "npx-skill-installer") {
|
|
4539
|
+
return { ok: false, phase: "preflight", error: `dispatch bug: ${install.method}` };
|
|
4540
|
+
}
|
|
4541
|
+
const abort = dryRunGate(ctx);
|
|
4542
|
+
if (abort) return abort;
|
|
4543
|
+
const name = ctx.manifest.metadata.name;
|
|
4544
|
+
const skillName = extractSkillName2(install.cmd, name);
|
|
4545
|
+
const skillDir = join(homedir(), ".claude", "skills", skillName);
|
|
4546
|
+
await rm(skillDir, { recursive: true, force: true, maxRetries: 3 });
|
|
4547
|
+
return { ok: true, removedPaths: [skillDir] };
|
|
4548
|
+
};
|
|
4549
|
+
|
|
4550
|
+
// src/uninstallers/index.ts
|
|
4551
|
+
var uninstallers = {
|
|
4552
|
+
"npm-cli": uninstallNpmCli,
|
|
4553
|
+
"mcp-stdio-add": uninstallMcpStdioAdd,
|
|
4554
|
+
"mcp-http-add": uninstallMcpHttpAdd,
|
|
4555
|
+
"cc-plugin-marketplace": uninstallCcPluginMarketplace,
|
|
4556
|
+
"git-clone-with-setup": uninstallGitCloneWithSetup,
|
|
4557
|
+
"npx-skill-installer": uninstallNpxSkillInstaller,
|
|
4558
|
+
"cc-hook-add": uninstallCcHookAdd
|
|
4559
|
+
};
|
|
4560
|
+
async function runUninstall(manifest, opts) {
|
|
4561
|
+
const uninstaller = uninstallers[manifest.spec.install.method];
|
|
4562
|
+
return uninstaller({ manifest, opts, cwd: process.cwd() });
|
|
4563
|
+
}
|
|
4564
|
+
|
|
4565
|
+
// src/cli/uninstall.ts
|
|
4566
|
+
function registerUninstall(program2) {
|
|
4567
|
+
program2.command("uninstall <name>").description("Uninstall an upstream (dry-run by default \u2014 pass --apply to execute)").option("--apply", "execute the uninstall (default: dry-run preview only)").option("--dry-run", "force dry-run (overrides --apply if both are set)").option("--yes", "skip interactive confirm \u2014 requires --apply (CI / scripts)").option("--non-interactive", "alias for --yes (CI compat)").action(async (name, raw) => {
|
|
4568
|
+
const yes = raw.yes === true || raw.nonInteractive === true;
|
|
4569
|
+
if (yes && !raw.apply) {
|
|
4570
|
+
console.error(
|
|
4571
|
+
`error: --yes requires --apply to execute
|
|
4572
|
+
fix: harnessed uninstall ${name} --yes --apply`
|
|
4573
|
+
);
|
|
4574
|
+
process.exit(2);
|
|
4575
|
+
}
|
|
4576
|
+
const { resolveAlias: resolveAlias2 } = await Promise.resolve().then(() => (init_aliases(), aliases_exports));
|
|
4577
|
+
const resolvedName = resolveAlias2(name) ?? name;
|
|
4578
|
+
checkPathSafe(resolvedName);
|
|
4579
|
+
const manifestPath = resolve(process.cwd(), `manifests/tools/${resolvedName}.yaml`);
|
|
4580
|
+
const skillPackPath = resolve(process.cwd(), `manifests/skill-packs/${resolvedName}.yaml`);
|
|
4581
|
+
let yamlSrc;
|
|
4582
|
+
let chosenPath = manifestPath;
|
|
4583
|
+
try {
|
|
4584
|
+
yamlSrc = await readFile(manifestPath, "utf8");
|
|
4585
|
+
} catch {
|
|
4586
|
+
try {
|
|
4587
|
+
yamlSrc = await readFile(skillPackPath, "utf8");
|
|
4588
|
+
chosenPath = skillPackPath;
|
|
4589
|
+
} catch {
|
|
4590
|
+
console.error(
|
|
4591
|
+
`error: manifest '${resolvedName}' not found
|
|
4592
|
+
fix: ensure manifests/tools/${resolvedName}.yaml or manifests/skill-packs/${resolvedName}.yaml exists`
|
|
4593
|
+
);
|
|
4594
|
+
process.exit(1);
|
|
4595
|
+
}
|
|
4596
|
+
}
|
|
4597
|
+
const v = validateManifestFile(yamlSrc, chosenPath);
|
|
4598
|
+
if (!v.ok) {
|
|
4599
|
+
for (const e of v.errors) console.error(`error: ${e.message} at ${e.path}`);
|
|
4600
|
+
process.exit(1);
|
|
4601
|
+
}
|
|
4602
|
+
const method = v.manifest.spec.install.method;
|
|
4603
|
+
const dryRun = raw.dryRun === true || !raw.apply;
|
|
4604
|
+
if (dryRun) {
|
|
4605
|
+
console.log(`[dry-run] would uninstall '${resolvedName}' via method '${method}'`);
|
|
4606
|
+
console.log(` run with --apply to execute`);
|
|
4607
|
+
process.exit(2);
|
|
4608
|
+
}
|
|
4609
|
+
if (!yes) {
|
|
4610
|
+
const answer = await p.confirm({
|
|
4611
|
+
message: `Uninstall '${resolvedName}'? This cannot be undone.`,
|
|
4612
|
+
initialValue: false
|
|
4613
|
+
});
|
|
4614
|
+
if (p.isCancel(answer) || answer === false) {
|
|
4615
|
+
console.error(`aborted: user cancelled`);
|
|
4616
|
+
process.exit(2);
|
|
4617
|
+
}
|
|
4618
|
+
}
|
|
4619
|
+
const opts = { apply: true, dryRun: false, yes };
|
|
4620
|
+
const result = await runUninstall(v.manifest, opts);
|
|
4621
|
+
if ("aborted" in result) {
|
|
4622
|
+
console.error(`aborted: ${result.reason}`);
|
|
4623
|
+
process.exit(2);
|
|
4624
|
+
}
|
|
4625
|
+
if (result.ok) {
|
|
4626
|
+
console.log(`uninstalled ${resolvedName}`);
|
|
4627
|
+
process.exit(0);
|
|
4628
|
+
}
|
|
4629
|
+
console.error(`error: ${result.error}`);
|
|
4630
|
+
process.exit(1);
|
|
4631
|
+
});
|
|
4632
|
+
}
|
|
4633
|
+
|
|
4634
|
+
// src/cli.ts
|
|
4635
|
+
var program = new Command();
|
|
4636
|
+
program.name("harnessed").description("AI coding harness package manager + composition orchestrator").version(package_default.version);
|
|
4637
|
+
registerInstall(program);
|
|
4638
|
+
registerInstallBase(program);
|
|
4639
|
+
registerResearch(program);
|
|
4640
|
+
registerExecuteTask(program);
|
|
4641
|
+
registerManifestAdd(program);
|
|
4642
|
+
registerDoctor(program);
|
|
4643
|
+
registerAudit(program);
|
|
4644
|
+
registerAuditLog(program);
|
|
4645
|
+
registerRollback(program);
|
|
4646
|
+
registerStatus(program);
|
|
4647
|
+
registerBackupList(program);
|
|
4648
|
+
registerGc(program);
|
|
4649
|
+
registerResume(program);
|
|
4650
|
+
registerUninstall(program);
|
|
4651
|
+
program.parse(process.argv);
|
|
4652
|
+
//# sourceMappingURL=cli.mjs.map
|
|
4653
|
+
//# sourceMappingURL=cli.mjs.map
|