sillyspec 3.26.11 → 3.26.13
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/.claude/skills/sillyspec-archive/SKILL.md +1 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
- package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
- package/.claude/skills/sillyspec-plan/SKILL.md +5 -5
- package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
- package/README.md +7 -5
- package/SKILL.md +5 -4
- package/package.json +1 -1
- package/packages/dashboard/server/executor.js +10 -1
- package/packages/dashboard/server/index.js +108 -46
- package/src/config-schema.js +3 -0
- package/src/contract-matrix.js +22 -13
- package/src/db.js +24 -4
- package/src/dispatch/probe.js +99 -63
- package/src/docs-check.js +58 -13
- package/src/docs-debt.js +6 -2
- package/src/docs-gate.js +1 -0
- package/src/doctor-diagnostics.js +208 -21
- package/src/fs-atomic.js +3 -1
- package/src/git-helper.js +7 -4
- package/src/hooks/worktree-guard.js +8 -2
- package/src/index.js +161 -12
- package/src/init.js +3 -2
- package/src/local-detect.js +9 -1
- package/src/modules.js +7 -5
- package/src/progress/stage-machine.js +589 -578
- package/src/progress/step-store.js +1 -1
- package/src/progress.js +184 -30
- package/src/quicklog.js +47 -16
- package/src/run/command.js +31 -8
- package/src/run/complete-handlers.js +21 -14
- package/src/run/complete.js +1181 -1121
- package/src/run/gates.js +148 -25
- package/src/run/prompt.js +9 -5
- package/src/run/shared.js +138 -58
- package/src/run/stage.js +41 -1
- package/src/setup.js +8 -10
- package/src/sillyhub-mcp/client.js +74 -6
- package/src/spec-dir-typo.js +3 -24
- package/src/spec-sync.js +43 -8
- package/src/stage-contract.js +12 -38
- package/src/stages/archive.js +4 -3
- package/src/stages/brainstorm-auto.js +1 -1
- package/src/stages/brainstorm.js +2 -1
- package/src/stages/doctor.js +28 -13
- package/src/stages/execute.js +164 -153
- package/src/stages/plan-postcheck.js +49 -28
- package/src/stages/plan.js +57 -40
- package/src/stages/quick.js +3 -1
- package/src/stages/verify.js +1 -1
- package/src/sync.js +323 -221
- package/src/task-review.js +246 -61
- package/src/taskcard.js +178 -0
- package/src/verify-postcheck.js +48 -35
- package/src/worktree-apply.js +58 -34
- package/src/worktree-deps.js +120 -20
- package/src/worktree.js +55 -9
- package/.npmrc-publish +0 -2
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* writer 落盘到 <authoritySpecDir>/.runtime/。
|
|
22
22
|
*/
|
|
23
23
|
import { openDatabase, pluckGet, pluckAll } from './db-engine.js';
|
|
24
|
-
import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
|
|
24
|
+
import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync, rmSync } from 'fs';
|
|
25
25
|
import { join } from 'path';
|
|
26
26
|
import { CHECK_SEVERITY } from './constants.js';
|
|
27
27
|
import { checkPlatformManaged } from './run/shared.js';
|
|
@@ -101,6 +101,14 @@ function probeDb(dbPath) {
|
|
|
101
101
|
schema_version: pick('SELECT schema_version FROM project LIMIT 1'),
|
|
102
102
|
change_count: pick('SELECT count(*) FROM changes', 0),
|
|
103
103
|
active_changes: pickCol("SELECT name FROM changes WHERE status='active'"),
|
|
104
|
+
// SS-2b(2026-08-20):active 行 name → last_active 映射,供 D4 空壳判定做时间门槛
|
|
105
|
+
//(刚创建的合法变更目录本来就是空的,见 GHOST_EMPTY_DIR_STALE_MS 注释)。
|
|
106
|
+
active_last_active: (() => {
|
|
107
|
+
try {
|
|
108
|
+
const rows = db.prepare("SELECT name, last_active FROM changes WHERE status='active'").all();
|
|
109
|
+
return Object.fromEntries(rows.map((r) => [r.name, r.last_active]));
|
|
110
|
+
} catch { return {}; }
|
|
111
|
+
})(),
|
|
104
112
|
last_active: pick('SELECT MAX(last_active) FROM changes'),
|
|
105
113
|
execute_status_by_change: pickExecuteStatusByChange(),
|
|
106
114
|
};
|
|
@@ -211,6 +219,10 @@ function detectMultiDb(cwd, pointer) {
|
|
|
211
219
|
change_count: d.change_count,
|
|
212
220
|
last_active: d.last_active,
|
|
213
221
|
active: d.readable ? summarizeActive(d.active_changes) : null,
|
|
222
|
+
// SS-2:全量名单随摘要透传,供 D4 精确集合对账(ghost/orphan)消费。
|
|
223
|
+
active_changes: d.readable ? (d.active_changes || []) : [],
|
|
224
|
+
// SS-2b:name → last_active 映射随摘要透传,供 D4 空壳判定(时间门槛)消费。
|
|
225
|
+
active_last_active: d.readable ? (d.active_last_active || {}) : {},
|
|
214
226
|
})),
|
|
215
227
|
};
|
|
216
228
|
}
|
|
@@ -359,6 +371,35 @@ function detectChangesSplit(cwd, pointer) {
|
|
|
359
371
|
|
|
360
372
|
// ── D4 change↔db 一致性(针对权威 db + 权威 changes 目录)──────────────
|
|
361
373
|
|
|
374
|
+
// SS-2b(2026-08-20):空壳目录判定的 last_active 时间门槛(7 天)。
|
|
375
|
+
// 背景:brainstorm 刚注册的合法变更目录本来就是 0 文件(首份产物 proposal.md
|
|
376
|
+
// 落盘前有空窗,2026-08-20 task-truth-unify 即空目录数小时)。「active 行 + 目录
|
|
377
|
+
// 0 文件」不能直接判幽灵,必须叠加 last_active 陈旧度;门槛取 7 天(quick 会话
|
|
378
|
+
// 生命周期通常当天结束,正规变更 brainstorm 首产物也远早于 7 天)。
|
|
379
|
+
const GHOST_EMPTY_DIR_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
380
|
+
|
|
381
|
+
/** 目录内是否 0 个常规文件(递归;空目录/仅空子目录都算 0 文件)。读失败按非空处理(宁漏勿杀)。 */
|
|
382
|
+
function dirHasNoFiles(dirPath) {
|
|
383
|
+
const stack = [dirPath];
|
|
384
|
+
while (stack.length > 0) {
|
|
385
|
+
const cur = stack.pop();
|
|
386
|
+
let entries;
|
|
387
|
+
try { entries = readdirSync(cur, { withFileTypes: true }); } catch { return false; }
|
|
388
|
+
for (const e of entries) {
|
|
389
|
+
if (e.isFile()) return false;
|
|
390
|
+
if (e.isDirectory()) stack.push(join(cur, e.name));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** last_active 是否陈旧超过 GHOST_EMPTY_DIR_STALE_MS。无值/不可解析按非陈旧处理(宁漏勿杀)。 */
|
|
397
|
+
function isStaleLastActive(lastActive, now = Date.now()) {
|
|
398
|
+
if (!lastActive) return false;
|
|
399
|
+
const ts = Date.parse(lastActive);
|
|
400
|
+
return Number.isFinite(ts) && (now - ts) > GHOST_EMPTY_DIR_STALE_MS;
|
|
401
|
+
}
|
|
402
|
+
|
|
362
403
|
function detectChangeDbConsistency(cwd, pointer, multiDb) {
|
|
363
404
|
const auth = (multiDb.dbs || []).find((d) => d.role === DB_ROLE.AUTHORITY);
|
|
364
405
|
if (!auth) {
|
|
@@ -373,25 +414,52 @@ function detectChangeDbConsistency(cwd, pointer, multiDb) {
|
|
|
373
414
|
}
|
|
374
415
|
const authoritySpecRoot = pointer.present && pointer.specRoot ? pointer.specRoot : join(cwd, '.sillyspec');
|
|
375
416
|
const dirChanges = listChanges(authoritySpecRoot);
|
|
376
|
-
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
417
|
+
// SS-2(2026-08-20):从 count 粗对齐升级为精确集合对账——ghost_rows(db active
|
|
418
|
+
// 无目录)与 orphan_dirs(目录无 db active 行)逐名列出,供 doctor --cleanup-ghosts
|
|
419
|
+
// 消费,不再让 52 条残留只停留在「需 dump 后细对齐」。
|
|
420
|
+
const activeNames = (auth.active_changes && Array.isArray(auth.active_changes))
|
|
421
|
+
? auth.active_changes
|
|
422
|
+
: [];
|
|
423
|
+
const dirSet = new Set(dirChanges);
|
|
424
|
+
const activeSet = new Set(activeNames);
|
|
425
|
+
const ghostRows = activeNames.filter((n) => !dirSet.has(n));
|
|
426
|
+
const orphanDirs = dirChanges.filter((n) => !activeSet.has(n));
|
|
427
|
+
// SS-2b:空壳——db active + 目录存在但 0 文件 + last_active 超过 7 天。有目录所以
|
|
428
|
+
// 逃过 ghostRows 的「无目录」判定(2026-08-15 清理时 6 个空壳即此漏网形态)。
|
|
429
|
+
const activeLastActive = (auth.active_last_active && typeof auth.active_last_active === 'object')
|
|
430
|
+
? auth.active_last_active
|
|
431
|
+
: {};
|
|
432
|
+
const emptyShells = activeNames.filter((n) =>
|
|
433
|
+
dirSet.has(n) && isStaleLastActive(activeLastActive[n]) && dirHasNoFiles(join(authoritySpecRoot, 'changes', n)));
|
|
382
434
|
const findings = [];
|
|
383
435
|
let pass = true;
|
|
384
436
|
let severity = null;
|
|
385
|
-
|
|
386
|
-
if (Math.abs(dirCount - dbCount) > Math.max(2, dirCount * 0.2)) {
|
|
437
|
+
if (ghostRows.length > 0) {
|
|
387
438
|
pass = false;
|
|
388
439
|
severity = CHECK_SEVERITY.WARNING;
|
|
389
440
|
findings.push(
|
|
390
|
-
|
|
391
|
-
|
|
441
|
+
`幽灵记录 ${ghostRows.length} 条(db active 但无目录):${ghostRows.slice(0, 10).join(', ')}${ghostRows.length > 10 ? ' …' : ''}——` +
|
|
442
|
+
`可用 sillyspec doctor --cleanup-ghosts --confirm 归档清理`
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
if (emptyShells.length > 0) {
|
|
446
|
+
pass = false;
|
|
447
|
+
severity = severity || CHECK_SEVERITY.WARNING;
|
|
448
|
+
findings.push(
|
|
449
|
+
`空壳目录 ${emptyShells.length} 个(db active + 目录 0 文件 + last_active 超 7 天):${emptyShells.slice(0, 10).join(', ')}${emptyShells.length > 10 ? ' …' : ''}——` +
|
|
450
|
+
`可用 sillyspec doctor --cleanup-ghosts --confirm 归档行并移除空目录`
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (orphanDirs.length > 0) {
|
|
454
|
+
pass = false;
|
|
455
|
+
severity = severity || CHECK_SEVERITY.WARNING;
|
|
456
|
+
findings.push(
|
|
457
|
+
`孤儿目录 ${orphanDirs.length} 个(目录存在但无 db active 行):${orphanDirs.slice(0, 10).join(', ')}${orphanDirs.length > 10 ? ' …' : ''}——` +
|
|
458
|
+
`先 dump 确认再手工归位,doctor 不自动删目录`
|
|
392
459
|
);
|
|
393
|
-
}
|
|
394
|
-
|
|
460
|
+
}
|
|
461
|
+
if (pass) {
|
|
462
|
+
findings.push(`权威 db active changes (${activeNames.length}) 与 changes/ 目录数 (${dirChanges.length}) 完全一致`);
|
|
395
463
|
}
|
|
396
464
|
return {
|
|
397
465
|
name: 'change_db_consistency',
|
|
@@ -399,10 +467,15 @@ function detectChangeDbConsistency(cwd, pointer, multiDb) {
|
|
|
399
467
|
pass,
|
|
400
468
|
severity,
|
|
401
469
|
findings,
|
|
402
|
-
safe_actions:
|
|
470
|
+
safe_actions: ghostRows.length > 0 || emptyShells.length > 0
|
|
471
|
+
? ['sillyspec doctor --cleanup-ghosts(dry-run)→ 加 --confirm 归档幽灵行/空壳目录']
|
|
472
|
+
: [],
|
|
403
473
|
authority_spec_root: authoritySpecRoot,
|
|
404
|
-
dir_count:
|
|
405
|
-
db_active_count:
|
|
474
|
+
dir_count: dirChanges.length,
|
|
475
|
+
db_active_count: activeNames.length,
|
|
476
|
+
ghost_rows: ghostRows,
|
|
477
|
+
orphan_dirs: orphanDirs,
|
|
478
|
+
empty_shells: emptyShells,
|
|
406
479
|
};
|
|
407
480
|
}
|
|
408
481
|
|
|
@@ -416,15 +489,17 @@ function detectChangeDbConsistency(cwd, pointer, multiDb) {
|
|
|
416
489
|
* @returns {{total:number, checked:number} | null} null = plan/tasks.md 都不存在或无 task 行
|
|
417
490
|
*/
|
|
418
491
|
function readPlanCheckboxStatus(changeDir) {
|
|
419
|
-
|
|
492
|
+
// 2026-08-20-task-truth-unify:tasks.md(任务注册表)优先;plan.md 纯 ID 引用行无 checkbox,
|
|
493
|
+
// 旧「plan 优先」顺序对新契约变更恒 null(D5 诊断静默致盲)。tasks.md 缺失回退 plan.md(旧变更兼容)。
|
|
420
494
|
const tasksPath = join(changeDir, 'tasks.md');
|
|
495
|
+
const planPath = join(changeDir, 'plan.md');
|
|
421
496
|
let content = null;
|
|
422
|
-
if (existsSync(
|
|
423
|
-
try { content = readFileSync(planPath, 'utf8'); } catch { content = null; }
|
|
424
|
-
}
|
|
425
|
-
if (content == null && existsSync(tasksPath)) {
|
|
497
|
+
if (existsSync(tasksPath)) {
|
|
426
498
|
try { content = readFileSync(tasksPath, 'utf8'); } catch { content = null; }
|
|
427
499
|
}
|
|
500
|
+
if (content == null && existsSync(planPath)) {
|
|
501
|
+
try { content = readFileSync(planPath, 'utf8'); } catch { content = null; }
|
|
502
|
+
}
|
|
428
503
|
if (content == null) return null;
|
|
429
504
|
// match both "- [ ] task-01: title" and "- [x] task-01: title"
|
|
430
505
|
const taskLine = /(?:^- \[[ x]\] )task-\d+[^:]*:?\s*.+$/gm;
|
|
@@ -709,3 +784,115 @@ function writeDump(result, authoritySpecDir) {
|
|
|
709
784
|
return { ...result, dump_write_error: e.message };
|
|
710
785
|
}
|
|
711
786
|
}
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* SS-2(2026-08-20):归档幽灵行——db active 但 changes/ 无同名目录的记录。
|
|
791
|
+
* SS-2b(2026-08-20):扩空壳目录——db active + 目录存在但 0 文件 + last_active 超
|
|
792
|
+
* 7 天(GHOST_EMPTY_DIR_STALE_MS)。两者都是 quick 收尾注销缺陷(工具侧已修)在
|
|
793
|
+
* 历史会话中累积的僵尸 active 行,污染 listChanges/progress show 的「活跃变更」
|
|
794
|
+
* 列表;旧 doctor 只 WARNING 不给动作(stage-machine 提示「可用 doctor 清理」却清不掉)。
|
|
795
|
+
*
|
|
796
|
+
* 安全设计:
|
|
797
|
+
* - 仅把 changes.status 从 active 改为 archived(对齐 change-registry 归档语义,
|
|
798
|
+
* 状态可逆:手工改回 status='active' 即恢复,不删任何行)。
|
|
799
|
+
* - 默认 dry-run 只列名单;加 confirm 才写。
|
|
800
|
+
* - 空壳目录归档时同时移除空目录(删前复查仍为 0 文件,复查非空整条放过);
|
|
801
|
+
* 有内容的目录一律不动。孤儿目录(有目录无行)不自动删,留给人工归位。
|
|
802
|
+
*/
|
|
803
|
+
export async function cleanupGhostChanges({ cwd, specDir = null, confirm = false }) {
|
|
804
|
+
const pointer = resolvePointer(cwd);
|
|
805
|
+
const localSpec = join(cwd, '.sillyspec');
|
|
806
|
+
const candidates = [
|
|
807
|
+
join(localSpec, '.runtime', 'sillyspec.db'),
|
|
808
|
+
join(localSpec, 'sillyspec.db'),
|
|
809
|
+
];
|
|
810
|
+
if (pointer.specRoot) {
|
|
811
|
+
candidates.unshift(
|
|
812
|
+
join(pointer.specRoot, '.runtime', 'sillyspec.db'),
|
|
813
|
+
join(pointer.specRoot, 'sillyspec.db'),
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
let dbPath = null;
|
|
817
|
+
for (const p of candidates) {
|
|
818
|
+
try {
|
|
819
|
+
if (existsSync(p) && statSync(p).size > 0) { dbPath = p; break; }
|
|
820
|
+
} catch { /* stat 失败跳过 */ }
|
|
821
|
+
}
|
|
822
|
+
if (!dbPath) {
|
|
823
|
+
return { action: 'skipped', reason: '未找到非空 sillyspec.db', ghosts: [], archived: [], errors: [], count: 0 };
|
|
824
|
+
}
|
|
825
|
+
const authoritySpecRoot = (pointer.present && pointer.specRoot && existsSync(pointer.specRoot))
|
|
826
|
+
? pointer.specRoot
|
|
827
|
+
: localSpec;
|
|
828
|
+
const changesDir = join(authoritySpecRoot, 'changes');
|
|
829
|
+
let dirNames = [];
|
|
830
|
+
try {
|
|
831
|
+
dirNames = readdirSync(changesDir, { withFileTypes: true })
|
|
832
|
+
.filter((e) => e.isDirectory() && e.name !== 'archive')
|
|
833
|
+
.map((e) => e.name);
|
|
834
|
+
} catch { /* 目录缺失 → 全部 active 都是幽灵 */ }
|
|
835
|
+
const dirSet = new Set(dirNames);
|
|
836
|
+
|
|
837
|
+
let db = null;
|
|
838
|
+
const archived = [];
|
|
839
|
+
const errors = [];
|
|
840
|
+
const skippedNonEmpty = [];
|
|
841
|
+
const removedDirs = [];
|
|
842
|
+
try {
|
|
843
|
+
db = openDatabase(dbPath, { readOnly: !confirm });
|
|
844
|
+
// last_active 与 name 一起取:空壳判定需要时间门槛(见 isStaleLastActive)。
|
|
845
|
+
const activeRows = db.prepare("SELECT name, last_active FROM changes WHERE status='active'").all();
|
|
846
|
+
const ghosts = activeRows.filter((r) => !dirSet.has(r.name)).map((r) => r.name);
|
|
847
|
+
const emptyShells = activeRows
|
|
848
|
+
.filter((r) => dirSet.has(r.name) && isStaleLastActive(r.last_active) && dirHasNoFiles(join(changesDir, r.name)))
|
|
849
|
+
.map((r) => r.name);
|
|
850
|
+
if (confirm) {
|
|
851
|
+
const now = new Date().toISOString();
|
|
852
|
+
const stmt = db.prepare("UPDATE changes SET status = 'archived', last_active = ? WHERE name = ? AND status = 'active'");
|
|
853
|
+
for (const n of ghosts) {
|
|
854
|
+
try {
|
|
855
|
+
stmt.run(now, n);
|
|
856
|
+
archived.push(n);
|
|
857
|
+
} catch (e) {
|
|
858
|
+
errors.push({ name: n, error: e.message });
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
for (const n of emptyShells) {
|
|
862
|
+
// 删目录前复查非空:dry-run 与 --confirm 之间并发会话可能已往空目录写入首份产物
|
|
863
|
+
//(如 brainstorm 落 proposal.md)。复查非空 → 整条放过(行也不归档),宁漏勿杀。
|
|
864
|
+
const dirPath = join(changesDir, n);
|
|
865
|
+
if (!dirHasNoFiles(dirPath)) {
|
|
866
|
+
skippedNonEmpty.push(n);
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
try {
|
|
870
|
+
stmt.run(now, n);
|
|
871
|
+
rmSync(dirPath, { recursive: true, force: true });
|
|
872
|
+
removedDirs.push(n);
|
|
873
|
+
archived.push(n);
|
|
874
|
+
} catch (e) {
|
|
875
|
+
errors.push({ name: n, error: e.message });
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
const activeAfter = confirm ? pluckAll(db, "SELECT name FROM changes WHERE status='active'") : null;
|
|
880
|
+
return {
|
|
881
|
+
action: confirm ? 'archived' : 'dry_run',
|
|
882
|
+
db_path: dbPath,
|
|
883
|
+
changes_dir: changesDir,
|
|
884
|
+
ghosts: confirm ? archived : ghosts,
|
|
885
|
+
empty_shells: confirm ? removedDirs : emptyShells,
|
|
886
|
+
skipped_nonempty: skippedNonEmpty,
|
|
887
|
+
removed_dirs: removedDirs,
|
|
888
|
+
archived,
|
|
889
|
+
errors,
|
|
890
|
+
count: ghosts.length + emptyShells.length,
|
|
891
|
+
active_after: activeAfter,
|
|
892
|
+
};
|
|
893
|
+
} catch (e) {
|
|
894
|
+
return { action: 'error', reason: e.message, ghosts: [], archived: [], errors: [{ error: e.message }], count: 0 };
|
|
895
|
+
} finally {
|
|
896
|
+
if (db) { try { db.close(); } catch { /* 忽略关闭失败 */ } }
|
|
897
|
+
}
|
|
898
|
+
}
|
package/src/fs-atomic.js
CHANGED
|
@@ -58,7 +58,9 @@ export function renameSyncRetry(from, to, retries = 5) {
|
|
|
58
58
|
*/
|
|
59
59
|
export function writeAtomicSync(filePath, content) {
|
|
60
60
|
const dir = dirname(filePath);
|
|
61
|
-
|
|
61
|
+
// pid + 随机段双因子:Windows PID 重用激进,两进程可能撞同 pid,tmp 名碰撞 rename 互相覆盖
|
|
62
|
+
const rnd = Math.random().toString(36).slice(2, 10);
|
|
63
|
+
const tmpPath = join(dir, `.${basename(filePath)}.${process.pid}.${rnd}.tmp`);
|
|
62
64
|
writeFileSync(tmpPath, content);
|
|
63
65
|
try {
|
|
64
66
|
renameSyncRetry(tmpPath, filePath);
|
package/src/git-helper.js
CHANGED
|
@@ -53,13 +53,16 @@ export function safeGit(cwd, args, opts = {}) {
|
|
|
53
53
|
* 调用方自行 catch;不经 shell,参数以数组元素传递。
|
|
54
54
|
* @param {string} cwd
|
|
55
55
|
* @param {string[]} args
|
|
56
|
-
* @param {{ trim?: boolean, timeout?: number }} [opts]
|
|
57
|
-
*
|
|
56
|
+
* @param {{ trim?: boolean, timeout?: number, encoding?: string }} [opts]
|
|
57
|
+
* - encoding:'buffer' 返回原始 Buffer(git diff --binary 等**二进制输出**专用——
|
|
58
|
+
* utf8 解码会破坏 NUL 字节使补丁不可应用;Buffer 输出跳过 trim)
|
|
59
|
+
* @returns {string|Buffer}
|
|
58
60
|
*/
|
|
59
61
|
export function git(cwd, args, opts = {}) {
|
|
60
|
-
const { trim = true, timeout = 5000 } = opts
|
|
62
|
+
const { trim = true, timeout = 5000, encoding = 'utf8' } = opts
|
|
61
63
|
const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
|
|
62
|
-
const value = execFileSync('git', fullArgs, { encoding
|
|
64
|
+
const value = execFileSync('git', fullArgs, { encoding, timeout, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
65
|
+
if (Buffer.isBuffer(value)) return value
|
|
63
66
|
return trim ? value.trim() : value
|
|
64
67
|
}
|
|
65
68
|
|
|
@@ -121,8 +121,14 @@ function readWorktreeMeta(cwd, changeName) {
|
|
|
121
121
|
|
|
122
122
|
function isPathInside(child, parent) {
|
|
123
123
|
if (!child || !parent) return false
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
let absChild = path.resolve(child)
|
|
125
|
+
let absParent = path.resolve(parent)
|
|
126
|
+
// Windows 文件系统大小写不敏感:git worktree list / 编辑器给出的路径大小写可能与
|
|
127
|
+
// 注册的 meta.worktreePath 不一致,敏感比较会把 worktree 内合法写入误判为越权(体检 BUG-09)
|
|
128
|
+
if (process.platform === 'win32') {
|
|
129
|
+
absChild = absChild.toLowerCase()
|
|
130
|
+
absParent = absParent.toLowerCase()
|
|
131
|
+
}
|
|
126
132
|
return absChild === absParent || absChild.startsWith(absParent + path.sep)
|
|
127
133
|
}
|
|
128
134
|
|
package/src/index.js
CHANGED
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
|
|
10
10
|
import { writeAtomicSync } from './fs-atomic.js';
|
|
11
11
|
import { basename, join, resolve } from 'path';
|
|
12
|
-
import {
|
|
13
|
-
import { git } from './git-helper.js';
|
|
12
|
+
import { safeGit, git } from './git-helper.js';
|
|
14
13
|
import { getVersion } from './version.js';
|
|
15
14
|
|
|
16
15
|
// E22(性能#2):progress.js(拖 db.js→node:sqlite 链 ~48ms)与 run/shared.js(拖 stages 全家
|
|
@@ -78,6 +77,7 @@ SillySpec CLI — 规范驱动开发工具包
|
|
|
78
77
|
list | meta <change> 列出 / 读取 meta.json
|
|
79
78
|
cleanup <change> [--force] 清理 worktree
|
|
80
79
|
doctor [--fix] [--stale-hours N] 健康检查 + 修复
|
|
80
|
+
--cleanup-ghosts [--confirm] 归档幽灵 active 记录(db 有行无目录 / 空壳目录 0 文件超 7 天,SS-2/2b)
|
|
81
81
|
|
|
82
82
|
sillyspec local detect [--dir <path>] 生成本地配置 local.yaml(纯 fs 嗅探,零 token、不跑 scan)
|
|
83
83
|
sillyspec config [schema] [--json] 打印 local.yaml 全部已知键 + 生效状态 + 读取点(堵外部 agent 配置发现缺口)
|
|
@@ -100,6 +100,9 @@ SillySpec CLI — 规范驱动开发工具包
|
|
|
100
100
|
--dump-db --path <db 路径> dump 指定 db 内容到文件(取证用)
|
|
101
101
|
--json 结构化诊断 + 落盘 .sillyspec/.runtime/doctor-diagnosis.json
|
|
102
102
|
sillyspec modules <rebuild | status | migrate>
|
|
103
|
+
sillyspec taskcard <change> --task task-01[,task-02...] | --all [--force]
|
|
104
|
+
生成 Windows 安全 TaskCard 骨架(LF 行尾 + frontmatter 闭合 +
|
|
105
|
+
硬校验 9 字段齐全,标题自动取自 plan.md,Edit 填占位符即可)
|
|
103
106
|
sillyspec change-rename <旧变更名> <新变更名>
|
|
104
107
|
sillyspec knowledge <search --query "..." --limit N
|
|
105
108
|
| inspect --id "..."
|
|
@@ -242,12 +245,9 @@ async function main() {
|
|
|
242
245
|
// 导致 sillyspec 命令找不到 .sillyspec。此函数尝试从 git root 解析。
|
|
243
246
|
function resolveEffectiveDir(baseDir) {
|
|
244
247
|
if (existsSync(join(baseDir, '.sillyspec'))) return baseDir
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}).trim()
|
|
249
|
-
if (gitRoot && existsSync(join(gitRoot, '.sillyspec'))) return gitRoot
|
|
250
|
-
} catch {}
|
|
248
|
+
// QUAL-01 收口:原 execSync 字符串拼接(经 shell)→ git-helper 数组形式
|
|
249
|
+
const r = safeGit(baseDir, ['rev-parse', '--show-toplevel'])
|
|
250
|
+
if (r.value && existsSync(join(r.value, '.sillyspec'))) return r.value
|
|
251
251
|
return baseDir
|
|
252
252
|
}
|
|
253
253
|
|
|
@@ -373,8 +373,34 @@ async function main() {
|
|
|
373
373
|
}
|
|
374
374
|
break;
|
|
375
375
|
}
|
|
376
|
+
case 'dump': {
|
|
377
|
+
// 只读进度导出(design §6.2):daemon 消费的机器接口 JSON。
|
|
378
|
+
// --json 模式下劫持 console.log 到 stderr,stdout 留给纯 JSON(与 gate/derive 同纪律)。
|
|
379
|
+
const dumpData = pm.dump(progDir);
|
|
380
|
+
if (json) {
|
|
381
|
+
const { buildEnvelope } = await import('./machine-interface.js');
|
|
382
|
+
const envelope = buildEnvelope({
|
|
383
|
+
command: 'progress dump',
|
|
384
|
+
ok: dumpData !== null,
|
|
385
|
+
data: dumpData,
|
|
386
|
+
errors: dumpData === null ? ['无活跃变更或进度数据不存在'] : [],
|
|
387
|
+
});
|
|
388
|
+
console.log(JSON.stringify(envelope));
|
|
389
|
+
} else {
|
|
390
|
+
if (!dumpData) {
|
|
391
|
+
console.log('📭 无活跃变更或进度数据不存在');
|
|
392
|
+
} else {
|
|
393
|
+
console.log(`项目: ${dumpData.project}`);
|
|
394
|
+
console.log(`当前变更: ${dumpData.current_change || '(无)'}`);
|
|
395
|
+
console.log(`当前阶段: ${dumpData.current_stage || '(无)'}`);
|
|
396
|
+
console.log(`最后活跃: ${dumpData.last_active || '(无)'}`);
|
|
397
|
+
console.log(`产物数: ${dumpData.artifacts?.length || 0}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
376
402
|
default:
|
|
377
|
-
console.log('用法: sillyspec progress <init|show|validate|reset|set-stage|add-step|update-step|complete-stage>');
|
|
403
|
+
console.log('用法: sillyspec progress <init|show|validate|reset|set-stage|add-step|update-step|complete-stage|dump>');
|
|
378
404
|
process.exit(2);
|
|
379
405
|
}
|
|
380
406
|
break;
|
|
@@ -627,6 +653,7 @@ async function main() {
|
|
|
627
653
|
paths: cliPaths || cfg.paths,
|
|
628
654
|
skip: cfg.skip,
|
|
629
655
|
keywordAssert: cfg.keywordAssert,
|
|
656
|
+
crossRepoRoots: cfg.crossRepoRoots,
|
|
630
657
|
});
|
|
631
658
|
// task-03 修复链路(FR-04,platform-map-auto-anchors):fixActive 才构造 fixes——
|
|
632
659
|
// 仅 fix.fixable===true 且 newLine 为整数的条目可重锚;newRef = ref 的行号部分替换为
|
|
@@ -654,6 +681,9 @@ async function main() {
|
|
|
654
681
|
console.log(JSON.stringify(result, null, 2));
|
|
655
682
|
} else {
|
|
656
683
|
for (const w of result.warnings) console.warn(`⚠️ ${w}`);
|
|
684
|
+
if (result.crossRepoSkipped > 0) {
|
|
685
|
+
console.log(`ℹ️ 跨仓 repo:// 引用跳过 ${result.crossRepoSkipped} 处(未配 docs-check.cross_repo_roots 映射;配置后走同款行号+关键词校验)`);
|
|
686
|
+
}
|
|
657
687
|
if (result.ok) {
|
|
658
688
|
console.log(`✅ docs check: ${result.total} 处引用全通过(其中 ${result.kwChecked} 处带关键词断言)`);
|
|
659
689
|
} else {
|
|
@@ -793,8 +823,9 @@ async function main() {
|
|
|
793
823
|
// slice(1) 去掉的是 'run' 字面量,这里 command 本身就是 stage 名不能丢。
|
|
794
824
|
case 'doctor': {
|
|
795
825
|
const doctorEffectiveDir = specDir ? dir : resolveEffectiveDir(dir);
|
|
796
|
-
// 执行流:--cleanup-remnant / --dump-db(结构化诊断之外的修复/取证动作)
|
|
826
|
+
// 执行流:--cleanup-remnant / --cleanup-ghosts / --dump-db(结构化诊断之外的修复/取证动作)
|
|
797
827
|
const cleanupRemnant = filteredArgs.includes('--cleanup-remnant');
|
|
828
|
+
const cleanupGhosts = filteredArgs.includes('--cleanup-ghosts');
|
|
798
829
|
const dumpDbFlag = filteredArgs.includes('--dump-db');
|
|
799
830
|
const doctorConfirm = filteredArgs.includes('--confirm');
|
|
800
831
|
const pathIdx = filteredArgs.indexOf('--path');
|
|
@@ -825,6 +856,20 @@ async function main() {
|
|
|
825
856
|
// 复用顶层静态 import(line 12),不动态 await import
|
|
826
857
|
const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(doctorEffectiveDir, specDir) });
|
|
827
858
|
const specBase = resolvePlatformSpecDir(doctorEffectiveDir, specDir) || join(doctorEffectiveDir, '.sillyspec');
|
|
859
|
+
// 坑 doctor-align-bypass-review-gate(2026-08-20 实证):align 直写 completed 绕过
|
|
860
|
+
// completeStep,此前 execute 的 Stage/Task Review Gate 被整体跳过——worktree 清理后的
|
|
861
|
+
// 恢复场景恰是最需要审计的时刻。confirm 落盘前跑同源只读校验(dry-run 不拦,保持只读语义)。
|
|
862
|
+
if (doctorConfirm) {
|
|
863
|
+
const { enforceAlignExecuteReviewGate } = await import('./run/gates.js');
|
|
864
|
+
const gateBlocked = await enforceAlignExecuteReviewGate({
|
|
865
|
+
cwd: doctorEffectiveDir, changeName: alignChange, specBase,
|
|
866
|
+
});
|
|
867
|
+
if (gateBlocked) {
|
|
868
|
+
console.error(' 对齐未执行(review 校验未过)。补齐 review 后重跑本命令。');
|
|
869
|
+
process.exitCode = 1;
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
828
873
|
let r;
|
|
829
874
|
try {
|
|
830
875
|
r = pm.alignExecuteToPlan(doctorEffectiveDir, alignChange, specBase, { confirm: doctorConfirm });
|
|
@@ -866,6 +911,26 @@ async function main() {
|
|
|
866
911
|
process.exitCode = r.errors.length > 0 ? 1 : 0;
|
|
867
912
|
break;
|
|
868
913
|
}
|
|
914
|
+
if (cleanupGhosts) {
|
|
915
|
+
// SS-2(2026-08-20):归档幽灵行(db active 无目录)。默认 dry-run,--confirm 才写。
|
|
916
|
+
// SS-2b:扩空壳目录(active + 目录 0 文件 + last_active 超 7 天),归档行并移除空目录。
|
|
917
|
+
const { cleanupGhostChanges } = await import('./doctor-diagnostics.js');
|
|
918
|
+
const r = await cleanupGhostChanges({ cwd: doctorEffectiveDir, specDir, confirm: doctorConfirm });
|
|
919
|
+
if (json) {
|
|
920
|
+
console.log(JSON.stringify(r, null, 2));
|
|
921
|
+
} else {
|
|
922
|
+
console.log(`👻 幽灵 active 记录(db active 无目录 / 空壳目录超 7 天)${doctorConfirm ? '已归档' : '待清理(dry-run)'}:${r.count} 个 [db: ${r.db_path || '(未找到)'}]`);
|
|
923
|
+
for (const n of r.ghosts) console.log(` ${doctorConfirm ? '✅' : '-'} ${n}`);
|
|
924
|
+
for (const n of (r.empty_shells || [])) console.log(` ${doctorConfirm ? '✅' : '🫙'} ${n}(空壳目录${doctorConfirm ? ',空目录已移除' : ''})`);
|
|
925
|
+
for (const n of (r.skipped_nonempty || [])) console.log(` ⏭️ ${n}(复查时目录已非空,已放过——可能是活跃变更)`);
|
|
926
|
+
for (const e of r.errors) console.log(` ❌ ${e.name || ''}: ${e.error}`);
|
|
927
|
+
if (!doctorConfirm && r.count > 0) console.log(`
|
|
928
|
+
加 --confirm 执行归档(仅改 status=archived,可逆;空壳同时移除 0 文件空目录,不删任何有内容的目录)。`);
|
|
929
|
+
if (r.reason) console.log(`ℹ️ ${r.reason}`);
|
|
930
|
+
}
|
|
931
|
+
process.exitCode = r.errors.length > 0 ? 1 : 0;
|
|
932
|
+
break;
|
|
933
|
+
}
|
|
869
934
|
if (dumpDbFlag) {
|
|
870
935
|
if (!dbPath) { console.error('❌ --dump-db 需要 --path <db 路径>'); process.exitCode = 2; break; }
|
|
871
936
|
const { dumpDb } = await import('./doctor-diagnostics.js');
|
|
@@ -950,6 +1015,10 @@ async function main() {
|
|
|
950
1015
|
for (let i = 1; i < args.length; i++) {
|
|
951
1016
|
if (args[i] === '--port' && args[i + 1]) {
|
|
952
1017
|
port = parseInt(args[i + 1], 10);
|
|
1018
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
1019
|
+
console.error(`❌ 无效端口: ${args[i + 1]}(应为 1-65535 整数)`);
|
|
1020
|
+
process.exit(2);
|
|
1021
|
+
}
|
|
953
1022
|
i++;
|
|
954
1023
|
} else if (args[i] === '--no-open') {
|
|
955
1024
|
openBrowser = false;
|
|
@@ -1298,7 +1367,7 @@ SillySpec worktree — git worktree 隔离管理
|
|
|
1298
1367
|
const staleHours = staleIdx !== -1 && args[staleIdx + 1] ? parseInt(args[staleIdx + 1], 10) : 24;
|
|
1299
1368
|
const changeIdx = args.indexOf('--change');
|
|
1300
1369
|
const changeName = changeIdx !== -1 && args[changeIdx + 1] ? args[changeIdx + 1] : null;
|
|
1301
|
-
const diag = wm.doctor({ fix: fixFlag, staleHours, changeName });
|
|
1370
|
+
const diag = await wm.doctor({ fix: fixFlag, staleHours, changeName });
|
|
1302
1371
|
if (diag.issues.length === 0) {
|
|
1303
1372
|
console.log('✅ worktree 健康检查通过,无异常');
|
|
1304
1373
|
} else {
|
|
@@ -1437,6 +1506,15 @@ SillySpec platform — SillyHub 平台同步
|
|
|
1437
1506
|
process.exit(1);
|
|
1438
1507
|
}
|
|
1439
1508
|
|
|
1509
|
+
// 体检 SEC-05:platform 子命令族统一校验 --change 值——此前只有 run 命令族走
|
|
1510
|
+
// assertSafeChangeName,platform sync/pull/resolve 直接透传,冲突文件名
|
|
1511
|
+
// join(runtimeDir, `sync-conflict-${changeName}.json`) 可被 ../ 穿越出 .runtime
|
|
1512
|
+
{
|
|
1513
|
+
const pChangeIdx = args.indexOf('--change');
|
|
1514
|
+
const pChangeVal = pChangeIdx >= 0 && args[pChangeIdx + 1] ? args[pChangeIdx + 1] : null;
|
|
1515
|
+
if (pChangeVal) assertSafeChangeName(pChangeVal, 'platform --change 变更名');
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1440
1518
|
switch (platformSub) {
|
|
1441
1519
|
case 'pointer': {
|
|
1442
1520
|
// 指针状态检查(不依赖 sync 模块)
|
|
@@ -1503,7 +1581,11 @@ SillySpec platform — SillyHub 平台同步
|
|
|
1503
1581
|
const tokenIdx = args.indexOf('--token');
|
|
1504
1582
|
const token = tokenIdx >= 0 && args[tokenIdx + 1] ? args[tokenIdx + 1] : undefined;
|
|
1505
1583
|
if (!token) {
|
|
1506
|
-
|
|
1584
|
+
// 缺 token 直接终止(体检 HUB-02):交互式输入尚未实现(task-11),此前
|
|
1585
|
+
// 继续执行会把 undefined 序列化成字符串 "undefined" 写入 local.yaml,
|
|
1586
|
+
// 报"连接成功"后所有同步持续 401(Bearer undefined)
|
|
1587
|
+
console.error('❌ 缺少 --token <token>(交互式输入暂未实现);请在 SillyHub 平台获取 token 后重试');
|
|
1588
|
+
process.exit(1);
|
|
1507
1589
|
}
|
|
1508
1590
|
await syncModule.connect(url, token, dir);
|
|
1509
1591
|
break;
|
|
@@ -1626,6 +1708,8 @@ SillySpec platform — SillyHub 平台同步
|
|
|
1626
1708
|
console.error('❌ 必须恰好指定 --keep-local / --take-platform / --abort 之一');
|
|
1627
1709
|
process.exit(1);
|
|
1628
1710
|
}
|
|
1711
|
+
// 位置参数变更名与 --change 同规格校验(SEC-05:resolve 会按该名重推/删冲突文件)
|
|
1712
|
+
assertSafeChangeName(resolveName, 'platform resolve 变更名');
|
|
1629
1713
|
const modeMap = { '--keep-local': 'keep-local', '--take-platform': 'take-platform', '--abort': 'abort' };
|
|
1630
1714
|
const resolveMode = modeMap[resolveFlags[0]];
|
|
1631
1715
|
const r = await syncModule.resolve(resolveName, resolveMode, dir);
|
|
@@ -1673,6 +1757,71 @@ SillySpec platform — SillyHub 平台同步
|
|
|
1673
1757
|
}
|
|
1674
1758
|
break;
|
|
1675
1759
|
}
|
|
1760
|
+
case 'taskcard': {
|
|
1761
|
+
const tcName = filteredArgs.slice(1).find(a => !a.startsWith('-'));
|
|
1762
|
+
if (!tcName || tcName === 'help' || tcName === '--help' || tcName === '-h') {
|
|
1763
|
+
console.log(`
|
|
1764
|
+
SillySpec taskcard — 生成 Windows 安全的 TaskCard 骨架
|
|
1765
|
+
|
|
1766
|
+
用法:
|
|
1767
|
+
sillyspec taskcard <change-name> --task task-01[,task-02...] [--title <t>] [--title-zh <t>] [--force]
|
|
1768
|
+
sillyspec taskcard <change-name> --all [--force]
|
|
1769
|
+
|
|
1770
|
+
骨架由 CLI 直写(LF 行尾 + frontmatter 闭合 + 硬校验 9 字段齐全),标题自动取自 plan.md
|
|
1771
|
+
checkbox 行;之后用 Edit 填充占位符即可,勿手写整卡(CRLF/漏闭合 ---/漏字段的源头治理)。
|
|
1772
|
+
`);
|
|
1773
|
+
break;
|
|
1774
|
+
}
|
|
1775
|
+
try { assertSafeChangeName(tcName, '变更名'); }
|
|
1776
|
+
catch (e) { console.error(`❌ ${e.message}`); process.exit(2); }
|
|
1777
|
+
|
|
1778
|
+
const force = filteredArgs.includes('--force');
|
|
1779
|
+
const all = filteredArgs.includes('--all');
|
|
1780
|
+
const getVal = (flag) => {
|
|
1781
|
+
const idx = filteredArgs.indexOf(flag);
|
|
1782
|
+
return idx !== -1 && filteredArgs[idx + 1] && !filteredArgs[idx + 1].startsWith('-')
|
|
1783
|
+
? filteredArgs[idx + 1] : null;
|
|
1784
|
+
};
|
|
1785
|
+
const taskVal = getVal('--task');
|
|
1786
|
+
const titleVal = getVal('--title');
|
|
1787
|
+
const titleZhVal = getVal('--title-zh');
|
|
1788
|
+
|
|
1789
|
+
if (all && taskVal) {
|
|
1790
|
+
console.error('❌ --all 与 --task 互斥(--all 取 plan.md 全部任务,--task 显式指定)');
|
|
1791
|
+
process.exit(1);
|
|
1792
|
+
}
|
|
1793
|
+
if (!all && !taskVal) {
|
|
1794
|
+
console.error('❌ 用法: sillyspec taskcard <change-name> --task task-01[,task-02...] | --all [--force]');
|
|
1795
|
+
process.exit(1);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
let taskIds;
|
|
1799
|
+
if (all) {
|
|
1800
|
+
taskIds = 'all';
|
|
1801
|
+
} else {
|
|
1802
|
+
const { normalizeTaskId } = await import('./taskcard.js');
|
|
1803
|
+
try {
|
|
1804
|
+
taskIds = taskVal.split(',').map(s => s.trim()).filter(Boolean).map(normalizeTaskId);
|
|
1805
|
+
} catch (e) {
|
|
1806
|
+
console.error(`❌ ${e.message}`);
|
|
1807
|
+
process.exit(1);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
const { cmdTaskcard } = await import('./taskcard.js');
|
|
1812
|
+
try {
|
|
1813
|
+
const result = cmdTaskcard(tcName, { cwd: dir, specDir, taskIds, title: titleVal, titleZh: titleZhVal, force });
|
|
1814
|
+
for (const f of result.created) console.log(`✅ 已生成: ${f}`);
|
|
1815
|
+
for (const f of result.skipped) console.log(`⏭️ 已存在,跳过(--force 覆盖): ${f}`);
|
|
1816
|
+
if (result.created.length > 0) {
|
|
1817
|
+
console.log(` 下一步:用 Edit tool 填充占位符(allowed_paths/goal/implementation/acceptance/verify/constraints),勿用 Write 整文件重写。`);
|
|
1818
|
+
}
|
|
1819
|
+
} catch (e) {
|
|
1820
|
+
console.error(`❌ ${e.message}`);
|
|
1821
|
+
process.exit(1);
|
|
1822
|
+
}
|
|
1823
|
+
break;
|
|
1824
|
+
}
|
|
1676
1825
|
case 'change-rename': {
|
|
1677
1826
|
const oldName = filteredArgs[1];
|
|
1678
1827
|
const newName = filteredArgs[2];
|
package/src/init.js
CHANGED
|
@@ -5,6 +5,7 @@ import { checkbox, confirm, input } from '@inquirer/prompts';
|
|
|
5
5
|
import { ProgressManager } from './progress.js';
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import { getVersion } from './version.js';
|
|
8
|
+
import { gitQuiet } from './git-helper.js';
|
|
8
9
|
import { renderExample } from './config-schema.js';
|
|
9
10
|
// 向后兼容:getVersion 已抽到轻量 version.js(避免 index.js 为 --version 静态加载 init.js 的 inquirer 税),
|
|
10
11
|
// 此处 re-export 保持 init.js 既有导出 API 不破坏(如 test/init-claude-injection.test.mjs)。
|
|
@@ -557,9 +558,9 @@ export async function cmdInit(projectDir, options = {}) {
|
|
|
557
558
|
|
|
558
559
|
let repo = '';
|
|
559
560
|
try {
|
|
560
|
-
|
|
561
|
+
// QUAL-01 收口:裸 execFileSync → git-helper(safe.directory + 数组形式)
|
|
561
562
|
const absPath = resolve(projectDir, subPath.trim() || `./${name.trim()}`);
|
|
562
|
-
repo =
|
|
563
|
+
repo = gitQuiet(absPath, ['remote', 'get-url', 'origin']) || '';
|
|
563
564
|
} catch {}
|
|
564
565
|
|
|
565
566
|
subprojects.push({ name: name.trim(), path: subPath.trim() || `./${name.trim()}`, role: role.trim(), repo });
|