@nick848/fet 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/{chunk-FZOVNHE7.js → chunk-V4ZRBF5L.js} +5 -1
- package/dist/chunk-V4ZRBF5L.js.map +1 -0
- package/dist/cli/index.js +751 -118
- package/dist/cli/index.js.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-FZOVNHE7.js.map +0 -1
package/dist/cli/index.js
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
import {
|
|
3
3
|
FetError,
|
|
4
4
|
toFetError
|
|
5
|
-
} from "../chunk-
|
|
5
|
+
} from "../chunk-V4ZRBF5L.js";
|
|
6
6
|
|
|
7
7
|
// src/cli/index.ts
|
|
8
|
+
import { createInterface } from "readline/promises";
|
|
8
9
|
import { Command } from "commander";
|
|
9
10
|
|
|
10
11
|
// src/commands/init.ts
|
|
11
|
-
import { readFile as
|
|
12
|
-
import { join as
|
|
12
|
+
import { readFile as readFile6, stat as stat3 } from "fs/promises";
|
|
13
|
+
import { join as join8 } from "path";
|
|
13
14
|
|
|
14
15
|
// src/fs/atomic-write.ts
|
|
15
16
|
import { dirname } from "path";
|
|
@@ -115,16 +116,130 @@ async function writeInitJournal(projectRoot, journal) {
|
|
|
115
116
|
);
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
// src/gitnexus.ts
|
|
120
|
+
import { execFile } from "child_process";
|
|
121
|
+
import { stat as stat2 } from "fs/promises";
|
|
122
|
+
import { join as join4 } from "path";
|
|
123
|
+
import { promisify } from "util";
|
|
124
|
+
var execFileAsync = promisify(execFile);
|
|
125
|
+
var DEFAULT_GRAPH_PATH = ".gitnexus";
|
|
126
|
+
async function detectGitNexus(env = process.env) {
|
|
127
|
+
const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
128
|
+
const command = resolveGitNexusCommand(env);
|
|
129
|
+
try {
|
|
130
|
+
const { stdout, stderr } = await execFileAsync(command.file, [...command.args, "--version"], { shell: process.platform === "win32" });
|
|
131
|
+
return {
|
|
132
|
+
installed: true,
|
|
133
|
+
executablePath: command.label,
|
|
134
|
+
version: (stdout.trim() || stderr.trim() || "unknown").split(/\r?\n/)[0] ?? "unknown",
|
|
135
|
+
checkedAt
|
|
136
|
+
};
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return {
|
|
139
|
+
installed: false,
|
|
140
|
+
executablePath: command.label,
|
|
141
|
+
version: null,
|
|
142
|
+
checkedAt,
|
|
143
|
+
error: error instanceof Error ? error.message : String(error)
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function toGitNexusState(detection, previous) {
|
|
148
|
+
return {
|
|
149
|
+
provider: "gitnexus",
|
|
150
|
+
installed: detection.installed,
|
|
151
|
+
executablePath: detection.installed ? detection.executablePath : null,
|
|
152
|
+
version: detection.version,
|
|
153
|
+
checkedAt: detection.checkedAt,
|
|
154
|
+
recommendationShownAt: previous?.recommendationShownAt ?? null,
|
|
155
|
+
graphPath: previous?.graphPath ?? null,
|
|
156
|
+
graphExists: previous?.graphExists ?? false,
|
|
157
|
+
lastIndexedAt: previous?.lastIndexedAt ?? null,
|
|
158
|
+
lastRefreshAt: previous?.lastRefreshAt ?? null,
|
|
159
|
+
lastStatus: previous?.lastStatus ?? null,
|
|
160
|
+
setupHandoffPath: previous?.setupHandoffPath ?? null,
|
|
161
|
+
setupHandoffUpdatedAt: previous?.setupHandoffUpdatedAt ?? null,
|
|
162
|
+
handoffPath: previous?.handoffPath ?? null,
|
|
163
|
+
handoffUpdatedAt: previous?.handoffUpdatedAt ?? null
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
async function inspectGitNexusGraph(projectRoot, env = process.env) {
|
|
167
|
+
const relative2 = env.FET_GITNEXUS_GRAPH_PATH?.trim() || DEFAULT_GRAPH_PATH;
|
|
168
|
+
const graphPath = join4(projectRoot, relative2);
|
|
169
|
+
try {
|
|
170
|
+
const info = await stat2(graphPath);
|
|
171
|
+
return {
|
|
172
|
+
graphPath: relative2,
|
|
173
|
+
graphExists: true,
|
|
174
|
+
lastIndexedAt: info.mtime.toISOString()
|
|
175
|
+
};
|
|
176
|
+
} catch {
|
|
177
|
+
return {
|
|
178
|
+
graphPath: relative2,
|
|
179
|
+
graphExists: false,
|
|
180
|
+
lastIndexedAt: null
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function runGitNexus(args, options) {
|
|
185
|
+
const command = resolveGitNexusCommand(options.env ?? process.env);
|
|
186
|
+
const fullCommand = [command.file, ...command.args, ...args];
|
|
187
|
+
try {
|
|
188
|
+
const { stdout, stderr } = await execFileAsync(command.file, [...command.args, ...args], {
|
|
189
|
+
cwd: options.cwd,
|
|
190
|
+
shell: process.platform === "win32"
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
exitCode: 0,
|
|
194
|
+
stdout,
|
|
195
|
+
stderr,
|
|
196
|
+
command: fullCommand
|
|
197
|
+
};
|
|
198
|
+
} catch (error) {
|
|
199
|
+
const maybe = error;
|
|
200
|
+
return {
|
|
201
|
+
exitCode: typeof maybe.code === "number" ? maybe.code : 1,
|
|
202
|
+
stdout: maybe.stdout ?? "",
|
|
203
|
+
stderr: maybe.stderr ?? (error instanceof Error ? error.message : String(error)),
|
|
204
|
+
command: fullCommand
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function mergeGitNexusGraphInfo(state, graph2) {
|
|
209
|
+
return {
|
|
210
|
+
...state,
|
|
211
|
+
graphPath: graph2.graphPath,
|
|
212
|
+
graphExists: graph2.graphExists,
|
|
213
|
+
lastIndexedAt: graph2.lastIndexedAt
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function renderGitNexusRecommendation(state) {
|
|
217
|
+
if (state.installed) {
|
|
218
|
+
return `Optional GitNexus detected (${state.version ?? "unknown"}). You can generate a code graph after init; future OpenSpec artifacts should prefer the graph when it is available.`;
|
|
219
|
+
}
|
|
220
|
+
return "Optional GitNexus code graph support is not installed. Consider installing GitNexus later to speed up OpenSpec artifact generation and improve code insertion context.";
|
|
221
|
+
}
|
|
222
|
+
function resolveGitNexusCommand(env) {
|
|
223
|
+
const raw = env.FET_GITNEXUS_COMMAND?.trim() || env.FET_GITNEXUS_EXECUTABLE?.trim() || "gitnexus";
|
|
224
|
+
const parts = splitCommand(raw);
|
|
225
|
+
const [file = "gitnexus", ...args] = parts;
|
|
226
|
+
return { file, args, label: raw };
|
|
227
|
+
}
|
|
228
|
+
function splitCommand(value) {
|
|
229
|
+
const matches = value.match(/"[^"]+"|'[^']+'|\S+/g);
|
|
230
|
+
return (matches?.length ? matches : [value]).map((part) => part.replace(/^["']|["']$/g, ""));
|
|
231
|
+
}
|
|
232
|
+
|
|
118
233
|
// src/version.ts
|
|
119
234
|
import { existsSync, readFileSync } from "fs";
|
|
120
|
-
import { dirname as dirname4, join as
|
|
235
|
+
import { dirname as dirname4, join as join5, parse } from "path";
|
|
121
236
|
import { fileURLToPath } from "url";
|
|
122
237
|
var FET_VERSION = readPackageVersion();
|
|
123
238
|
function readPackageVersion() {
|
|
124
239
|
let currentDir = dirname4(fileURLToPath(import.meta.url));
|
|
125
240
|
const root = parse(currentDir).root;
|
|
126
241
|
while (true) {
|
|
127
|
-
const packageJsonPath =
|
|
242
|
+
const packageJsonPath = join5(currentDir, "package.json");
|
|
128
243
|
if (existsSync(packageJsonPath)) {
|
|
129
244
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
130
245
|
if (typeof packageJson.version === "string" && packageJson.version.length > 0) {
|
|
@@ -144,6 +259,7 @@ var AUTO_BEGIN = "<!-- FET:BEGIN AUTO -->";
|
|
|
144
259
|
var AUTO_END = "<!-- FET:END AUTO -->";
|
|
145
260
|
var USER_BEGIN = "<!-- FET:BEGIN USER -->";
|
|
146
261
|
var USER_END = "<!-- FET:END USER -->";
|
|
262
|
+
var LLM_PLACEHOLDER_PATTERN = /\[NEEDS? LLM INPUT\]/;
|
|
147
263
|
function hasManagedAutoRegion(content) {
|
|
148
264
|
return count(content, AUTO_BEGIN) === 1 && count(content, AUTO_END) === 1 && content.indexOf(AUTO_BEGIN) < content.indexOf(AUTO_END);
|
|
149
265
|
}
|
|
@@ -163,11 +279,41 @@ function replaceManagedRegion(existing, generated) {
|
|
|
163
279
|
}
|
|
164
280
|
const before = existing.slice(0, start);
|
|
165
281
|
const after = existing.slice(end + AUTO_END.length);
|
|
282
|
+
const existingAuto = extractAuto(existing);
|
|
166
283
|
const generatedAuto = extractAuto(generated);
|
|
167
284
|
return `${before}${AUTO_BEGIN}
|
|
168
|
-
${generatedAuto}
|
|
285
|
+
${mergeAutoRegion(existingAuto, generatedAuto)}
|
|
169
286
|
${AUTO_END}${after}`;
|
|
170
287
|
}
|
|
288
|
+
function mergeAutoRegion(existingAuto, generatedAuto) {
|
|
289
|
+
const generatedSections = splitMarkdownSections(generatedAuto);
|
|
290
|
+
const existingSections = new Map(splitMarkdownSections(existingAuto).map((section) => [section.heading, section]));
|
|
291
|
+
if (!generatedSections.length || !existingSections.size) {
|
|
292
|
+
return generatedAuto;
|
|
293
|
+
}
|
|
294
|
+
return generatedSections.map((section) => {
|
|
295
|
+
const existing = existingSections.get(section.heading);
|
|
296
|
+
if (existing && LLM_PLACEHOLDER_PATTERN.test(section.body) && !LLM_PLACEHOLDER_PATTERN.test(existing.body)) {
|
|
297
|
+
return existing.raw.trim();
|
|
298
|
+
}
|
|
299
|
+
return section.raw.trim();
|
|
300
|
+
}).join("\n\n");
|
|
301
|
+
}
|
|
302
|
+
function splitMarkdownSections(content) {
|
|
303
|
+
const matches = [...content.matchAll(/^## .+$/gm)];
|
|
304
|
+
if (!matches.length) {
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
return matches.map((match, index) => {
|
|
308
|
+
const start = match.index ?? 0;
|
|
309
|
+
const end = matches[index + 1]?.index ?? content.length;
|
|
310
|
+
const raw = content.slice(start, end).trim();
|
|
311
|
+
const newline = raw.indexOf("\n");
|
|
312
|
+
const heading = newline === -1 ? raw.trim() : raw.slice(0, newline).trim();
|
|
313
|
+
const body = newline === -1 ? "" : raw.slice(newline + 1).trim();
|
|
314
|
+
return { heading, body, raw };
|
|
315
|
+
});
|
|
316
|
+
}
|
|
171
317
|
function extractAuto(content) {
|
|
172
318
|
const start = content.indexOf(AUTO_BEGIN);
|
|
173
319
|
const end = content.indexOf(AUTO_END);
|
|
@@ -303,7 +449,8 @@ var RULES = [
|
|
|
303
449
|
"openspec/.fet.lock",
|
|
304
450
|
"openspec/.fet-init-journal.json",
|
|
305
451
|
"openspec/changes/*/fet-state.json",
|
|
306
|
-
"openspec/changes/*/.fet/"
|
|
452
|
+
"openspec/changes/*/.fet/",
|
|
453
|
+
".gitnexus/"
|
|
307
454
|
];
|
|
308
455
|
function mergeGitignore(existing) {
|
|
309
456
|
const block = `${BEGIN}
|
|
@@ -325,8 +472,8 @@ ${block}
|
|
|
325
472
|
}
|
|
326
473
|
|
|
327
474
|
// src/commands/update-context.ts
|
|
328
|
-
import { readFile as
|
|
329
|
-
import { join as
|
|
475
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
476
|
+
import { join as join7 } from "path";
|
|
330
477
|
|
|
331
478
|
// src/config/yaml.ts
|
|
332
479
|
import { readFile as readFile3 } from "fs/promises";
|
|
@@ -345,23 +492,43 @@ async function mergeFetConfig(configPath, renderedFetYaml) {
|
|
|
345
492
|
return doc.toString();
|
|
346
493
|
}
|
|
347
494
|
|
|
495
|
+
// src/context-placeholders.ts
|
|
496
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
497
|
+
import { join as join6 } from "path";
|
|
498
|
+
var AGENTS_LLM_PLACEHOLDER_PATTERN = /\[NEEDS? LLM INPUT\]/g;
|
|
499
|
+
async function countAgentsLlmPlaceholders(projectRoot) {
|
|
500
|
+
try {
|
|
501
|
+
const content = await readFile4(join6(projectRoot, "AGENTS.md"), "utf8");
|
|
502
|
+
return [...content.matchAll(AGENTS_LLM_PLACEHOLDER_PATTERN)].length;
|
|
503
|
+
} catch {
|
|
504
|
+
return 0;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function renderAgentsPlaceholderWarning(count2) {
|
|
508
|
+
return `AGENTS.md still contains ${count2} LLM placeholder(s). Run fet fill-context first so your IDE AI can replace them. Continuing current command.`;
|
|
509
|
+
}
|
|
510
|
+
|
|
348
511
|
// src/commands/update-context.ts
|
|
349
512
|
async function updateContextCommand(ctx) {
|
|
513
|
+
let contextResult = { warnings: [] };
|
|
350
514
|
await withProjectLock(
|
|
351
515
|
ctx.projectRoot,
|
|
352
516
|
{ command: "update-context", cwd: ctx.cwd, fetVersion: ctx.fetVersion },
|
|
353
|
-
async () =>
|
|
517
|
+
async () => {
|
|
518
|
+
contextResult = await updateContextFiles(ctx);
|
|
519
|
+
}
|
|
354
520
|
);
|
|
355
521
|
ctx.output.result({
|
|
356
522
|
ok: true,
|
|
357
523
|
command: "update-context",
|
|
358
|
-
summary: "\u5DF2\u66F4\u65B0 AGENTS.md \u4E0E openspec/config.yaml \u7684 FET \u6258\u7BA1\u533A\u57DF\u3002"
|
|
524
|
+
summary: "\u5DF2\u66F4\u65B0 AGENTS.md \u4E0E openspec/config.yaml \u7684 FET \u6258\u7BA1\u533A\u57DF\u3002",
|
|
525
|
+
warnings: contextResult.warnings
|
|
359
526
|
});
|
|
360
527
|
}
|
|
361
528
|
async function updateContextFiles(ctx) {
|
|
362
529
|
const scan = await ctx.scanner.scan(ctx.projectRoot, {});
|
|
363
|
-
const agentsPath =
|
|
364
|
-
const configPath =
|
|
530
|
+
const agentsPath = join7(ctx.projectRoot, "AGENTS.md");
|
|
531
|
+
const configPath = join7(ctx.projectRoot, "openspec", "config.yaml");
|
|
365
532
|
const existingAgents = await readOptional(agentsPath);
|
|
366
533
|
const warnings = [...scan.warnings];
|
|
367
534
|
if (existingAgents && hasInvalidManagedAutoRegion(existingAgents)) {
|
|
@@ -388,6 +555,10 @@ async function updateContextFiles(ctx) {
|
|
|
388
555
|
}
|
|
389
556
|
await atomicWrite(agentsPath, replaceManagedRegion(existingAgents, renderAgentsMd(scan)));
|
|
390
557
|
await atomicWrite(configPath, await mergeFetConfig(configPath, renderFetConfig(scan)));
|
|
558
|
+
const placeholderCount = await countAgentsLlmPlaceholders(ctx.projectRoot);
|
|
559
|
+
if (placeholderCount > 0) {
|
|
560
|
+
warnings.push(renderAgentsPlaceholderWarning(placeholderCount));
|
|
561
|
+
}
|
|
391
562
|
const state = await ctx.stateStore.getOrCreateGlobal();
|
|
392
563
|
state.context = {
|
|
393
564
|
agentsMdUpdatedAt: scan.generatedAt,
|
|
@@ -399,7 +570,7 @@ async function updateContextFiles(ctx) {
|
|
|
399
570
|
}
|
|
400
571
|
async function readOptional(path) {
|
|
401
572
|
try {
|
|
402
|
-
return await
|
|
573
|
+
return await readFile5(path, "utf8");
|
|
403
574
|
} catch {
|
|
404
575
|
return null;
|
|
405
576
|
}
|
|
@@ -407,7 +578,8 @@ async function readOptional(path) {
|
|
|
407
578
|
|
|
408
579
|
// src/commands/init.ts
|
|
409
580
|
async function initCommand(ctx) {
|
|
410
|
-
const alreadyInitialized = await exists(
|
|
581
|
+
const alreadyInitialized = await exists(join8(ctx.projectRoot, "openspec", "config.yaml"));
|
|
582
|
+
let warnings = [];
|
|
411
583
|
await withProjectLock(
|
|
412
584
|
ctx.projectRoot,
|
|
413
585
|
{ command: "init", cwd: ctx.cwd, fetVersion: ctx.fetVersion },
|
|
@@ -423,9 +595,20 @@ async function initCommand(ctx) {
|
|
|
423
595
|
}
|
|
424
596
|
}
|
|
425
597
|
const contextResult = await updateContextFiles(ctx);
|
|
598
|
+
warnings = contextResult.warnings;
|
|
426
599
|
await ensureGitignore(ctx);
|
|
427
600
|
const state = await ctx.stateStore.getOrCreateGlobal();
|
|
428
601
|
state.openspec = identity;
|
|
602
|
+
state.graph ??= {};
|
|
603
|
+
const gitnexus = mergeGitNexusGraphInfo(
|
|
604
|
+
toGitNexusState(await detectGitNexus(), state.graph.gitnexus),
|
|
605
|
+
await inspectGitNexusGraph(ctx.projectRoot)
|
|
606
|
+
);
|
|
607
|
+
if (!gitnexus.installed && !gitnexus.recommendationShownAt) {
|
|
608
|
+
warnings.push(renderGitNexusRecommendation(gitnexus));
|
|
609
|
+
gitnexus.recommendationShownAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
610
|
+
}
|
|
611
|
+
state.graph.gitnexus = gitnexus;
|
|
429
612
|
for (const adapter of ctx.toolAdapters) {
|
|
430
613
|
const plan = await adapter.planInstall(ctx.projectRoot);
|
|
431
614
|
const result = await adapter.install(ctx.projectRoot, plan, ctx.yes);
|
|
@@ -439,33 +622,31 @@ async function initCommand(ctx) {
|
|
|
439
622
|
journal.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
440
623
|
await writeInitJournal(ctx.projectRoot, journal);
|
|
441
624
|
await ctx.stateStore.writeGlobal(state);
|
|
442
|
-
for (const warning of contextResult.warnings) {
|
|
443
|
-
ctx.output.warn(warning);
|
|
444
|
-
}
|
|
445
625
|
}
|
|
446
626
|
);
|
|
447
627
|
ctx.output.result({
|
|
448
628
|
ok: true,
|
|
449
629
|
command: "init",
|
|
450
630
|
summary: "FET \u521D\u59CB\u5316\u5B8C\u6210\u3002",
|
|
631
|
+
warnings,
|
|
451
632
|
nextSteps: ["\u4F7F\u7528 fet propose/new \u521B\u5EFA OpenSpec change", "\u4F7F\u7528 fet doctor \u68C0\u67E5\u9879\u76EE\u72B6\u6001"]
|
|
452
633
|
});
|
|
453
634
|
}
|
|
454
635
|
async function ensureGitignore(ctx) {
|
|
455
|
-
const gitignorePath =
|
|
636
|
+
const gitignorePath = join8(ctx.projectRoot, ".gitignore");
|
|
456
637
|
const existing = await readOptional2(gitignorePath);
|
|
457
638
|
await atomicWrite(gitignorePath, mergeGitignore(existing));
|
|
458
639
|
}
|
|
459
640
|
async function readOptional2(path) {
|
|
460
641
|
try {
|
|
461
|
-
return await
|
|
642
|
+
return await readFile6(path, "utf8");
|
|
462
643
|
} catch {
|
|
463
644
|
return null;
|
|
464
645
|
}
|
|
465
646
|
}
|
|
466
647
|
async function exists(path) {
|
|
467
648
|
try {
|
|
468
|
-
await
|
|
649
|
+
await stat3(path);
|
|
469
650
|
return true;
|
|
470
651
|
} catch {
|
|
471
652
|
return false;
|
|
@@ -473,19 +654,20 @@ async function exists(path) {
|
|
|
473
654
|
}
|
|
474
655
|
|
|
475
656
|
// src/commands/doctor.ts
|
|
476
|
-
import { readFile as
|
|
477
|
-
import { join as
|
|
657
|
+
import { readFile as readFile7, stat as stat4 } from "fs/promises";
|
|
658
|
+
import { join as join9 } from "path";
|
|
478
659
|
async function doctorCommand(ctx, options = {}) {
|
|
479
660
|
const checks = [];
|
|
480
661
|
checks.push(await checkOpenSpec(ctx));
|
|
481
662
|
checks.push(await checkState(ctx));
|
|
482
|
-
checks.push(await checkFile("agents",
|
|
483
|
-
checks.push(await checkFile("config",
|
|
484
|
-
checks.push(await checkPlaceholders(
|
|
663
|
+
checks.push(await checkFile("agents", join9(ctx.projectRoot, "AGENTS.md"), "AGENTS.md \u7F3A\u5931", "fet update-context"));
|
|
664
|
+
checks.push(await checkFile("config", join9(ctx.projectRoot, "openspec", "config.yaml"), "openspec/config.yaml \u7F3A\u5931", "fet init"));
|
|
665
|
+
checks.push(await checkPlaceholders(ctx.projectRoot));
|
|
666
|
+
checks.push(await checkGitNexus(ctx));
|
|
485
667
|
for (const adapter of ctx.toolAdapters) {
|
|
486
668
|
checks.push(...await adapter.doctor(ctx.projectRoot));
|
|
487
669
|
}
|
|
488
|
-
const lockPath =
|
|
670
|
+
const lockPath = join9(ctx.projectRoot, "openspec", ".fet.lock");
|
|
489
671
|
if (await exists2(lockPath)) {
|
|
490
672
|
if (options.fixLock) {
|
|
491
673
|
await clearLock(ctx.projectRoot);
|
|
@@ -503,6 +685,28 @@ async function doctorCommand(ctx, options = {}) {
|
|
|
503
685
|
data: checks
|
|
504
686
|
});
|
|
505
687
|
}
|
|
688
|
+
async function checkGitNexus(ctx) {
|
|
689
|
+
const global = await ctx.stateStore.readGlobal();
|
|
690
|
+
const state = mergeGitNexusGraphInfo(
|
|
691
|
+
toGitNexusState(await detectGitNexus(), global?.graph?.gitnexus),
|
|
692
|
+
await inspectGitNexusGraph(ctx.projectRoot)
|
|
693
|
+
);
|
|
694
|
+
if (global) {
|
|
695
|
+
global.graph ??= {};
|
|
696
|
+
global.graph.gitnexus = state;
|
|
697
|
+
await ctx.stateStore.writeGlobal(global);
|
|
698
|
+
}
|
|
699
|
+
return state.installed ? {
|
|
700
|
+
id: "gitnexus",
|
|
701
|
+
status: "pass",
|
|
702
|
+
message: `GitNexus detected: ${state.executablePath ?? "gitnexus"} (${state.version ?? "unknown"}), graph ${state.graphExists ? "found" : "not found"}`
|
|
703
|
+
} : {
|
|
704
|
+
id: "gitnexus",
|
|
705
|
+
status: "warn",
|
|
706
|
+
message: "Optional GitNexus code graph support is not installed",
|
|
707
|
+
suggestedCommand: "Install GitNexus later if you want OpenSpec artifacts to prefer a repository graph"
|
|
708
|
+
};
|
|
709
|
+
}
|
|
506
710
|
async function checkOpenSpec(ctx) {
|
|
507
711
|
try {
|
|
508
712
|
const identity = await ctx.openSpec.resolveExecutable();
|
|
@@ -522,10 +726,10 @@ async function checkState(ctx) {
|
|
|
522
726
|
async function checkFile(id, path, missing, suggestedCommand) {
|
|
523
727
|
return await exists2(path) ? { id, status: "pass", message: `${id} \u5B58\u5728` } : { id, status: "warn", message: missing, suggestedCommand };
|
|
524
728
|
}
|
|
525
|
-
async function checkPlaceholders(
|
|
729
|
+
async function checkPlaceholders(projectRoot) {
|
|
526
730
|
try {
|
|
527
|
-
|
|
528
|
-
const count2 =
|
|
731
|
+
await readFile7(join9(projectRoot, "AGENTS.md"), "utf8");
|
|
732
|
+
const count2 = await countAgentsLlmPlaceholders(projectRoot);
|
|
529
733
|
return count2 ? {
|
|
530
734
|
id: "context-placeholders",
|
|
531
735
|
status: "warn",
|
|
@@ -538,7 +742,7 @@ async function checkPlaceholders(path) {
|
|
|
538
742
|
}
|
|
539
743
|
async function exists2(path) {
|
|
540
744
|
try {
|
|
541
|
-
await
|
|
745
|
+
await stat4(path);
|
|
542
746
|
return true;
|
|
543
747
|
} catch {
|
|
544
748
|
return false;
|
|
@@ -546,15 +750,14 @@ async function exists2(path) {
|
|
|
546
750
|
}
|
|
547
751
|
|
|
548
752
|
// src/commands/fill-context.ts
|
|
549
|
-
import { mkdir as mkdir3
|
|
550
|
-
import { dirname as dirname5, join as
|
|
551
|
-
var placeholderPattern = /\[NEEDS? LLM INPUT\]/g;
|
|
753
|
+
import { mkdir as mkdir3 } from "fs/promises";
|
|
754
|
+
import { dirname as dirname5, join as join10 } from "path";
|
|
552
755
|
async function fillContextCommand(ctx) {
|
|
553
756
|
await withProjectLock(
|
|
554
757
|
ctx.projectRoot,
|
|
555
758
|
{ command: "fill-context", cwd: ctx.cwd, fetVersion: ctx.fetVersion },
|
|
556
759
|
async () => {
|
|
557
|
-
const handoffPath =
|
|
760
|
+
const handoffPath = join10(ctx.projectRoot, ".fet", "fill-context.md");
|
|
558
761
|
await mkdir3(dirname5(handoffPath), { recursive: true });
|
|
559
762
|
await atomicWrite(handoffPath, renderGenericHandoff());
|
|
560
763
|
for (const adapter of ctx.toolAdapters) {
|
|
@@ -573,7 +776,7 @@ async function fillContextCommand(ctx) {
|
|
|
573
776
|
}
|
|
574
777
|
}
|
|
575
778
|
);
|
|
576
|
-
const placeholders = await
|
|
779
|
+
const placeholders = await countAgentsLlmPlaceholders(ctx.projectRoot);
|
|
577
780
|
ctx.output.result({
|
|
578
781
|
ok: true,
|
|
579
782
|
command: "fill-context",
|
|
@@ -608,23 +811,265 @@ Use the IDE AI to complete FET-generated placeholders.
|
|
|
608
811
|
6. Run \`fet doctor\` and confirm no AGENTS.md placeholder warning remains.
|
|
609
812
|
`;
|
|
610
813
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
814
|
+
|
|
815
|
+
// src/commands/graph.ts
|
|
816
|
+
import { mkdir as mkdir4 } from "fs/promises";
|
|
817
|
+
import { dirname as dirname6, join as join11 } from "path";
|
|
818
|
+
async function graphCommand(ctx, action, args = []) {
|
|
819
|
+
switch (action) {
|
|
820
|
+
case "status":
|
|
821
|
+
await graphStatusCommand(ctx);
|
|
822
|
+
return;
|
|
823
|
+
case "doctor":
|
|
824
|
+
await graphDoctorCommand(ctx);
|
|
825
|
+
return;
|
|
826
|
+
case "setup":
|
|
827
|
+
await graphSetupCommand(ctx);
|
|
828
|
+
return;
|
|
829
|
+
case "handoff":
|
|
830
|
+
await graphHandoffCommand(ctx);
|
|
831
|
+
return;
|
|
832
|
+
case "init":
|
|
833
|
+
await graphAnalyzeCommand(ctx, "init", args);
|
|
834
|
+
return;
|
|
835
|
+
case "refresh":
|
|
836
|
+
await graphAnalyzeCommand(ctx, "refresh", args);
|
|
837
|
+
return;
|
|
617
838
|
}
|
|
618
839
|
}
|
|
840
|
+
async function graphStatusCommand(ctx) {
|
|
841
|
+
const result = await refreshGraphState(ctx, { runStatus: true });
|
|
842
|
+
const warnings = result.state.installed ? [] : ["GitNexus is not installed. Run fet graph setup for installation handoff instructions."];
|
|
843
|
+
ctx.output.result({
|
|
844
|
+
ok: true,
|
|
845
|
+
command: "graph status",
|
|
846
|
+
summary: result.state.installed ? `GitNexus graph status checked. Graph ${result.state.graphExists ? "exists" : "does not exist"} at ${result.state.graphPath ?? ".gitnexus"}.` : "GitNexus is not installed. Graph support remains optional.",
|
|
847
|
+
warnings,
|
|
848
|
+
nextSteps: result.state.installed && !result.state.graphExists ? ["Run fet graph init to build the first GitNexus graph"] : void 0,
|
|
849
|
+
data: result
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
async function graphDoctorCommand(ctx) {
|
|
853
|
+
const result = await refreshGraphState(ctx, { runStatus: true });
|
|
854
|
+
const warnings = [
|
|
855
|
+
...!result.state.installed ? ["GitNexus is not installed."] : [],
|
|
856
|
+
...result.state.installed && !result.state.graphExists ? ["GitNexus is installed but no graph directory was found."] : [],
|
|
857
|
+
...!result.state.handoffPath ? ["Graph handoff instructions have not been generated."] : []
|
|
858
|
+
];
|
|
859
|
+
ctx.output.result({
|
|
860
|
+
ok: true,
|
|
861
|
+
command: "graph doctor",
|
|
862
|
+
summary: warnings.length ? `Graph doctor completed with ${warnings.length} warning(s).` : "Graph doctor completed without warnings.",
|
|
863
|
+
warnings,
|
|
864
|
+
nextSteps: warnings.length ? ["Run fet graph setup", "Run fet graph handoff", "Run fet graph init when GitNexus is installed"] : void 0,
|
|
865
|
+
data: result
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
async function graphSetupCommand(ctx) {
|
|
869
|
+
let result;
|
|
870
|
+
const handoffPath = join11(ctx.projectRoot, ".fet", "graph-setup.md");
|
|
871
|
+
await withProjectLock(ctx.projectRoot, { command: "graph setup", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
|
|
872
|
+
result = await refreshGraphState(ctx, { write: false });
|
|
873
|
+
await writeHandoffFile(handoffPath, renderGraphSetupHandoff(result.state));
|
|
874
|
+
const global = await ctx.stateStore.getOrCreateGlobal();
|
|
875
|
+
global.graph ??= {};
|
|
876
|
+
global.graph.gitnexus = {
|
|
877
|
+
...result.state,
|
|
878
|
+
setupHandoffPath: ".fet/graph-setup.md",
|
|
879
|
+
setupHandoffUpdatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
880
|
+
};
|
|
881
|
+
await ctx.stateStore.writeGlobal(global);
|
|
882
|
+
});
|
|
883
|
+
ctx.output.result({
|
|
884
|
+
ok: true,
|
|
885
|
+
command: "graph setup",
|
|
886
|
+
summary: "GitNexus setup handoff generated.",
|
|
887
|
+
warnings: result.state.installed ? [] : ["GitNexus is not installed. The handoff explains installation and IDE-assisted setup options."],
|
|
888
|
+
nextSteps: result.state.installed ? ["Run gitnexus setup if you want to configure IDE/MCP integrations", "Run fet graph init"] : ["Open .fet/graph-setup.md in your IDE AI"],
|
|
889
|
+
data: {
|
|
890
|
+
path: ".fet/graph-setup.md",
|
|
891
|
+
gitnexus: result.state
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
async function graphHandoffCommand(ctx) {
|
|
896
|
+
let result;
|
|
897
|
+
const handoffPath = join11(ctx.projectRoot, ".fet", "graph-handoff.md");
|
|
898
|
+
await withProjectLock(ctx.projectRoot, { command: "graph handoff", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
|
|
899
|
+
result = await refreshGraphState(ctx, { runStatus: true, write: false });
|
|
900
|
+
await writeHandoffFile(handoffPath, renderGraphUsageHandoff(result.state));
|
|
901
|
+
const global = await ctx.stateStore.getOrCreateGlobal();
|
|
902
|
+
global.graph ??= {};
|
|
903
|
+
global.graph.gitnexus = {
|
|
904
|
+
...result.state,
|
|
905
|
+
handoffPath: ".fet/graph-handoff.md",
|
|
906
|
+
handoffUpdatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
907
|
+
};
|
|
908
|
+
await ctx.stateStore.writeGlobal(global);
|
|
909
|
+
});
|
|
910
|
+
ctx.output.result({
|
|
911
|
+
ok: true,
|
|
912
|
+
command: "graph handoff",
|
|
913
|
+
summary: "GitNexus graph usage handoff generated.",
|
|
914
|
+
warnings: result.state.installed ? [] : ["GitNexus is not installed. The handoff still documents the fallback behavior."],
|
|
915
|
+
nextSteps: ["Cursor/Codex/OpenCode: read .fet/graph-handoff.md before broad repository scans"],
|
|
916
|
+
data: {
|
|
917
|
+
path: ".fet/graph-handoff.md",
|
|
918
|
+
gitnexus: result.state
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
async function graphAnalyzeCommand(ctx, mode, args) {
|
|
923
|
+
const detection = await detectGitNexus();
|
|
924
|
+
if (!detection.installed) {
|
|
925
|
+
throw new FetError({
|
|
926
|
+
code: "GRAPH_PROVIDER_NOT_FOUND" /* GraphProviderNotFound */,
|
|
927
|
+
message: "GitNexus is not installed or is not available on PATH.",
|
|
928
|
+
details: { executable: detection.executablePath, error: detection.error },
|
|
929
|
+
suggestedCommand: "fet graph setup"
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
const run = await runGitNexus(["analyze", ...args], { cwd: ctx.projectRoot });
|
|
933
|
+
if (run.exitCode !== 0) {
|
|
934
|
+
throw new FetError({
|
|
935
|
+
code: "GRAPH_COMMAND_FAILED" /* GraphCommandFailed */,
|
|
936
|
+
message: "GitNexus analyze failed.",
|
|
937
|
+
details: { command: run.command.join(" "), exitCode: run.exitCode, stdout: run.stdout, stderr: run.stderr },
|
|
938
|
+
suggestedCommand: "fet graph doctor"
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
const result = await refreshGraphState(ctx, { write: false });
|
|
942
|
+
const global = await ctx.stateStore.getOrCreateGlobal();
|
|
943
|
+
global.graph ??= {};
|
|
944
|
+
global.graph.gitnexus = {
|
|
945
|
+
...result.state,
|
|
946
|
+
lastRefreshAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
947
|
+
};
|
|
948
|
+
await ctx.stateStore.writeGlobal(global);
|
|
949
|
+
ctx.output.result({
|
|
950
|
+
ok: true,
|
|
951
|
+
command: `graph ${mode}`,
|
|
952
|
+
summary: mode === "init" ? "GitNexus graph initialized." : "GitNexus graph refreshed.",
|
|
953
|
+
warnings: result.state.graphExists ? [] : ["GitNexus analyze completed, but the configured graph directory was not found."],
|
|
954
|
+
nextSteps: ["Run fet graph status", "Use .fet/graph-handoff.md or generated IDE prompts to prefer graph context"],
|
|
955
|
+
data: {
|
|
956
|
+
gitnexus: global.graph.gitnexus,
|
|
957
|
+
run: {
|
|
958
|
+
command: run.command,
|
|
959
|
+
stdout: run.stdout.trim(),
|
|
960
|
+
stderr: run.stderr.trim()
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
async function refreshGraphState(ctx, options = {}) {
|
|
966
|
+
const global = await ctx.stateStore.getOrCreateGlobal();
|
|
967
|
+
global.graph ??= {};
|
|
968
|
+
const detection = await detectGitNexus();
|
|
969
|
+
const graph2 = await inspectGitNexusGraph(ctx.projectRoot);
|
|
970
|
+
let state = mergeGitNexusGraphInfo(toGitNexusState(detection, global.graph.gitnexus), graph2);
|
|
971
|
+
let gitnexusStatus = null;
|
|
972
|
+
if (options.runStatus && detection.installed) {
|
|
973
|
+
gitnexusStatus = await runGitNexus(["status"], { cwd: ctx.projectRoot });
|
|
974
|
+
state = {
|
|
975
|
+
...state,
|
|
976
|
+
lastStatus: firstLine(gitnexusStatus.stdout) || firstLine(gitnexusStatus.stderr) || `exit ${gitnexusStatus.exitCode}`
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
if (options.write ?? true) {
|
|
980
|
+
global.graph.gitnexus = state;
|
|
981
|
+
await ctx.stateStore.writeGlobal(global);
|
|
982
|
+
}
|
|
983
|
+
return {
|
|
984
|
+
state,
|
|
985
|
+
gitnexusStatus: gitnexusStatus ? {
|
|
986
|
+
exitCode: gitnexusStatus.exitCode,
|
|
987
|
+
command: gitnexusStatus.command,
|
|
988
|
+
stdout: gitnexusStatus.stdout.trim(),
|
|
989
|
+
stderr: gitnexusStatus.stderr.trim()
|
|
990
|
+
} : null
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
async function writeHandoffFile(path, content) {
|
|
994
|
+
await mkdir4(dirname6(path), { recursive: true });
|
|
995
|
+
await atomicWrite(path, content);
|
|
996
|
+
}
|
|
997
|
+
function renderGraphSetupHandoff(state) {
|
|
998
|
+
return `<!-- FET:MANAGED
|
|
999
|
+
schemaVersion: 1
|
|
1000
|
+
generator: graph-setup
|
|
1001
|
+
FET:END -->
|
|
1002
|
+
|
|
1003
|
+
# FET Graph Setup
|
|
1004
|
+
|
|
1005
|
+
GitNexus graph support is optional. FET does not install GitNexus automatically and does not require graph support for OpenSpec workflows.
|
|
1006
|
+
|
|
1007
|
+
Current status:
|
|
1008
|
+
|
|
1009
|
+
- Installed: ${state.installed ? "yes" : "no"}
|
|
1010
|
+
- Executable: ${state.executablePath ?? "gitnexus"}
|
|
1011
|
+
- Version: ${state.version ?? "unknown"}
|
|
1012
|
+
- Graph path: ${state.graphPath ?? ".gitnexus"}
|
|
1013
|
+
- Graph exists: ${state.graphExists ? "yes" : "no"}
|
|
1014
|
+
|
|
1015
|
+
Suggested setup flow:
|
|
1016
|
+
|
|
1017
|
+
1. If GitNexus is not installed, install it using the method recommended by the GitNexus project.
|
|
1018
|
+
2. If you want GitNexus MCP or IDE integration, run \`gitnexus setup\` yourself after reviewing what it changes.
|
|
1019
|
+
3. Return to this project and run \`fet graph init\` to build the first graph.
|
|
1020
|
+
4. Run \`fet graph handoff\` so IDE AI can prefer graph context before broad repository scans.
|
|
1021
|
+
|
|
1022
|
+
Guardrails:
|
|
1023
|
+
|
|
1024
|
+
- Do not block FET/OpenSpec commands when GitNexus is unavailable.
|
|
1025
|
+
- Do not generate or modify application code during setup.
|
|
1026
|
+
- Do not run global IDE configuration commands unless the user explicitly approves them.
|
|
1027
|
+
`;
|
|
1028
|
+
}
|
|
1029
|
+
function renderGraphUsageHandoff(state) {
|
|
1030
|
+
return `<!-- FET:MANAGED
|
|
1031
|
+
schemaVersion: 1
|
|
1032
|
+
generator: graph-handoff
|
|
1033
|
+
FET:END -->
|
|
1034
|
+
|
|
1035
|
+
# FET Graph Handoff
|
|
1036
|
+
|
|
1037
|
+
Use GitNexus graph context as an optional first pass before broad repository scans.
|
|
1038
|
+
|
|
1039
|
+
Current status:
|
|
1040
|
+
|
|
1041
|
+
- Installed: ${state.installed ? "yes" : "no"}
|
|
1042
|
+
- Graph path: ${state.graphPath ?? ".gitnexus"}
|
|
1043
|
+
- Graph exists: ${state.graphExists ? "yes" : "no"}
|
|
1044
|
+
- Last indexed at: ${state.lastIndexedAt ?? "unknown"}
|
|
1045
|
+
- Last status: ${state.lastStatus ?? "unknown"}
|
|
1046
|
+
|
|
1047
|
+
When graph context is available:
|
|
1048
|
+
|
|
1049
|
+
1. Use the graph to identify likely modules, dependencies, and insertion points.
|
|
1050
|
+
2. Read only the concrete source files needed to confirm behavior.
|
|
1051
|
+
3. Prefer OpenSpec artifacts and AGENTS.md over graph guesses when they conflict.
|
|
1052
|
+
4. Fall back to normal repository inspection if the graph is missing, stale, or incomplete.
|
|
1053
|
+
|
|
1054
|
+
When producing OpenSpec artifacts:
|
|
1055
|
+
|
|
1056
|
+
- Use graph context to make proposal, design, specs, and tasks more precise.
|
|
1057
|
+
- Avoid large repository scans when the graph already narrows the relevant area.
|
|
1058
|
+
- Keep all generated artifacts in the normal OpenSpec change directory.
|
|
1059
|
+
`;
|
|
1060
|
+
}
|
|
1061
|
+
function firstLine(value) {
|
|
1062
|
+
return value.trim().split(/\r?\n/)[0]?.trim() || null;
|
|
1063
|
+
}
|
|
619
1064
|
|
|
620
1065
|
// src/commands/proxy.ts
|
|
621
1066
|
import { readFile as readFile10 } from "fs/promises";
|
|
622
|
-
import { join as
|
|
1067
|
+
import { join as join13 } from "path";
|
|
623
1068
|
|
|
624
1069
|
// src/state/project.ts
|
|
625
|
-
import { execFile } from "child_process";
|
|
626
|
-
import { promisify } from "util";
|
|
627
|
-
var
|
|
1070
|
+
import { execFile as execFile2 } from "child_process";
|
|
1071
|
+
import { promisify as promisify2 } from "util";
|
|
1072
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
628
1073
|
async function detectProjectIdentity(projectRoot) {
|
|
629
1074
|
const [gitRoot, branch, headCommit] = await Promise.all([
|
|
630
1075
|
git(projectRoot, ["rev-parse", "--show-toplevel"]),
|
|
@@ -640,7 +1085,7 @@ async function detectProjectIdentity(projectRoot) {
|
|
|
640
1085
|
}
|
|
641
1086
|
async function git(cwd, args) {
|
|
642
1087
|
try {
|
|
643
|
-
const { stdout } = await
|
|
1088
|
+
const { stdout } = await execFileAsync2("git", args, { cwd });
|
|
644
1089
|
return stdout.trim() || null;
|
|
645
1090
|
} catch {
|
|
646
1091
|
return null;
|
|
@@ -648,8 +1093,8 @@ async function git(cwd, args) {
|
|
|
648
1093
|
}
|
|
649
1094
|
|
|
650
1095
|
// src/state/store.ts
|
|
651
|
-
import { mkdir as
|
|
652
|
-
import { join as
|
|
1096
|
+
import { mkdir as mkdir5, readFile as readFile8 } from "fs/promises";
|
|
1097
|
+
import { join as join12 } from "path";
|
|
653
1098
|
|
|
654
1099
|
// src/state/schema.ts
|
|
655
1100
|
var phases = ["explore", "propose", "implement", "verify", "sync", "archive"];
|
|
@@ -670,6 +1115,7 @@ function createGlobalState(fetVersion, project) {
|
|
|
670
1115
|
scannerVersion: 1
|
|
671
1116
|
},
|
|
672
1117
|
toolAdapters: {},
|
|
1118
|
+
graph: {},
|
|
673
1119
|
verifyAuthorization: null,
|
|
674
1120
|
lastDoctor: null
|
|
675
1121
|
};
|
|
@@ -757,7 +1203,7 @@ var StateStore = class {
|
|
|
757
1203
|
}
|
|
758
1204
|
async writeGlobal(state) {
|
|
759
1205
|
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
760
|
-
await
|
|
1206
|
+
await mkdir5(join12(this.projectRoot, "openspec"), { recursive: true });
|
|
761
1207
|
await atomicWrite(this.globalPath(), `${JSON.stringify(state, null, 2)}
|
|
762
1208
|
`);
|
|
763
1209
|
}
|
|
@@ -778,15 +1224,15 @@ var StateStore = class {
|
|
|
778
1224
|
}
|
|
779
1225
|
async writeChange(state) {
|
|
780
1226
|
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
781
|
-
await
|
|
1227
|
+
await mkdir5(join12(this.projectRoot, "openspec", "changes", state.changeId), { recursive: true });
|
|
782
1228
|
await atomicWrite(this.changePath(state.changeId), `${JSON.stringify(state, null, 2)}
|
|
783
1229
|
`);
|
|
784
1230
|
}
|
|
785
1231
|
globalPath() {
|
|
786
|
-
return
|
|
1232
|
+
return join12(this.projectRoot, "openspec", "fet-state.json");
|
|
787
1233
|
}
|
|
788
1234
|
changePath(changeId) {
|
|
789
|
-
return
|
|
1235
|
+
return join12(this.projectRoot, "openspec", "changes", changeId, "fet-state.json");
|
|
790
1236
|
}
|
|
791
1237
|
};
|
|
792
1238
|
function isNotFound(error) {
|
|
@@ -903,7 +1349,7 @@ async function createChangelogEntry(projectRoot, changeId) {
|
|
|
903
1349
|
};
|
|
904
1350
|
}
|
|
905
1351
|
async function appendChangelog(projectRoot, entry) {
|
|
906
|
-
const changelogPath =
|
|
1352
|
+
const changelogPath = join13(projectRoot, "CHANGELOG.md");
|
|
907
1353
|
const existing = await readOptional3(changelogPath);
|
|
908
1354
|
const block = `updateTime: ${entry.updateTime}
|
|
909
1355
|
\u66F4\u65B0\u5185\u5BB9:${entry.content}
|
|
@@ -914,12 +1360,12 @@ ${block}` : block;
|
|
|
914
1360
|
await atomicWrite(changelogPath, next);
|
|
915
1361
|
}
|
|
916
1362
|
async function readChangeRequirement(projectRoot, changeId) {
|
|
917
|
-
const changeRoot =
|
|
918
|
-
const proposal = await readOptional3(
|
|
1363
|
+
const changeRoot = join13(projectRoot, "openspec", "changes", changeId);
|
|
1364
|
+
const proposal = await readOptional3(join13(changeRoot, "proposal.md"));
|
|
919
1365
|
if (proposal) {
|
|
920
1366
|
return summarizeMarkdown(proposal);
|
|
921
1367
|
}
|
|
922
|
-
const readme = await readOptional3(
|
|
1368
|
+
const readme = await readOptional3(join13(changeRoot, "README.md"));
|
|
923
1369
|
if (readme) {
|
|
924
1370
|
return summarizeMarkdown(readme);
|
|
925
1371
|
}
|
|
@@ -1063,8 +1509,8 @@ async function assertVerified(ctx) {
|
|
|
1063
1509
|
|
|
1064
1510
|
// src/commands/verify.ts
|
|
1065
1511
|
import { createHash } from "crypto";
|
|
1066
|
-
import { mkdir as
|
|
1067
|
-
import { join as
|
|
1512
|
+
import { mkdir as mkdir6, readFile as readFile11, stat as stat5 } from "fs/promises";
|
|
1513
|
+
import { join as join14 } from "path";
|
|
1068
1514
|
async function verifyCommand(ctx, options) {
|
|
1069
1515
|
if (options.auto) {
|
|
1070
1516
|
const scan = await ctx.scanner.scan(ctx.projectRoot, {});
|
|
@@ -1131,9 +1577,9 @@ async function verifyCommand(ctx, options) {
|
|
|
1131
1577
|
async function writeInstructions(ctx, changeId) {
|
|
1132
1578
|
await assertChangeExists(ctx, changeId);
|
|
1133
1579
|
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1134
|
-
const dir =
|
|
1135
|
-
const instructionsPath =
|
|
1136
|
-
await
|
|
1580
|
+
const dir = join14(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
|
|
1581
|
+
const instructionsPath = join14(dir, "verify-instructions.md");
|
|
1582
|
+
await mkdir6(dir, { recursive: true });
|
|
1137
1583
|
await atomicWrite(instructionsPath, renderVerifyInstructions(changeId, generatedAt));
|
|
1138
1584
|
const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
|
|
1139
1585
|
state.currentPhase = "verify";
|
|
@@ -1149,7 +1595,7 @@ async function writeInstructions(ctx, changeId) {
|
|
|
1149
1595
|
async function markDone(ctx, changeId) {
|
|
1150
1596
|
await assertChangeExists(ctx, changeId);
|
|
1151
1597
|
const declaredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1152
|
-
const instructionsPath =
|
|
1598
|
+
const instructionsPath = join14(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
|
|
1153
1599
|
const instructions = await readInstructions(instructionsPath, changeId);
|
|
1154
1600
|
const instructionsGeneratedAt = readFrontMatterValue(instructions, "generatedAt") ?? declaredAt;
|
|
1155
1601
|
const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
|
|
@@ -1184,7 +1630,7 @@ async function assertChangeExists(ctx, changeId) {
|
|
|
1184
1630
|
}
|
|
1185
1631
|
async function readInstructions(path, changeId) {
|
|
1186
1632
|
try {
|
|
1187
|
-
await
|
|
1633
|
+
await stat5(path);
|
|
1188
1634
|
const content = await readFile11(path, "utf8");
|
|
1189
1635
|
const fileChangeId = readFrontMatterValue(content, "changeId");
|
|
1190
1636
|
if (fileChangeId !== changeId) {
|
|
@@ -1235,13 +1681,76 @@ async function resolveChangeId(ctx) {
|
|
|
1235
1681
|
});
|
|
1236
1682
|
}
|
|
1237
1683
|
|
|
1684
|
+
// src/model-policy.ts
|
|
1685
|
+
var HIGH_COST_MODEL_PATTERNS = [
|
|
1686
|
+
/gpt[-_ ]?5\.5/i,
|
|
1687
|
+
/glm[-_ ]?5(?:\.1)?/i,
|
|
1688
|
+
/claude.*opus/i,
|
|
1689
|
+
/opus/i,
|
|
1690
|
+
/claude.*sonnet/i,
|
|
1691
|
+
/sonnet/i
|
|
1692
|
+
];
|
|
1693
|
+
var MODEL_ENV_KEYS = ["FET_IDE_MODEL", "FET_MODEL", "CODEX_MODEL", "CURSOR_MODEL", "OPENCODE_MODEL", "OPENAI_MODEL", "ANTHROPIC_MODEL"];
|
|
1694
|
+
function detectCurrentModel(env = process.env) {
|
|
1695
|
+
for (const key of MODEL_ENV_KEYS) {
|
|
1696
|
+
const value = env[key]?.trim();
|
|
1697
|
+
if (value) {
|
|
1698
|
+
return { source: key, name: value };
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
return null;
|
|
1702
|
+
}
|
|
1703
|
+
function isHighCostModel(model) {
|
|
1704
|
+
return HIGH_COST_MODEL_PATTERNS.some((pattern) => pattern.test(model));
|
|
1705
|
+
}
|
|
1706
|
+
function getCommandModelPolicyMismatch(command, env = process.env) {
|
|
1707
|
+
if (env.FET_MODEL_POLICY === "off" || env.FET_SKIP_MODEL_POLICY === "1") {
|
|
1708
|
+
return null;
|
|
1709
|
+
}
|
|
1710
|
+
const detected = detectCurrentModel(env);
|
|
1711
|
+
if (!detected) {
|
|
1712
|
+
return null;
|
|
1713
|
+
}
|
|
1714
|
+
const highCost = isHighCostModel(detected.name);
|
|
1715
|
+
if (command === "apply") {
|
|
1716
|
+
if (!highCost) {
|
|
1717
|
+
return {
|
|
1718
|
+
command,
|
|
1719
|
+
detected,
|
|
1720
|
+
recommended: "high-cost",
|
|
1721
|
+
reason: "fet apply is the implementation phase and is recommended to use a high-capability/high-cost model."
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
return null;
|
|
1725
|
+
}
|
|
1726
|
+
if (highCost) {
|
|
1727
|
+
return {
|
|
1728
|
+
command,
|
|
1729
|
+
detected,
|
|
1730
|
+
recommended: "low-cost",
|
|
1731
|
+
reason: `fet ${command} is not the implementation phase and is recommended to use a lower-cost model.`
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
return null;
|
|
1735
|
+
}
|
|
1736
|
+
function formatModelPolicyMismatch(mismatch) {
|
|
1737
|
+
const switchHint = mismatch.recommended === "high-cost" ? "Recommended models include GPT-5.5, GLM-5.1, GLM-5, Claude Opus, or Claude Sonnet." : "Recommended action: switch to a lower-cost model and reserve high-cost models for fet apply.";
|
|
1738
|
+
return `${mismatch.reason} Detected ${mismatch.detected.source}="${mismatch.detected.name}". ${switchHint}`;
|
|
1739
|
+
}
|
|
1740
|
+
function renderIdeModelPolicy(command) {
|
|
1741
|
+
if (command === "apply") {
|
|
1742
|
+
return "Model policy: this command is recommended to run with a high-capability/high-cost model such as GPT-5.5, GLM-5.1, GLM-5, Claude Opus, or Claude Sonnet. If the current IDE model is lower-cost, tell the user and ask whether to stop for a model switch or continue anyway.";
|
|
1743
|
+
}
|
|
1744
|
+
return "Model policy: this command is recommended to run with a low-cost model. If the current IDE model is GPT-5.5, GLM-5.1, GLM-5, Claude Opus, Claude Sonnet, or another high-cost model, tell the user and ask whether to stop for a model switch or continue anyway.";
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1238
1747
|
// src/cli/context.ts
|
|
1239
1748
|
import { resolve } from "path";
|
|
1240
1749
|
|
|
1241
1750
|
// src/adapters/codex/index.ts
|
|
1242
|
-
import { mkdir as
|
|
1751
|
+
import { mkdir as mkdir7, readFile as readFile12, stat as stat6 } from "fs/promises";
|
|
1243
1752
|
import { homedir } from "os";
|
|
1244
|
-
import { dirname as
|
|
1753
|
+
import { dirname as dirname7, join as join15 } from "path";
|
|
1245
1754
|
|
|
1246
1755
|
// src/adapters/commands.ts
|
|
1247
1756
|
var FET_WORKFLOW_COMMANDS = [
|
|
@@ -1257,7 +1766,18 @@ var FET_WORKFLOW_COMMANDS = [
|
|
|
1257
1766
|
"bulk-archive",
|
|
1258
1767
|
"onboard"
|
|
1259
1768
|
];
|
|
1260
|
-
var
|
|
1769
|
+
var FET_GRAPH_COMMANDS = ["graph-status", "graph-setup", "graph-init", "graph-refresh", "graph-doctor", "graph-handoff"];
|
|
1770
|
+
var FET_ADAPTER_COMMANDS = [...FET_WORKFLOW_COMMANDS, "fill-context", "passthrough", ...FET_GRAPH_COMMANDS];
|
|
1771
|
+
function renderFetAdapterUsage(command, args = "[...args]") {
|
|
1772
|
+
if (command.startsWith("graph-")) {
|
|
1773
|
+
const subcommand = command.slice("graph-".length);
|
|
1774
|
+
return `fet graph ${subcommand}${args ? ` ${args}` : ""}`;
|
|
1775
|
+
}
|
|
1776
|
+
if (command === "passthrough") {
|
|
1777
|
+
return `fet passthrough <openspec-command>${args ? ` ${args}` : ""}`;
|
|
1778
|
+
}
|
|
1779
|
+
return `fet ${command}${args ? ` ${args}` : ""}`;
|
|
1780
|
+
}
|
|
1261
1781
|
|
|
1262
1782
|
// src/adapters/codex/templates.ts
|
|
1263
1783
|
function codexGuideFile() {
|
|
@@ -1278,6 +1798,8 @@ Before doing FET or OpenSpec work in Codex, read:
|
|
|
1278
1798
|
- openspec/config.yaml
|
|
1279
1799
|
- the active change files under openspec/changes/<change-id>/, when a change is selected
|
|
1280
1800
|
|
|
1801
|
+
If GitNexus code graph context is available in the IDE or MCP tools, prefer it before broad repository scans. Use it to identify relevant modules, dependencies, and insertion points, then read only the concrete source files needed. If GitNexus is unavailable, continue with the normal FET/OpenSpec workflow.
|
|
1802
|
+
|
|
1281
1803
|
Use the terminal command \`fet <command>\` as the source of truth for workflow transitions. These files are Codex-readable guidance; they do not register native slash commands.
|
|
1282
1804
|
|
|
1283
1805
|
Command guides live in .codex/fet/commands/.
|
|
@@ -1303,22 +1825,30 @@ function renderCommand(command) {
|
|
|
1303
1825
|
if (command === "passthrough") {
|
|
1304
1826
|
return renderPassthroughCommand();
|
|
1305
1827
|
}
|
|
1828
|
+
if (command.startsWith("graph-")) {
|
|
1829
|
+
return renderGraphCommand(command);
|
|
1830
|
+
}
|
|
1831
|
+
const usage = renderFetAdapterUsage(command, "");
|
|
1306
1832
|
return `<!-- FET:MANAGED
|
|
1307
1833
|
schemaVersion: 1
|
|
1308
1834
|
fetVersion: ${FET_VERSION}
|
|
1309
1835
|
generator: codex-adapter
|
|
1310
1836
|
adapterVersion: 1
|
|
1311
|
-
command:
|
|
1837
|
+
command: ${usage}
|
|
1312
1838
|
FET:END -->
|
|
1313
1839
|
|
|
1314
|
-
#
|
|
1840
|
+
# ${usage}
|
|
1841
|
+
|
|
1842
|
+
${renderIdeModelPolicy(command)}
|
|
1315
1843
|
|
|
1316
1844
|
When the user asks Codex to run the FET ${command} workflow, first make sure the project context is loaded from AGENTS.md and openspec/config.yaml.
|
|
1317
1845
|
|
|
1846
|
+
If GitNexus graph context is available, consult it before broad source scans and use it to narrow the files you read. If it is unavailable, continue normally.
|
|
1847
|
+
|
|
1318
1848
|
Then run:
|
|
1319
1849
|
|
|
1320
1850
|
\`\`\`sh
|
|
1321
|
-
|
|
1851
|
+
${usage}
|
|
1322
1852
|
\`\`\`
|
|
1323
1853
|
|
|
1324
1854
|
If the command needs a change id, pass it with \`--change <change-id>\` or use the active OpenSpec change from the user's request.
|
|
@@ -1337,8 +1867,12 @@ FET:END -->
|
|
|
1337
1867
|
|
|
1338
1868
|
# fet passthrough
|
|
1339
1869
|
|
|
1870
|
+
${renderIdeModelPolicy("passthrough")}
|
|
1871
|
+
|
|
1340
1872
|
When the user asks Codex to run an OpenSpec command that FET does not manage as a first-class workflow command, use FET passthrough instead of calling OpenSpec directly.
|
|
1341
1873
|
|
|
1874
|
+
If GitNexus graph context is available, consult it before broad source scans and use it to narrow the files you read. If it is unavailable, continue normally.
|
|
1875
|
+
|
|
1342
1876
|
Then run:
|
|
1343
1877
|
|
|
1344
1878
|
\`\`\`sh
|
|
@@ -1348,6 +1882,36 @@ fet passthrough <openspec-command> [...args]
|
|
|
1348
1882
|
This preserves the FET entry point while allowing access to unmanaged or newly added OpenSpec commands. Passthrough does not update FET lifecycle state.
|
|
1349
1883
|
`;
|
|
1350
1884
|
}
|
|
1885
|
+
function renderGraphCommand(command) {
|
|
1886
|
+
const usage = renderFetAdapterUsage(command, "");
|
|
1887
|
+
const subcommand = command.slice("graph-".length);
|
|
1888
|
+
return `<!-- FET:MANAGED
|
|
1889
|
+
schemaVersion: 1
|
|
1890
|
+
fetVersion: ${FET_VERSION}
|
|
1891
|
+
generator: codex-adapter
|
|
1892
|
+
adapterVersion: 1
|
|
1893
|
+
command: ${usage}
|
|
1894
|
+
FET:END -->
|
|
1895
|
+
|
|
1896
|
+
# ${usage}
|
|
1897
|
+
|
|
1898
|
+
${renderIdeModelPolicy(command)}
|
|
1899
|
+
|
|
1900
|
+
When the user asks Codex to work with optional GitNexus graph support, use FET as the entry point.
|
|
1901
|
+
|
|
1902
|
+
If GitNexus graph context is available, consult it before broad source scans and use it to narrow the files you read. If it is unavailable, continue normally.
|
|
1903
|
+
|
|
1904
|
+
Run:
|
|
1905
|
+
|
|
1906
|
+
\`\`\`sh
|
|
1907
|
+
${usage}
|
|
1908
|
+
\`\`\`
|
|
1909
|
+
|
|
1910
|
+
For graph init or refresh, pass extra GitNexus analyze arguments only when the user provides them.
|
|
1911
|
+
|
|
1912
|
+
After the command completes, report the GitNexus state, generated handoff files, and next steps.
|
|
1913
|
+
`;
|
|
1914
|
+
}
|
|
1351
1915
|
function renderSlashPrompt(command) {
|
|
1352
1916
|
if (command === "continue") {
|
|
1353
1917
|
return renderContinueSlashPrompt();
|
|
@@ -1385,9 +1949,10 @@ function renderSlashPrompt(command) {
|
|
|
1385
1949
|
if (command === "passthrough") {
|
|
1386
1950
|
return renderPassthroughSlashPrompt();
|
|
1387
1951
|
}
|
|
1388
|
-
const usage = command
|
|
1389
|
-
const
|
|
1390
|
-
const
|
|
1952
|
+
const usage = renderFetAdapterUsage(command);
|
|
1953
|
+
const isGraph = command.startsWith("graph-");
|
|
1954
|
+
const shellCommand = isGraph ? `${renderFetAdapterUsage(command, "")} $ARGUMENTS` : `fet ${command} $ARGUMENTS`;
|
|
1955
|
+
const description = isGraph ? `Run optional GitNexus graph ${command.slice("graph-".length)} through FET` : `Run the FET-managed OpenSpec ${command} workflow`;
|
|
1391
1956
|
return `<!-- FET:MANAGED
|
|
1392
1957
|
schemaVersion: 1
|
|
1393
1958
|
fetVersion: ${FET_VERSION}
|
|
@@ -1405,6 +1970,8 @@ Use FET as the entry point for this OpenSpec workflow.
|
|
|
1405
1970
|
|
|
1406
1971
|
Before running the command, make sure the relevant project context is loaded from AGENTS.md and openspec/config.yaml. If a change id is needed and was not provided, infer it from the active FET/OpenSpec state when unambiguous; otherwise ask the user for the change id.
|
|
1407
1972
|
|
|
1973
|
+
If GitNexus graph context is available, consult it before broad source scans and use it to narrow the files you read. If it is unavailable, continue normally.
|
|
1974
|
+
|
|
1408
1975
|
Run:
|
|
1409
1976
|
|
|
1410
1977
|
\`\`\`sh
|
|
@@ -1425,8 +1992,12 @@ FET:END -->
|
|
|
1425
1992
|
|
|
1426
1993
|
# fet fill-context
|
|
1427
1994
|
|
|
1995
|
+
${renderIdeModelPolicy("fill-context")}
|
|
1996
|
+
|
|
1428
1997
|
Use this command to complete FET-generated project context placeholders with Codex.
|
|
1429
1998
|
|
|
1999
|
+
If GitNexus graph context is available, consult it before broad source scans and use it to narrow the files you read. If it is unavailable, continue normally.
|
|
2000
|
+
|
|
1430
2001
|
First run:
|
|
1431
2002
|
|
|
1432
2003
|
\`\`\`sh
|
|
@@ -1809,6 +2380,7 @@ Output:
|
|
|
1809
2380
|
);
|
|
1810
2381
|
}
|
|
1811
2382
|
function renderManagedSlashPrompt(command, description, body) {
|
|
2383
|
+
const policyCommand = command.split(/\s+/)[1] ?? command;
|
|
1812
2384
|
return `<!-- FET:MANAGED
|
|
1813
2385
|
schemaVersion: 1
|
|
1814
2386
|
fetVersion: ${FET_VERSION}
|
|
@@ -1822,6 +2394,10 @@ description: ${description}
|
|
|
1822
2394
|
argument-hint: command arguments
|
|
1823
2395
|
---
|
|
1824
2396
|
|
|
2397
|
+
${renderIdeModelPolicy(policyCommand)}
|
|
2398
|
+
|
|
2399
|
+
If GitNexus graph context is available, consult it before broad source scans and use it to narrow the files you read. If it is unavailable, continue normally.
|
|
2400
|
+
|
|
1825
2401
|
${body}
|
|
1826
2402
|
`;
|
|
1827
2403
|
}
|
|
@@ -1832,7 +2408,7 @@ var CodexAdapter = class {
|
|
|
1832
2408
|
adapterVersion = 1;
|
|
1833
2409
|
async detect(projectRoot) {
|
|
1834
2410
|
return {
|
|
1835
|
-
detected: await exists3(
|
|
2411
|
+
detected: await exists3(join15(projectRoot, ".codex")) || await exists3(join15(projectRoot, "AGENTS.md")),
|
|
1836
2412
|
reason: "Codex adapter is available for projects that use AGENTS.md"
|
|
1837
2413
|
};
|
|
1838
2414
|
}
|
|
@@ -1871,7 +2447,7 @@ var CodexAdapter = class {
|
|
|
1871
2447
|
if (existing && !existing.includes("FET:MANAGED") && force) {
|
|
1872
2448
|
await createBackup(target);
|
|
1873
2449
|
}
|
|
1874
|
-
await
|
|
2450
|
+
await mkdir7(dirname7(target), { recursive: true });
|
|
1875
2451
|
await atomicWrite(target, file.content);
|
|
1876
2452
|
written.push(displayPath);
|
|
1877
2453
|
}
|
|
@@ -1898,9 +2474,9 @@ var CodexAdapter = class {
|
|
|
1898
2474
|
};
|
|
1899
2475
|
function resolveTarget(projectRoot, file) {
|
|
1900
2476
|
if (file.root === "codex-home") {
|
|
1901
|
-
return
|
|
2477
|
+
return join15(resolveCodexHome(), file.path);
|
|
1902
2478
|
}
|
|
1903
|
-
return
|
|
2479
|
+
return join15(projectRoot, file.path);
|
|
1904
2480
|
}
|
|
1905
2481
|
function displayPathFor(file) {
|
|
1906
2482
|
if (file.root === "codex-home") {
|
|
@@ -1909,7 +2485,7 @@ function displayPathFor(file) {
|
|
|
1909
2485
|
return file.path;
|
|
1910
2486
|
}
|
|
1911
2487
|
function resolveCodexHome() {
|
|
1912
|
-
return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ??
|
|
2488
|
+
return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join15(homedir(), ".codex");
|
|
1913
2489
|
}
|
|
1914
2490
|
async function readExisting(path) {
|
|
1915
2491
|
try {
|
|
@@ -1920,7 +2496,7 @@ async function readExisting(path) {
|
|
|
1920
2496
|
}
|
|
1921
2497
|
async function exists3(path) {
|
|
1922
2498
|
try {
|
|
1923
|
-
await
|
|
2499
|
+
await stat6(path);
|
|
1924
2500
|
return true;
|
|
1925
2501
|
} catch {
|
|
1926
2502
|
return false;
|
|
@@ -1928,8 +2504,8 @@ async function exists3(path) {
|
|
|
1928
2504
|
}
|
|
1929
2505
|
|
|
1930
2506
|
// src/adapters/cursor/index.ts
|
|
1931
|
-
import { mkdir as
|
|
1932
|
-
import { dirname as
|
|
2507
|
+
import { mkdir as mkdir8, readFile as readFile13, stat as stat7 } from "fs/promises";
|
|
2508
|
+
import { dirname as dirname8, join as join16 } from "path";
|
|
1933
2509
|
|
|
1934
2510
|
// src/adapters/cursor/templates.ts
|
|
1935
2511
|
function cursorSkillFiles() {
|
|
@@ -1957,6 +2533,7 @@ alwaysApply: false
|
|
|
1957
2533
|
|
|
1958
2534
|
- AGENTS.md
|
|
1959
2535
|
- openspec/config.yaml
|
|
2536
|
+
- GitNexus code graph context, when available. Prefer it before broad repository scans; if it is unavailable, continue normally.
|
|
1960
2537
|
- \u5F53\u524D change \u76EE\u5F55\u4E0B\u7684 OpenSpec \u89C4\u5212\u4EA7\u7269
|
|
1961
2538
|
|
|
1962
2539
|
\u5982\u679C\u7528\u6237\u8F93\u5165\u7C7B\u4F3C \`/fet apply\` \u7684\u8BF7\u6C42\uFF0CCursor \u5F53\u524D\u7248\u672C\u672A\u5FC5\u4F1A\u628A\u672C\u6587\u4EF6\u6CE8\u518C\u4E3A\u539F\u751F slash command\u3002\u6B64\u65F6\u8BF7\u628A\u5B83\u5F53\u4F5C\u5DE5\u4F5C\u6D41\u610F\u56FE\uFF0C\u5E76\u63D0\u793A\u7528\u6237\u5728\u7EC8\u7AEF\u6267\u884C\u5BF9\u5E94\u7684 \`fet <cmd>\` \u547D\u4EE4\u3002
|
|
@@ -1964,7 +2541,7 @@ alwaysApply: false
|
|
|
1964
2541
|
};
|
|
1965
2542
|
}
|
|
1966
2543
|
function renderSkill(command) {
|
|
1967
|
-
const usage = command === "passthrough" ? "
|
|
2544
|
+
const usage = renderFetAdapterUsage(command, command === "passthrough" ? "[...args]" : "");
|
|
1968
2545
|
if (command === "fill-context") {
|
|
1969
2546
|
return `<!-- FET:MANAGED
|
|
1970
2547
|
schemaVersion: 1
|
|
@@ -1982,6 +2559,10 @@ disable-model-invocation: false
|
|
|
1982
2559
|
|
|
1983
2560
|
Run \`fet fill-context\` first if the IDE commands need refreshing.
|
|
1984
2561
|
|
|
2562
|
+
${renderIdeModelPolicy(command)}
|
|
2563
|
+
|
|
2564
|
+
If GitNexus code graph context is available in Cursor, prefer it before broad repository scans. If it is unavailable, continue normally.
|
|
2565
|
+
|
|
1985
2566
|
Then read:
|
|
1986
2567
|
|
|
1987
2568
|
- AGENTS.md
|
|
@@ -2004,6 +2585,10 @@ description: Run FET-managed OpenSpec ${command} workflow from the terminal
|
|
|
2004
2585
|
disable-model-invocation: true
|
|
2005
2586
|
---
|
|
2006
2587
|
|
|
2588
|
+
${renderIdeModelPolicy(command)}
|
|
2589
|
+
|
|
2590
|
+
If GitNexus code graph context is available in Cursor, prefer it before broad repository scans. If it is unavailable, continue normally.
|
|
2591
|
+
|
|
2007
2592
|
\u6CE8\u610F\uFF1A\u6B64\u6587\u4EF6\u91C7\u7528 Cursor Skill \u76EE\u5F55\u7ED3\u6784\u3002\u5B83\u63D0\u4F9B \`/fet-${command}\` \u98CE\u683C\u7684\u5DE5\u4F5C\u6D41\u8BF4\u660E\uFF0C\u4E0D\u627F\u8BFA\u6CE8\u518C \`/fet ${command}\` \u8FD9\u79CD\u5E26\u7A7A\u683C\u7684\u539F\u751F slash command\u3002
|
|
2008
2593
|
|
|
2009
2594
|
\u8BF7\u5728\u7EC8\u7AEF\u4E2D\u6267\u884C\uFF1A
|
|
@@ -2022,7 +2607,7 @@ var CursorAdapter = class {
|
|
|
2022
2607
|
adapterVersion = 1;
|
|
2023
2608
|
async detect(projectRoot) {
|
|
2024
2609
|
return {
|
|
2025
|
-
detected: await exists4(
|
|
2610
|
+
detected: await exists4(join16(projectRoot, ".cursor")),
|
|
2026
2611
|
reason: "Cursor adapter is available for any project"
|
|
2027
2612
|
};
|
|
2028
2613
|
}
|
|
@@ -2039,7 +2624,7 @@ var CursorAdapter = class {
|
|
|
2039
2624
|
const written = [];
|
|
2040
2625
|
const skipped = [];
|
|
2041
2626
|
for (const file of plan.files) {
|
|
2042
|
-
const target =
|
|
2627
|
+
const target = join16(projectRoot, file.path);
|
|
2043
2628
|
const existing = await readExisting2(target);
|
|
2044
2629
|
if (existing && !existing.includes("FET:MANAGED") && !force) {
|
|
2045
2630
|
throw new FetError({
|
|
@@ -2052,7 +2637,7 @@ var CursorAdapter = class {
|
|
|
2052
2637
|
if (existing && !existing.includes("FET:MANAGED") && force) {
|
|
2053
2638
|
await createBackup(target);
|
|
2054
2639
|
}
|
|
2055
|
-
await
|
|
2640
|
+
await mkdir8(dirname8(target), { recursive: true });
|
|
2056
2641
|
await atomicWrite(target, file.content);
|
|
2057
2642
|
written.push(file.path);
|
|
2058
2643
|
}
|
|
@@ -2062,7 +2647,7 @@ var CursorAdapter = class {
|
|
|
2062
2647
|
const plan = await this.planInstall(projectRoot);
|
|
2063
2648
|
const checks = [];
|
|
2064
2649
|
for (const file of plan.files) {
|
|
2065
|
-
const target =
|
|
2650
|
+
const target = join16(projectRoot, file.path);
|
|
2066
2651
|
const content = await readExisting2(target);
|
|
2067
2652
|
const managed = Boolean(content?.includes("FET:MANAGED"));
|
|
2068
2653
|
const versionMatches = Boolean(content?.includes(`adapterVersion: ${this.adapterVersion}`));
|
|
@@ -2085,7 +2670,7 @@ async function readExisting2(path) {
|
|
|
2085
2670
|
}
|
|
2086
2671
|
async function exists4(path) {
|
|
2087
2672
|
try {
|
|
2088
|
-
await
|
|
2673
|
+
await stat7(path);
|
|
2089
2674
|
return true;
|
|
2090
2675
|
} catch {
|
|
2091
2676
|
return false;
|
|
@@ -2093,17 +2678,17 @@ async function exists4(path) {
|
|
|
2093
2678
|
}
|
|
2094
2679
|
|
|
2095
2680
|
// src/openspec/adapter.ts
|
|
2096
|
-
import { execFile as
|
|
2097
|
-
import { promisify as
|
|
2681
|
+
import { execFile as execFile4 } from "child_process";
|
|
2682
|
+
import { promisify as promisify4 } from "util";
|
|
2098
2683
|
|
|
2099
2684
|
// src/openspec/inspector.ts
|
|
2100
|
-
import { readdir, stat as
|
|
2101
|
-
import { join as
|
|
2685
|
+
import { readdir, stat as stat8 } from "fs/promises";
|
|
2686
|
+
import { join as join17 } from "path";
|
|
2102
2687
|
async function inspectOpenSpecProject(projectRoot) {
|
|
2103
|
-
const openspecPath =
|
|
2104
|
-
const changesPath =
|
|
2105
|
-
const legacyArchivePath =
|
|
2106
|
-
const changesArchivePath =
|
|
2688
|
+
const openspecPath = join17(projectRoot, "openspec");
|
|
2689
|
+
const changesPath = join17(openspecPath, "changes");
|
|
2690
|
+
const legacyArchivePath = join17(openspecPath, "archive");
|
|
2691
|
+
const changesArchivePath = join17(changesPath, "archive");
|
|
2107
2692
|
return {
|
|
2108
2693
|
exists: await exists5(openspecPath),
|
|
2109
2694
|
changes: await listDirectories(changesPath, { exclude: ["archive"] }),
|
|
@@ -2111,13 +2696,13 @@ async function inspectOpenSpecProject(projectRoot) {
|
|
|
2111
2696
|
};
|
|
2112
2697
|
}
|
|
2113
2698
|
async function inspectOpenSpecChange(projectRoot, changeId) {
|
|
2114
|
-
const changePath =
|
|
2115
|
-
const tasksPath =
|
|
2116
|
-
const specsPath =
|
|
2699
|
+
const changePath = join17(projectRoot, "openspec", "changes", changeId);
|
|
2700
|
+
const tasksPath = join17(changePath, "tasks.md");
|
|
2701
|
+
const specsPath = join17(changePath, "specs");
|
|
2117
2702
|
return {
|
|
2118
2703
|
changeId,
|
|
2119
2704
|
exists: await exists5(changePath),
|
|
2120
|
-
hasProposal: await exists5(
|
|
2705
|
+
hasProposal: await exists5(join17(changePath, "proposal.md")),
|
|
2121
2706
|
hasTasks: await exists5(tasksPath),
|
|
2122
2707
|
hasSpecs: await exists5(specsPath),
|
|
2123
2708
|
tasksPath,
|
|
@@ -2135,7 +2720,7 @@ async function listDirectories(path, options = {}) {
|
|
|
2135
2720
|
}
|
|
2136
2721
|
async function exists5(path) {
|
|
2137
2722
|
try {
|
|
2138
|
-
await
|
|
2723
|
+
await stat8(path);
|
|
2139
2724
|
return true;
|
|
2140
2725
|
} catch {
|
|
2141
2726
|
return false;
|
|
@@ -2143,9 +2728,9 @@ async function exists5(path) {
|
|
|
2143
2728
|
}
|
|
2144
2729
|
|
|
2145
2730
|
// src/openspec/resolver.ts
|
|
2146
|
-
import { execFile as
|
|
2147
|
-
import { promisify as
|
|
2148
|
-
var
|
|
2731
|
+
import { execFile as execFile3 } from "child_process";
|
|
2732
|
+
import { promisify as promisify3 } from "util";
|
|
2733
|
+
var execFileAsync3 = promisify3(execFile3);
|
|
2149
2734
|
async function resolveOpenSpecExecutable() {
|
|
2150
2735
|
const executablePath = await findExecutable();
|
|
2151
2736
|
const version = await readVersion(executablePath);
|
|
@@ -2192,7 +2777,7 @@ async function readVersion(executablePath) {
|
|
|
2192
2777
|
}
|
|
2193
2778
|
}
|
|
2194
2779
|
function exec(command, args) {
|
|
2195
|
-
return
|
|
2780
|
+
return execFileAsync3(command, args, { shell: process.platform === "win32" });
|
|
2196
2781
|
}
|
|
2197
2782
|
|
|
2198
2783
|
// src/openspec/runner.ts
|
|
@@ -2238,7 +2823,7 @@ async function runOpenSpec(executablePath, command, args, options) {
|
|
|
2238
2823
|
}
|
|
2239
2824
|
|
|
2240
2825
|
// src/openspec/adapter.ts
|
|
2241
|
-
var
|
|
2826
|
+
var execFileAsync4 = promisify4(execFile4);
|
|
2242
2827
|
var DefaultOpenSpecAdapter = class {
|
|
2243
2828
|
identity;
|
|
2244
2829
|
async resolveExecutable() {
|
|
@@ -2250,7 +2835,7 @@ var DefaultOpenSpecAdapter = class {
|
|
|
2250
2835
|
const executable = identity.executablePath === "npx openspec" ? "npx" : identity.executablePath;
|
|
2251
2836
|
const args = identity.executablePath === "npx openspec" ? ["openspec", "--help"] : ["--help"];
|
|
2252
2837
|
try {
|
|
2253
|
-
const { stdout } = await
|
|
2838
|
+
const { stdout } = await execFileAsync4(executable, args, { shell: process.platform === "win32" });
|
|
2254
2839
|
return {
|
|
2255
2840
|
version: identity.version,
|
|
2256
2841
|
commands: parseCommands(stdout),
|
|
@@ -2294,12 +2879,12 @@ function parseCommands(help) {
|
|
|
2294
2879
|
}
|
|
2295
2880
|
|
|
2296
2881
|
// src/scanner/package.ts
|
|
2297
|
-
import { readFile as readFile14, stat as
|
|
2298
|
-
import { join as
|
|
2882
|
+
import { readFile as readFile14, stat as stat9 } from "fs/promises";
|
|
2883
|
+
import { join as join18 } from "path";
|
|
2299
2884
|
import { parse as parse2 } from "yaml";
|
|
2300
2885
|
async function readPackageJson(projectRoot) {
|
|
2301
2886
|
try {
|
|
2302
|
-
return JSON.parse(await readFile14(
|
|
2887
|
+
return JSON.parse(await readFile14(join18(projectRoot, "package.json"), "utf8"));
|
|
2303
2888
|
} catch {
|
|
2304
2889
|
return null;
|
|
2305
2890
|
}
|
|
@@ -2365,7 +2950,7 @@ function detectFramework(pkg) {
|
|
|
2365
2950
|
}
|
|
2366
2951
|
async function detectLanguage(projectRoot, pkg) {
|
|
2367
2952
|
const deps = { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
|
|
2368
|
-
if (deps.typescript || await exists6(
|
|
2953
|
+
if (deps.typescript || await exists6(join18(projectRoot, "tsconfig.json"))) {
|
|
2369
2954
|
return "typescript";
|
|
2370
2955
|
}
|
|
2371
2956
|
return "javascript";
|
|
@@ -2380,7 +2965,7 @@ async function detectWorkspaces(projectRoot, pkg) {
|
|
|
2380
2965
|
return packageWorkspaces;
|
|
2381
2966
|
}
|
|
2382
2967
|
try {
|
|
2383
|
-
const workspace = parse2(await readFile14(
|
|
2968
|
+
const workspace = parse2(await readFile14(join18(projectRoot, "pnpm-workspace.yaml"), "utf8"));
|
|
2384
2969
|
return (workspace?.packages ?? []).map((path) => ({
|
|
2385
2970
|
name: path,
|
|
2386
2971
|
path,
|
|
@@ -2400,7 +2985,7 @@ async function detectLockManagers(projectRoot) {
|
|
|
2400
2985
|
];
|
|
2401
2986
|
const found = [];
|
|
2402
2987
|
for (const [file, manager] of lockFiles) {
|
|
2403
|
-
if (await exists6(
|
|
2988
|
+
if (await exists6(join18(projectRoot, file))) {
|
|
2404
2989
|
found.push(manager);
|
|
2405
2990
|
}
|
|
2406
2991
|
}
|
|
@@ -2417,7 +3002,7 @@ function scriptCommand(packageManager, name) {
|
|
|
2417
3002
|
}
|
|
2418
3003
|
async function exists6(path) {
|
|
2419
3004
|
try {
|
|
2420
|
-
await
|
|
3005
|
+
await stat9(path);
|
|
2421
3006
|
return true;
|
|
2422
3007
|
} catch {
|
|
2423
3008
|
return false;
|
|
@@ -2425,13 +3010,13 @@ async function exists6(path) {
|
|
|
2425
3010
|
}
|
|
2426
3011
|
|
|
2427
3012
|
// src/scanner/routes.ts
|
|
2428
|
-
import { readdir as readdir2, stat as
|
|
2429
|
-
import { join as
|
|
3013
|
+
import { readdir as readdir2, stat as stat10 } from "fs/promises";
|
|
3014
|
+
import { join as join19, relative, sep } from "path";
|
|
2430
3015
|
async function scanRoutes(projectRoot) {
|
|
2431
3016
|
const candidates = ["src/routes", "src/pages", "app", "pages"];
|
|
2432
3017
|
const routes = [];
|
|
2433
3018
|
for (const candidate of candidates) {
|
|
2434
|
-
const root =
|
|
3019
|
+
const root = join19(projectRoot, candidate);
|
|
2435
3020
|
if (!await exists7(root)) {
|
|
2436
3021
|
continue;
|
|
2437
3022
|
}
|
|
@@ -2459,7 +3044,7 @@ async function listFiles(root) {
|
|
|
2459
3044
|
const entries = await readdir2(root, { withFileTypes: true });
|
|
2460
3045
|
const files = [];
|
|
2461
3046
|
for (const entry of entries) {
|
|
2462
|
-
const path =
|
|
3047
|
+
const path = join19(root, entry.name);
|
|
2463
3048
|
if (entry.isDirectory()) {
|
|
2464
3049
|
files.push(...await listFiles(path));
|
|
2465
3050
|
} else {
|
|
@@ -2470,7 +3055,7 @@ async function listFiles(root) {
|
|
|
2470
3055
|
}
|
|
2471
3056
|
async function exists7(path) {
|
|
2472
3057
|
try {
|
|
2473
|
-
await
|
|
3058
|
+
await stat10(path);
|
|
2474
3059
|
return true;
|
|
2475
3060
|
} catch {
|
|
2476
3061
|
return false;
|
|
@@ -2523,6 +3108,11 @@ var OutputWriter = class {
|
|
|
2523
3108
|
}
|
|
2524
3109
|
}
|
|
2525
3110
|
warn(message, details) {
|
|
3111
|
+
if (this.json) {
|
|
3112
|
+
process.stderr.write(`${JSON.stringify({ ok: true, warning: message, details }, null, 2)}
|
|
3113
|
+
`);
|
|
3114
|
+
return;
|
|
3115
|
+
}
|
|
2526
3116
|
if (!this.json) {
|
|
2527
3117
|
process.stderr.write(`\u8B66\u544A\uFF1A${message}${formatDetails(details)}
|
|
2528
3118
|
`);
|
|
@@ -2612,6 +3202,13 @@ program.name("fet").description("Frontend workflow orchestration tool built arou
|
|
|
2612
3202
|
addGlobalOptions(program.command("init")).description("\u521D\u59CB\u5316 FET + OpenSpec").action(wrap("init", initCommand));
|
|
2613
3203
|
addGlobalOptions(program.command("update-context")).description("\u66F4\u65B0\u9879\u76EE\u4E0A\u4E0B\u6587").action(wrap("update-context", updateContextCommand));
|
|
2614
3204
|
addGlobalOptions(program.command("fill-context")).description("Refresh IDE prompts for filling AGENTS.md placeholders").action(wrap("fill-context", fillContextCommand));
|
|
3205
|
+
var graph = addGlobalOptions(program.command("graph").description("Manage optional GitNexus code graph support"));
|
|
3206
|
+
for (const action of ["status", "setup", "doctor", "handoff"]) {
|
|
3207
|
+
addGlobalOptions(graph.command(action).description(`Run fet graph ${action}`)).action(wrap("graph", (ctx) => graphCommand(ctx, action)));
|
|
3208
|
+
}
|
|
3209
|
+
for (const action of ["init", "refresh"]) {
|
|
3210
|
+
addGlobalOptions(graph.command(`${action} [args...]`).description(`Run GitNexus analyze for graph ${action}`).allowUnknownOption(true).passThroughOptions()).action(wrap("graph", (ctx, args = []) => graphCommand(ctx, action, args)));
|
|
3211
|
+
}
|
|
2615
3212
|
addGlobalOptions(program.command("doctor").description("\u8BCA\u65AD\u72B6\u6001\u3001\u914D\u7F6E\u4E0E\u4E00\u81F4\u6027").option("--fix-lock", "\u6E05\u7406 FET \u9501\u6587\u4EF6")).action(
|
|
2616
3213
|
wrap("doctor", (ctx, options) => doctorCommand(ctx, { fixLock: Boolean(options.fixLock) }))
|
|
2617
3214
|
);
|
|
@@ -2633,6 +3230,8 @@ function wrap(command, handler) {
|
|
|
2633
3230
|
const opts = isCommandLike(maybeCommand) ? { ...maybeCommand.parent?.opts(), ...maybeCommand.opts() } : program.opts();
|
|
2634
3231
|
const ctx = await createCommandContext(command, { ...opts, ...extractGlobalOptions(args) });
|
|
2635
3232
|
try {
|
|
3233
|
+
await confirmModelPolicyRecommendation(ctx);
|
|
3234
|
+
await warnIfContextPlaceholdersRemain(ctx);
|
|
2636
3235
|
await handler(ctx, ...args);
|
|
2637
3236
|
} catch (error) {
|
|
2638
3237
|
const fetError = toFetError(error);
|
|
@@ -2641,6 +3240,40 @@ function wrap(command, handler) {
|
|
|
2641
3240
|
}
|
|
2642
3241
|
};
|
|
2643
3242
|
}
|
|
3243
|
+
async function confirmModelPolicyRecommendation(ctx) {
|
|
3244
|
+
const mismatch = getCommandModelPolicyMismatch(ctx.command);
|
|
3245
|
+
if (!mismatch) {
|
|
3246
|
+
return;
|
|
3247
|
+
}
|
|
3248
|
+
const warning = formatModelPolicyMismatch(mismatch);
|
|
3249
|
+
ctx.output.warn(`${warning} You can stop now to switch models, or continue this command.`);
|
|
3250
|
+
if (ctx.yes || ctx.json || !process.stdin.isTTY || !process.stderr.isTTY) {
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
3253
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
3254
|
+
try {
|
|
3255
|
+
const answer = (await rl.question("Continue anyway? [y/N] ")).trim().toLowerCase();
|
|
3256
|
+
if (answer !== "y" && answer !== "yes") {
|
|
3257
|
+
throw new FetError({
|
|
3258
|
+
code: "USER_CANCELLED" /* UserCancelled */,
|
|
3259
|
+
message: "Command cancelled so you can switch IDE model.",
|
|
3260
|
+
details: { command: ctx.command, detected: mismatch.detected, recommended: mismatch.recommended },
|
|
3261
|
+
suggestedCommand: `Switch IDE model, then rerun fet ${ctx.command}.`
|
|
3262
|
+
});
|
|
3263
|
+
}
|
|
3264
|
+
} finally {
|
|
3265
|
+
rl.close();
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
async function warnIfContextPlaceholdersRemain(ctx) {
|
|
3269
|
+
if (["init", "update-context", "fill-context", "doctor"].includes(ctx.command)) {
|
|
3270
|
+
return;
|
|
3271
|
+
}
|
|
3272
|
+
const count2 = await countAgentsLlmPlaceholders(ctx.projectRoot);
|
|
3273
|
+
if (count2 > 0) {
|
|
3274
|
+
ctx.output.warn(renderAgentsPlaceholderWarning(count2));
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
2644
3277
|
function isCommandLike(value) {
|
|
2645
3278
|
return value instanceof Command;
|
|
2646
3279
|
}
|