sillyspec 3.26.13 → 3.26.15
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 +2 -0
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
- package/.claude/skills/sillyspec-commit/SKILL.md +18 -60
- package/.claude/skills/sillyspec-continue/SKILL.md +2 -0
- package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
- package/.claude/skills/sillyspec-plan/SKILL.md +10 -2
- package/.claude/skills/sillyspec-quick/SKILL.md +2 -2
- package/.claude/skills/sillyspec-resume/SKILL.md +2 -0
- package/.claude/skills/sillyspec-verify/SKILL.md +2 -0
- package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
- package/package.json +1 -1
- package/src/commit-suggest.js +202 -0
- package/src/config-cat.js +88 -0
- package/src/docs-check.js +48 -12
- package/src/doctor-diagnostics.js +49 -0
- package/src/hooks/worktree-guard.js +20 -1
- package/src/index.js +547 -21
- package/src/init.js +1 -1
- package/src/local-register.js +98 -0
- package/src/module-impact.js +146 -0
- package/src/progress/change-registry.js +43 -4
- package/src/progress/stage-machine.js +10 -5
- package/src/progress.js +18 -3
- package/src/quicklog.js +106 -1
- package/src/run/command.js +151 -38
- package/src/run/complete-handlers.js +62 -4
- package/src/run/complete.js +10 -8
- package/src/run/concurrent-detect.js +3 -1
- package/src/run/gates.js +86 -4
- package/src/run/multi-repo-context.js +3 -1
- package/src/run/next.js +128 -0
- package/src/run/prompt.js +28 -6
- package/src/run/scan-profile.js +35 -23
- package/src/run/shared.js +28 -7
- package/src/scan-postcheck.js +142 -2
- package/src/stage-contract-spec.js +1 -1
- package/src/stage-review.js +55 -7
- package/src/stages/archive.js +1 -1
- package/src/stages/brainstorm.js +26 -12
- package/src/stages/doctor.js +2 -4
- package/src/stages/execute.js +110 -26
- package/src/stages/knowledge.js +21 -5
- package/src/stages/plan-postcheck.js +24 -10
- package/src/stages/plan.js +14 -9
- package/src/stages/quick.js +14 -6
- package/src/stages/scan.js +1 -0
- package/src/stages/verify.js +7 -49
- package/src/sync.js +19 -7
- package/src/task-review.js +256 -10
- package/src/verify-probes.js +264 -0
- package/src/workspace.js +125 -0
- package/src/worktree-apply.js +15 -1
- package/src/worktree.js +58 -8
- package/templates/claude-instruction.md +1 -1
- package/templates/prompts/taskcard-rules.md +1 -1
- package/templates/prompts/verify-probes.md +13 -47
package/src/docs-check.js
CHANGED
|
@@ -388,8 +388,8 @@ export function runDocsCheck(opts) {
|
|
|
388
388
|
reason: repoPrefix + (candidateFails.length > 1 ? `多候选全失败 → ${candidateFails.join(' | ')}` : candidateFails[0]),
|
|
389
389
|
// 建议行号(--suggest):token 在首个候选文件的全量命中行,供人工确认改锚——不自动改文件
|
|
390
390
|
suggest: suggestLines(candidates, tokens),
|
|
391
|
-
// 修复分类(--fix 判定依据):全量候选 token
|
|
392
|
-
fix: classifyFix(candidates, tokens),
|
|
391
|
+
// 修复分类(--fix 判定依据):全量候选 token 命中打分——唯一命中或选优严格领先可自动重锚
|
|
392
|
+
fix: classifyFix(candidates, tokens, r.start),
|
|
393
393
|
})
|
|
394
394
|
}
|
|
395
395
|
}
|
|
@@ -415,34 +415,70 @@ function suggestLines(candidates, tokens) {
|
|
|
415
415
|
|
|
416
416
|
/**
|
|
417
417
|
* 修复分类(--fix 判定依据):对 resolveCandidates 全量候选跑 token 命中统计,跨候选合并行号后——
|
|
418
|
-
* 唯一命中 → fixable=true + newLine
|
|
418
|
+
* 唯一命中 → fixable=true + newLine(自动重锚唯一解);
|
|
419
|
+
* 多命中 → 打分选优(2026-08-21 docs-ref-auto-pick,替代 D-006 一律人工):top1 严格优于
|
|
420
|
+
* top2(分数差 > 0)自动重锚,同分仍人工。实证:一次大改 34 处漂移只有 2 处唯一命中,
|
|
421
|
+
* 32 处人工逐个挑——多命中不代表歧义,行号漂移多为单次编辑的单调位移。
|
|
422
|
+
* 打分信号(全部来自现有数据,不新增 IO):
|
|
423
|
+
* - 距离:|命中行 - 文档旧行号|(权重封顶 400)——最近的命中大概率是原锚点漂移后的位置
|
|
424
|
+
* - 定义行模式 +50:function/const/let/class/def/export 关键词**紧跟** token(引用锚通常指
|
|
425
|
+
* 定义起始行而非调用点;「keyword 后 60 字符内出现」的宽松口径会把调用点误判为定义)
|
|
426
|
+
* - 注释行 -30(// * # -- 开头)
|
|
427
|
+
* - 含最长 token(点分整串)+10,优于只含拆段
|
|
419
428
|
* 与 suggestLines 的差异:suggest 只查首个候选(candidates[0]),本函数查全量候选(design §12 自审存疑 +
|
|
420
429
|
* R-01——多候选同名文件场景下防止定位到另一文件的同名符号)。合并口径按「去重后的行号集合」:
|
|
421
430
|
* 重锚写回的目标是行号,多个候选命中同一行号仍是唯一改写目标。
|
|
422
431
|
* 纯增量分类,不参与层1/层2 判定(D-004 兼容红线)。
|
|
423
432
|
* @param {string[]} candidates resolveCandidates 返回的全量候选绝对路径
|
|
424
433
|
* @param {string[]} tokens 层2 提取的期望 token(keywordAssert=false 时为空数组)
|
|
434
|
+
* @param {number|null} [refStart] 文档引用的旧行号(打分用距离信号;范围引用取 start)
|
|
425
435
|
* @returns {{ fixable: boolean, newLine?: number, reason: string }}
|
|
426
436
|
*/
|
|
427
|
-
function
|
|
437
|
+
function escapeReLocal(s) {
|
|
438
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function classifyFix(candidates, tokens, refStart = null) {
|
|
428
442
|
if (tokens.length === 0) {
|
|
429
443
|
return { fixable: false, reason: '无 token 符号线索(纯位置引用或关键词断言关闭),无法自动定位' }
|
|
430
444
|
}
|
|
431
|
-
const
|
|
445
|
+
const longest = tokens.reduce((a, b) => (b.length > a.length ? b : a), '')
|
|
446
|
+
const defToken = longest.includes('.') ? longest.split('.').pop() : longest
|
|
447
|
+
const defRe = new RegExp(`(?:export\\s+)?(?:async\\s+)?(?:function|const|let|class|def)\\s+${escapeReLocal(defToken)}\\b`)
|
|
448
|
+
const byLine = new Map()
|
|
432
449
|
for (const candAbs of candidates) {
|
|
433
450
|
const lines = readLines(candAbs)
|
|
434
451
|
if (lines === null) continue
|
|
435
|
-
lines.forEach((l, i) => {
|
|
452
|
+
lines.forEach((l, i) => {
|
|
453
|
+
if (!tokens.some((t) => l.includes(t))) return
|
|
454
|
+
const lineNo = i + 1
|
|
455
|
+
let score = 0
|
|
456
|
+
if (/^(\/\/|\*|#|--)/.test(l.trim())) score -= 30
|
|
457
|
+
if (defRe.test(l)) score += 50
|
|
458
|
+
if (l.includes(longest)) score += 10
|
|
459
|
+
if (refStart != null && Number.isFinite(refStart)) score -= Math.min(Math.abs(lineNo - refStart), 400)
|
|
460
|
+
// 同一行跨候选取最高分(合并口径不变:重锚目标是行号)
|
|
461
|
+
if (score > (byLine.get(lineNo) ?? -Infinity)) byLine.set(lineNo, score)
|
|
462
|
+
})
|
|
436
463
|
}
|
|
437
|
-
if (
|
|
464
|
+
if (byLine.size === 0) {
|
|
438
465
|
return { fixable: false, reason: 'token 在全量候选文件零命中,无法自动定位' }
|
|
439
466
|
}
|
|
440
|
-
if (
|
|
441
|
-
return { fixable: true, newLine: [...
|
|
467
|
+
if (byLine.size === 1) {
|
|
468
|
+
return { fixable: true, newLine: [...byLine.keys()][0], reason: 'token 在全量候选唯一命中,可自动重锚' }
|
|
469
|
+
}
|
|
470
|
+
const ranked = [...byLine.entries()].sort((a, b) => (b[1] - a[1]) || (a[0] - b[0]))
|
|
471
|
+
const [topLine, topScore] = ranked[0]
|
|
472
|
+
const [runLine, runScore] = ranked[1]
|
|
473
|
+
if (topScore > runScore) {
|
|
474
|
+
return {
|
|
475
|
+
fixable: true,
|
|
476
|
+
newLine: topLine,
|
|
477
|
+
reason: `多命中自动选优:picked=${topLine}(${topScore} 分)runnerUp=${runLine}(${runScore} 分,严格落后才自动改)`,
|
|
478
|
+
}
|
|
442
479
|
}
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
return { fixable: false, reason: `token 多处命中(候选行号:${shown}),歧义需人工确认(D-006 保守默认)` }
|
|
480
|
+
const shown = ranked.slice(0, 8).map(([l]) => l).join('、') + (ranked.length > 8 ? '…' : '')
|
|
481
|
+
return { fixable: false, reason: `token 多处命中且最优两行同分(候选行号:${shown}),歧义需人工确认(D-006 保守默认)` }
|
|
446
482
|
}
|
|
447
483
|
|
|
448
484
|
/**
|
|
@@ -836,6 +836,7 @@ export async function cleanupGhostChanges({ cwd, specDir = null, confirm = false
|
|
|
836
836
|
|
|
837
837
|
let db = null;
|
|
838
838
|
const archived = [];
|
|
839
|
+
const finalized = [];
|
|
839
840
|
const errors = [];
|
|
840
841
|
const skippedNonEmpty = [];
|
|
841
842
|
const removedDirs = [];
|
|
@@ -850,9 +851,56 @@ export async function cleanupGhostChanges({ cwd, specDir = null, confirm = false
|
|
|
850
851
|
if (confirm) {
|
|
851
852
|
const now = new Date().toISOString();
|
|
852
853
|
const stmt = db.prepare("UPDATE changes SET status = 'archived', last_active = ? WHERE name = ? AND status = 'active'");
|
|
854
|
+
// 终态一致化(坑 manual-archive-desync-status-only,2026-08-21 实证):手动搬目录到 archive/
|
|
855
|
+
// 绕过标准归档的变更在此被当幽灵归档——只翻 status 会留「已归档 + current_stage 停留 +
|
|
856
|
+
// 归档 0/5」的矛盾终态,平台渲染成「进度丢失」。有 archive/ 实体证据的幽灵同步收尾
|
|
857
|
+
// archive 阶段(current_stage/stages/steps);目录真丢失(无实体证据)保持 status-only
|
|
858
|
+
//(收尾=宣称归档完成属伪造,保持可逆的原语义)。步骤名取 stageRegistry 单一真相。
|
|
859
|
+
const { stageRegistry } = await import('./stages/index.js').catch(() => ({ stageRegistry: null }));
|
|
860
|
+
const archiveStepNames = Array.isArray(stageRegistry?.archive?.steps)
|
|
861
|
+
? stageRegistry.archive.steps.map(s => s.name) : [];
|
|
862
|
+
const archiveDir = join(changesDir, 'archive');
|
|
863
|
+
const finalizeTerminal = (name) => {
|
|
864
|
+
if (archiveStepNames.length === 0) return false;
|
|
865
|
+
try {
|
|
866
|
+
const row = db.prepare('SELECT id FROM changes WHERE name = ?').get(name);
|
|
867
|
+
if (!row) return false;
|
|
868
|
+
db.prepare("UPDATE changes SET current_stage = 'archive' WHERE id = ?").run(row.id);
|
|
869
|
+
db.prepare(
|
|
870
|
+
`INSERT INTO stages (change_id, stage, status, started_at, completed_at)
|
|
871
|
+
VALUES (?, 'archive', 'completed', ?, ?)
|
|
872
|
+
ON CONFLICT(change_id, stage) DO UPDATE SET status = 'completed', completed_at = excluded.completed_at`
|
|
873
|
+
).run(row.id, now, now);
|
|
874
|
+
const stageRow = db.prepare('SELECT id FROM stages WHERE change_id = ? AND stage = ?').get(row.id, 'archive');
|
|
875
|
+
if (!stageRow) return false;
|
|
876
|
+
db.prepare('UPDATE steps SET status = ?, completed_at = ? WHERE stage_id = ?').run('completed', now, stageRow.id);
|
|
877
|
+
const existing = new Set(
|
|
878
|
+
db.prepare('SELECT name FROM steps WHERE stage_id = ?').all(stageRow.id).map(r => r.name)
|
|
879
|
+
);
|
|
880
|
+
let order = existing.size;
|
|
881
|
+
const ins = db.prepare(
|
|
882
|
+
`INSERT INTO steps (stage_id, name, status, completed_at, ordering) VALUES (?, ?, 'completed', ?, ?)`
|
|
883
|
+
);
|
|
884
|
+
for (const sn of archiveStepNames) {
|
|
885
|
+
if (!existing.has(sn)) ins.run(stageRow.id, sn, now, order++)
|
|
886
|
+
}
|
|
887
|
+
return true;
|
|
888
|
+
} catch { return false }
|
|
889
|
+
};
|
|
853
890
|
for (const n of ghosts) {
|
|
854
891
|
try {
|
|
855
892
|
stmt.run(now, n);
|
|
893
|
+
// 手动归档证据:changes/archive/ 下有该变更目录 → 一并收尾终态
|
|
894
|
+
const archivedEvidence = (() => {
|
|
895
|
+
try {
|
|
896
|
+
if (!existsSync(archiveDir)) return false;
|
|
897
|
+
return readdirSync(archiveDir).some(e => e === n && existsSync(join(archiveDir, e, 'plan.md')))
|
|
898
|
+
|| readdirSync(archiveDir).some(e => e.startsWith(n) && existsSync(join(archiveDir, e, 'plan.md')));
|
|
899
|
+
} catch { return false }
|
|
900
|
+
})();
|
|
901
|
+
if (archivedEvidence && finalizeTerminal(n)) {
|
|
902
|
+
finalized.push(n);
|
|
903
|
+
}
|
|
856
904
|
archived.push(n);
|
|
857
905
|
} catch (e) {
|
|
858
906
|
errors.push({ name: n, error: e.message });
|
|
@@ -886,6 +934,7 @@ export async function cleanupGhostChanges({ cwd, specDir = null, confirm = false
|
|
|
886
934
|
skipped_nonempty: skippedNonEmpty,
|
|
887
935
|
removed_dirs: removedDirs,
|
|
888
936
|
archived,
|
|
937
|
+
finalized, // 手动归档型幽灵已同步收尾 archive 阶段终态(坑 manual-archive-desync-status-only)
|
|
889
938
|
errors,
|
|
890
939
|
count: ghosts.length + emptyShells.length,
|
|
891
940
|
active_after: activeAfter,
|
|
@@ -18,7 +18,9 @@ import { execFileSync } from 'child_process'
|
|
|
18
18
|
const WORKTREE_STAGES = ['execute'] // 这些阶段必须在 worktree 里
|
|
19
19
|
|
|
20
20
|
const FILE_WHITELIST_EXTS = ['.md']
|
|
21
|
-
|
|
21
|
+
// local.yaml/local.yml 不在白名单:同名文件只允许存在于 .sillyspec/ 下(shouldBlockWrite
|
|
22
|
+
// 规则 0 拦 .sillyspec/ 之外的写入,2026-08-21 root-local-yaml 治理)
|
|
23
|
+
const FILE_WHITELIST_NAMES = ['package.json', 'tsconfig.json']
|
|
22
24
|
|
|
23
25
|
/** 只读命令(命令名) */
|
|
24
26
|
const READONLY_COMMANDS = new Set([
|
|
@@ -681,6 +683,23 @@ export function shouldBlockWrite(filePath, cwd) {
|
|
|
681
683
|
const projectRoot = findProjectRoot(callerCwd)
|
|
682
684
|
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(callerCwd, filePath)
|
|
683
685
|
|
|
686
|
+
// 0. local.yaml 位置门禁(2026-08-21 root-local-yaml 治理):local.yaml 只允许存在于
|
|
687
|
+
// .sillyspec/ 下。agent 按根目录惯例找/建根级 local.yaml,而 loadLocalConfig 候选链
|
|
688
|
+
// 真会读它 → 根级副本会遮蔽/分裂真实配置。阶段无关——任何阶段写 .sillyspec/ 之外的
|
|
689
|
+
// 同名文件一律拦截并指路(真实读配置走 sillyspec config cat)。
|
|
690
|
+
const yamlBase = path.basename(absPath)
|
|
691
|
+
if ((yamlBase === 'local.yaml' || yamlBase === 'local.yml')
|
|
692
|
+
&& !absPath.split(path.sep).includes('.sillyspec')) {
|
|
693
|
+
return {
|
|
694
|
+
blocked: true,
|
|
695
|
+
reason: [
|
|
696
|
+
`local.yaml 只能存在于 .sillyspec/ 目录下(当前目标:${absPath})。`,
|
|
697
|
+
`真实配置:${path.join(projectRoot, '.sillyspec', 'local.yaml')}(gitignored,勿在别处建副本)。`,
|
|
698
|
+
'读配置:sillyspec config cat(自动定位真实路径,worktree 内也解析到主仓);键清单:sillyspec config schema。',
|
|
699
|
+
].join('\n'),
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
684
703
|
// 1. 阶段门禁(直读 sillyspec.db)
|
|
685
704
|
const stage = readCurrentStage(projectRoot) || '(none)'
|
|
686
705
|
|