peaks-loop 4.0.23 → 4.0.24

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/cli/commands/_register.js +2 -1
  3. package/dist/cli/commands/best-practice-scan-command.d.ts +36 -0
  4. package/dist/cli/commands/best-practice-scan-command.js +118 -0
  5. package/dist/cli/commands/request-commands.js +21 -9
  6. package/dist/services/best-practice/language-detector.d.ts +7 -0
  7. package/dist/services/best-practice/language-detector.js +96 -0
  8. package/dist/services/best-practice/output-formatter.d.ts +41 -0
  9. package/dist/services/best-practice/output-formatter.js +116 -0
  10. package/dist/services/best-practice/scan-orchestrator.d.ts +39 -0
  11. package/dist/services/best-practice/scan-orchestrator.js +85 -0
  12. package/dist/services/context/collector.d.ts +5 -12
  13. package/dist/services/context/context-builder.d.ts +12 -20
  14. package/dist/services/context/context-builder.js +1 -1
  15. package/dist/services/context/context-schema.d.ts +51 -364
  16. package/dist/services/context/context-schema.js +3 -3
  17. package/dist/services/crystallization/crystallization-service.d.ts +2 -12
  18. package/dist/services/crystallization/crystallization-service.js +3 -9
  19. package/dist/services/crystallization/crystallization-types.d.ts +45 -153
  20. package/dist/services/crystallization/crystallization-types.js +17 -11
  21. package/dist/services/crystallization/evidence-brief-builder.d.ts +12 -112
  22. package/dist/services/evolution/evolution-types.d.ts +83 -497
  23. package/dist/services/evolution/evolution-types.js +6 -10
  24. package/dist/services/fixture/fixture-sanitize-service.d.ts +10 -36
  25. package/dist/services/fixture/fixture-sanitize-service.js +1 -1
  26. package/dist/services/job/job-progress-store.d.ts +1 -17
  27. package/dist/services/job/job-progress-store.js +2 -2
  28. package/dist/services/job/job-types.d.ts +63 -370
  29. package/dist/services/job/job-types.js +46 -11
  30. package/dist/services/loop/loop-bee-relation-types.d.ts +20 -32
  31. package/dist/services/loop/loop-bee-relation-types.js +2 -2
  32. package/dist/services/loop/loop-release-types.d.ts +31 -110
  33. package/dist/services/migrate-skill-name/schema.d.ts +3 -19
  34. package/dist/services/migrate-skill-name/schema.js +4 -4
  35. package/dist/services/observability/observability-service.d.ts +21 -19
  36. package/dist/services/observability/observability-service.js +1 -1
  37. package/dist/services/prd/best-practice-auto-trigger.d.ts +61 -0
  38. package/dist/services/prd/best-practice-auto-trigger.js +126 -0
  39. package/dist/services/rd/types.d.ts +25 -194
  40. package/dist/services/sediment/json-schema.d.ts +39 -224
  41. package/dist/services/sediment/json-schema.js +2 -2
  42. package/dist/services/session/binding-store.d.ts +5 -47
  43. package/dist/services/session/binding-store.js +1 -1
  44. package/dist/services/share/bundle-types.d.ts +12 -180
  45. package/dist/services/share/bundle-types.js +15 -9
  46. package/dist/services/share/run-state-contract.d.ts +8 -25
  47. package/dist/services/skill/skill-search-service.d.ts +26 -46
  48. package/dist/services/skill/skill-search-service.js +2 -2
  49. package/dist/services/skills/hooks-settings-service.d.ts +9 -0
  50. package/dist/services/skills/hooks-settings-service.js +31 -1
  51. package/dist/services/skills/skill-presence-service.d.ts +24 -0
  52. package/dist/services/skills/skill-presence-service.js +33 -0
  53. package/package.json +6 -6
  54. package/schemas/context.schema.json +17 -8
  55. package/skills/bee/peaks-prd/SKILL.md +17 -0
  56. package/skills/peaks-code/SKILL.md +28 -1
  57. package/skills/peaks-code/references/skill-presence-and-title.md +3 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.24 — 2026-08-12 (zod v4 + Context7 + Step 2.5 best-practice scan)
4
+
5
+ **6 atomic commits from session 2026-08-12-session-4aaf2b** (5 functional + 1 sediment):
6
+
7
+ - `545a7d15` feat(peaks-prd): auto-trigger best-practice-scan on prd → handed-off transition (Slice F, 3 files, +446/-13)
8
+ - `3d866138` docs(peaks-code): wire Step 2.5 best-practice scan sub-step into workflow narrative (Slice D, 3 files, +48/-4)
9
+ - `a0f3efc0` feat(peaks-code): add Step 2.5 best-practice scan core (Slice C, 8 files, +947/-1)
10
+ - `a8f31b1b` chore(deps): add @upstash/context7-mcp@^4.0.2 for downstream best-practice docs (Slice B, 2 files, +490/-2)
11
+ - `24dfd8da` refactor(zod): bump zod 3.25.76 → 4.4.3 across monorepo (Slice A, 21 files, +148/-132)
12
+ - `f2fc76de` chore(memory): sediment pre-existing dirty — .peaks/PROJECT.md + .peaks/memory/index.json
13
+
14
+ **Highlights**:
15
+
16
+ 1. **zod v3 → v4 upgrade** — 21 src files migrated, 4 v3 API families fixed (`.nonnegative/.positive`, `z.record` 1-arg, `errorMap`, `.refine()` path). Lockstep peaks-loop-shared + peaks-loop-internal-runtime bump. D-2 deviation (RD fabrication) caught by QA and fixed in same session — `zod-to-json-schema` replaced by zod v4 native `z.toJSONSchema()`.
17
+
18
+ 2. **`@upstash/context7-mcp@^4.0.2` added** as production dep — Context7's zod v4 peer dep is now satisfied. 8 transitive deps resolved (express/jose/undici/MCP packages). Downstream projects consuming peaks-loop now have Context7 transitively available.
19
+
20
+ 3. **peak-code Step 2.5 best-practice scan (BPS)** shipped — new sub-step between Step 2 (PRD) and Step 3 (RD). Auto-triggered on prd → handed-off transition. 8-row markdown table output with ★-marked LLM recommendation + mandatory ⚠️ catch-gate. 5-lang detector (TS/JS/Python/Go/Java). 53 vitest cases (44 core + 9 auto-trigger). 0 forbidden-token regressions. All 8 ACs PASS.
21
+
22
+ **Lockstep contract** (per peaks-cli-version-shared-chicken-egg):
23
+ - peaks-loop root `package.json#version`: 4.0.23 → **4.0.24**
24
+ - peaks-loop-shared `src/version.ts` `CLI_VERSION`: 4.0.23 → **4.0.24**
25
+ - peaks-loop-shared `package.json#version`: 0.0.57 → **0.0.58**
26
+ - peaks-loop-internal-runtime `src/index.ts` `RUNTIME_VERSION`: 4.0.23 → **4.0.24**
27
+ - peaks-loop-internal-runtime `src/index.ts` `RUNTIME_NPM_VERSION`: 0.0.8 → **0.0.9**
28
+ - peaks-loop-internal-runtime `package.json#version`: 0.0.8 → **0.0.9**
29
+
30
+ **Pre-publish registry state**:
31
+ - `peaks-loop@4.0.23` ✓ on registry (current latest)
32
+ - `peaks-loop@4.0.22` ✗ unpublished + in 24-72h grace (out of scope)
33
+ - `peaks-loop-shared@0.0.57` ✓ on registry
34
+ - `peaks-loop-internal-runtime@0.0.8` ✓ on registry
35
+
36
+ **Sister subpackages NOT bumped** (out of Step 2.5 scope):
37
+ - peaks-loop-mut: 0.1.22 (unchanged; only its zod dep was bumped in Slice A)
38
+ - peaks-loop-shared-channel: 0.0.26 (unchanged)
39
+
40
+ **Pending follow-ups** (out of 4.0.24 scope):
41
+ - real MCP wiring for Context7 (replace v1 stubs in `src/services/best-practice/scan-orchestrator.ts`)
42
+ - per-language libraries lookup (currently `...` in non-parallel rows)
43
+ - `startup-sequence.md:100` legacy "Step 2.5: Set session title" → 2.5a rename (Slice D deviation #1)
44
+ - 4 unit pre-existing failures unrelated to this release (verified against pre-migration main)
45
+
3
46
  ## 4.0.23 — 2026-08-11 (Bump to NEW version — per Rule 3 unpublish 24-72h grace)
4
47
 
5
48
  **1 atomic commits from session 2026-08-11** + Layer 3 fix verify (publish.yml no auto-bump):
@@ -4,6 +4,7 @@ import { registerAssetCommands } from './asset-commands.js';
4
4
  import { registerAuditCommands } from './audit-commands.js';
5
5
  import { registerBaselineCommands } from './baseline-commands.js';
6
6
  import { registerBeeCommands } from './bee-commands.js';
7
+ import { registerBestPracticeScanCommand } from './best-practice-scan-command.js';
7
8
  import { registerCapabilityWorkerConfigAndSCCommands } from './capability-worker-config-sc-commands.js';
8
9
  import { registerCodeCommands } from './code-commands.js';
9
10
  import { registerCodeGateCommand } from './code-gate-command.js';
@@ -122,7 +123,7 @@ const REGISTRATIONS = [
122
123
  ['security-audit-commands', registerSecurityAuditCommands], ['perf-audit-commands', registerPerfAuditCommands],
123
124
  ['verdict-aggregate-command', registerVerdictAggregateCommands], ['log-commands', registerLogCommands],
124
125
  ['qa-commands', registerQaCommands], ['test-commands', registerTestCommands],
125
- ['playwright-commands', registerPlaywrightCommands], ['code-commands', registerCodeCommands], ['code-gate-command', registerCodeGateCommand], ['dashboard-commands', registerDashboardCommands],
126
+ ['playwright-commands', registerPlaywrightCommands], ['code-commands', registerCodeCommands], ['code-gate-command', registerCodeGateCommand], ['dashboard-commands', registerDashboardCommands], ['best-practice-scan-command', registerBestPracticeScanCommand],
126
127
  ['mut-commands', registerMutCommands], ['fixture-commands', registerFixtureCommands],
127
128
  ['reviewer-commands', registerReviewerCommands], ['ide-commands', registerIdeCommands],
128
129
  ['observability-commands', registerObservabilityCommands], ['compact-command', registerCompactCommands],
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Slice 2026-08-12 best-practice-scan — CLI subcommand.
3
+ *
4
+ * `peaks best-practice-scan --project <path> [--lang <lang>] [--commit]`
5
+ *
6
+ * Pipeline:
7
+ * 1. detect language via language-detector (or use --lang override)
8
+ * 2. scan via scan-orchestrator (Context7 → WebSearch → empty)
9
+ * 3. render 8-row table via output-formatter
10
+ * 4. write artifact into <projectRoot>/best-practice/<date>-<intent>.md
11
+ * (gitignored directory by convention; --commit flag reserved for
12
+ * a future slice that opts the artifact into git tracking)
13
+ * 5. print artifact path + the 8-row table to stdout
14
+ *
15
+ * Catch-gate (spec §7): the command emits a 3-line prompt asking the
16
+ * user to ack / pick alt / reject + reason. The gate is read from
17
+ * stdin via `PEAKS_BEST_PRACTICE_STDIN` (test seam) or the real stdin
18
+ * (production). The result is written to stdout as a JSON envelope
19
+ * suffix so the orchestrator can react programmatically.
20
+ */
21
+ import type { Command } from 'commander';
22
+ import { type ProgramIO } from '../cli-helpers.js';
23
+ import { type RecommendationChoice } from '../../services/best-practice/output-formatter.js';
24
+ export type CatchGateOutcome = {
25
+ kind: 'accept';
26
+ choice: RecommendationChoice;
27
+ } | {
28
+ kind: 'alternative';
29
+ choice: RecommendationChoice;
30
+ reason?: string;
31
+ } | {
32
+ kind: 'reject';
33
+ reason: string;
34
+ };
35
+ export declare function parseCatchGateReply(raw: string, recommended: RecommendationChoice): CatchGateOutcome;
36
+ export declare function registerBestPracticeScanCommand(program: Command, io: ProgramIO): void;
@@ -0,0 +1,118 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
4
+ import { fail, ok } from 'peaks-loop-shared/result';
5
+ import { detectLanguage } from '../../services/best-practice/language-detector.js';
6
+ import { scanBestPractice } from '../../services/best-practice/scan-orchestrator.js';
7
+ import { findForbiddenTokens, formatOutputTable } from '../../services/best-practice/output-formatter.js';
8
+ const CATCH_GATE_PROMPT = [
9
+ '⚠️ 任何跟你真实业务不一样,改 — LLM 推荐可能错。',
10
+ '回应 (默认 = 接受): 接受 / 接受方案 A|接受方案 B|接受方案 C / 拒绝 + 原因'
11
+ ].join('\n');
12
+ async function readUserInput() {
13
+ const override = process.env.PEAKS_BEST_PRACTICE_STDIN;
14
+ if (override !== undefined)
15
+ return override;
16
+ if (process.stdin.isTTY)
17
+ return '';
18
+ return new Promise((resolveStdin) => {
19
+ let data = '';
20
+ process.stdin.setEncoding('utf8');
21
+ process.stdin.on('data', (chunk) => {
22
+ data += chunk;
23
+ });
24
+ process.stdin.on('end', () => resolveStdin(data.trim()));
25
+ process.stdin.on('error', () => resolveStdin(data.trim()));
26
+ });
27
+ }
28
+ export function parseCatchGateReply(raw, recommended) {
29
+ const trimmed = raw.trim();
30
+ if (trimmed.length === 0) {
31
+ return { kind: 'accept', choice: recommended };
32
+ }
33
+ const altMatch = /^接受方案\s*([ABC])(?:\s+(.+))?$/u.exec(trimmed);
34
+ if (altMatch !== null) {
35
+ const letter = altMatch[1];
36
+ const reason = altMatch[2];
37
+ if (letter === 'A' || letter === 'B' || letter === 'C') {
38
+ return reason === undefined
39
+ ? { kind: 'alternative', choice: letter }
40
+ : { kind: 'alternative', choice: letter, reason };
41
+ }
42
+ }
43
+ const rejectMatch = /^拒绝(?:\s+(.+))?$/u.exec(trimmed);
44
+ if (rejectMatch !== null) {
45
+ const reason = rejectMatch[1];
46
+ return reason === undefined
47
+ ? { kind: 'reject', reason: 'unspecified' }
48
+ : { kind: 'reject', reason };
49
+ }
50
+ if (trimmed === '接受' || trimmed === 'accept' || trimmed === 'y' || trimmed === 'Y') {
51
+ return { kind: 'accept', choice: recommended };
52
+ }
53
+ // Anything else is treated as a rejection-with-reason.
54
+ return { kind: 'reject', reason: trimmed };
55
+ }
56
+ export function registerBestPracticeScanCommand(program, io) {
57
+ addJsonOption(program
58
+ .command('best-practice-scan')
59
+ .description('2026-08-12 best-practice-scan: language-aware + business-aware doc-fragment lookup ' +
60
+ 'via Context7 (priority 1) → WebSearch (priority 2) → empty fallback. ' +
61
+ 'Renders the 8-row comparison table from spec §5 + §6 + §7. ' +
62
+ 'Writes the artifact to <project>/best-practice/<date>-<intent>.md (gitignored).')
63
+ .option('--project <path>', 'project root (default cwd)', process.cwd())
64
+ .option('--lang <lang>', 'language override (skip auto-detect)')
65
+ .option('--commit', 'reserved flag — opts the artifact into git tracking (future slice)')).action(async (opts) => {
66
+ try {
67
+ const detection = opts.lang === undefined ? detectLanguage(opts.project) : null;
68
+ const language = opts.lang ?? detection?.language ?? 'unknown';
69
+ io.stdout(`[best-practice-scan] project=${opts.project} language=${language}`);
70
+ const scan = await scanBestPractice({
71
+ intent: opts.project,
72
+ language,
73
+ projectRoot: opts.project,
74
+ io
75
+ });
76
+ const recommendation = scan.results.length > 0 ? 'A' : 'B';
77
+ const reasoning = `基于 ${scan.fragments.length} 个 ${scan.source} 文档片段;` +
78
+ `语言=${language};LLM 推断方案 ${recommendation} 与项目阶段最匹配。`;
79
+ const table = formatOutputTable({
80
+ intent: opts.project,
81
+ language,
82
+ fragments: scan.results,
83
+ recommendation,
84
+ reasoning
85
+ });
86
+ io.stdout(table);
87
+ const forbiddenHits = findForbiddenTokens(table);
88
+ if (forbiddenHits.length > 0) {
89
+ io.stderr(`[best-practice-scan] WARNING: forbidden tokens detected: ${forbiddenHits.join(', ')}`);
90
+ }
91
+ io.stdout('');
92
+ io.stdout(CATCH_GATE_PROMPT);
93
+ const userInput = await readUserInput();
94
+ const outcome = parseCatchGateReply(userInput, recommendation);
95
+ const outDir = join(opts.project, 'best-practice');
96
+ mkdirSync(outDir, { recursive: true });
97
+ const dateStr = new Date().toISOString().slice(0, 10);
98
+ const slug = opts.project.replace(/[^a-zA-Z0-9_-]+/g, '-').slice(0, 32) || 'intent';
99
+ const artifactPath = join(outDir, `${dateStr}-${slug}.md`);
100
+ writeFileSync(artifactPath, table + '\n', 'utf8');
101
+ printResult(io, ok('best-practice.scan', {
102
+ project: opts.project,
103
+ language,
104
+ scanSource: scan.source,
105
+ fragments: scan.results,
106
+ recommendation,
107
+ catchGate: outcome,
108
+ artifactPath,
109
+ commitFlag: opts.commit === true,
110
+ forbiddenHits
111
+ }, forbiddenHits.length > 0 ? [`forbidden tokens present: ${forbiddenHits.join(', ')}`] : [], [`Artifact written to ${artifactPath}`, `catch-gate outcome: ${outcome.kind}`]), opts.json === true);
112
+ }
113
+ catch (err) {
114
+ printResult(io, fail('best-practice.scan', 'BEST_PRACTICE_SCAN_FAILED', getErrorMessage(err), { stack: err instanceof Error ? err.stack : undefined }, ['Rerun with --json for machine-readable envelope']), opts.json === true);
115
+ process.exitCode = 1;
116
+ }
117
+ });
118
+ }
@@ -5,6 +5,7 @@ import { recordBypass, isBypassLimitReached, MAX_BYPASSES_PER_SESSION } from '..
5
5
  import { lintRequestArtifact } from '../../services/artifacts/artifact-lint-service.js';
6
6
  import { getRepairCycleStatus } from '../../services/artifacts/repair-cycle-service.js';
7
7
  import { fail, ok } from 'peaks-loop-shared/result';
8
+ import { triggerBestPracticeScan } from '../../services/prd/best-practice-auto-trigger.js';
8
9
  import { formatMdCompact } from '../../shared/format-md-compact.js';
9
10
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
10
11
  /**
@@ -371,6 +372,10 @@ export function registerRequestCommands(program, io) {
371
372
  }
372
373
  // v2.13.2 AC-4 — auto-regen prd/handoff.md on prd:handed-off success.
373
374
  // Only fires when the handoff is missing; existing handoffs are not overwritten.
375
+ // Slice 2026-08-12 best-practice-scan (Slice F) — auto-trigger BPS as a
376
+ // post-step after peaks-prd's businessGoal artifact transitions to
377
+ // `handed-off` (the canonical "complete" state in the prd state machine).
378
+ // The trigger is fire-and-forget: failure NEVER blocks the transition.
374
379
  if (role === 'prd' && newState === 'handed-off' && options.sessionId !== undefined) {
375
380
  const { autoRegenPrdHandoff } = await import('../../services/prd/handoff-auto-regen.js');
376
381
  const regen = await autoRegenPrdHandoff({
@@ -379,17 +384,24 @@ export function registerRequestCommands(program, io) {
379
384
  requestId,
380
385
  role: 'prd'
381
386
  });
382
- if (regen.status === 'created') {
383
- // Stitch into the result envelope
384
- printResult(io, ok('request.transition', { ...result, handoffAutoRegen: { status: 'created', path: regen.path, sha256: regen.sha256 } }), options.json);
385
- return;
387
+ const bpsTrigger = await triggerBestPracticeScan({
388
+ projectRoot: options.project,
389
+ sessionId: result.sessionId,
390
+ requestId
391
+ });
392
+ const handoffAutoRegen = regen.status === 'created'
393
+ ? { status: 'created', path: regen.path, sha256: regen.sha256 }
394
+ : regen.status === 'skipped-exists'
395
+ ? { status: 'skipped-exists', path: regen.path }
396
+ : { status: 'failed', reason: regen.reason };
397
+ const notes = [];
398
+ if (regen.status === 'failed') {
399
+ notes.push(`prd handoff auto-regen failed: ${regen.reason}`);
386
400
  }
387
- if (regen.status === 'skipped-exists') {
388
- printResult(io, ok('request.transition', { ...result, handoffAutoRegen: { status: 'skipped-exists', path: regen.path } }), options.json);
389
- return;
401
+ if (bpsTrigger.status === 'failed') {
402
+ notes.push(`best-practice-scan auto-trigger failed: ${bpsTrigger.reason ?? 'unknown'}`);
390
403
  }
391
- // status === 'failed' — surface a warning but don't block the transition
392
- printResult(io, ok('request.transition', { ...result, handoffAutoRegen: { status: 'failed', reason: regen.reason } }, [`prd handoff auto-regen failed: ${regen.reason}`]), options.json);
404
+ printResult(io, ok('request.transition', { ...result, handoffAutoRegen, bestPracticeScanTrigger: bpsTrigger }, notes), options.json);
393
405
  return;
394
406
  }
395
407
  // Slice 2026-07-01-strategic-compact-cli: stitch the pre-compact
@@ -0,0 +1,7 @@
1
+ export type DetectedLanguage = 'typescript' | 'javascript' | 'python' | 'go' | 'java' | 'unknown';
2
+ export type LanguageDetection = {
3
+ readonly language: DetectedLanguage;
4
+ readonly confidence: number;
5
+ readonly signals: readonly string[];
6
+ };
7
+ export declare function detectLanguage(projectRoot: string): LanguageDetection;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Slice 2026-08-12 best-practice-scan — language detector.
3
+ *
4
+ * Detects the project's primary language from well-known marker files.
5
+ * Returns one of: typescript | javascript | python | go | java | unknown.
6
+ *
7
+ * Detection priority (first hit wins):
8
+ * 1. Java — `pom.xml` or `build.gradle` present
9
+ * 2. Go — `go.mod` present
10
+ * 3. Python — `requirements.txt` / `pyproject.toml` / `setup.py` present
11
+ * 4. Node — `package.json` present:
12
+ * a. `tsconfig.json` also present → typescript (high confidence)
13
+ * b. `package.json` `"type": "module"` + no tsconfig → javascript (medium confidence)
14
+ * c. default `package.json` (no tsconfig, no `"type"`) → javascript (lower confidence)
15
+ * 5. none of the above → `unknown`
16
+ *
17
+ * Signals are exposed in the result so the caller (orchestrator) can
18
+ * optionally surface the detected marker list to the user.
19
+ */
20
+ import { existsSync, readFileSync, statSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ const HIGH = 0.95;
23
+ const MEDIUM = 0.8;
24
+ const LOW = 0.6;
25
+ function hasFile(root, name) {
26
+ return existsSync(join(root, name));
27
+ }
28
+ function readPackageType(root) {
29
+ const pkgPath = join(root, 'package.json');
30
+ if (!existsSync(pkgPath))
31
+ return undefined;
32
+ try {
33
+ const raw = readFileSync(pkgPath, 'utf8');
34
+ const parsed = JSON.parse(raw);
35
+ if (parsed !== null && typeof parsed === 'object' && 'type' in parsed) {
36
+ const typeVal = parsed.type;
37
+ if (typeof typeVal === 'string')
38
+ return typeVal;
39
+ }
40
+ }
41
+ catch {
42
+ /* malformed package.json → treat as absent */
43
+ }
44
+ return undefined;
45
+ }
46
+ export function detectLanguage(projectRoot) {
47
+ if (!statSync(projectRoot, { throwIfNoEntry: false })) {
48
+ throw new Error(`detectLanguage: projectRoot does not exist: ${projectRoot}`);
49
+ }
50
+ if (hasFile(projectRoot, 'pom.xml') || hasFile(projectRoot, 'build.gradle')) {
51
+ const signals = [];
52
+ if (hasFile(projectRoot, 'pom.xml'))
53
+ signals.push('pom.xml');
54
+ if (hasFile(projectRoot, 'build.gradle'))
55
+ signals.push('build.gradle');
56
+ return { language: 'java', confidence: HIGH, signals };
57
+ }
58
+ if (hasFile(projectRoot, 'go.mod')) {
59
+ return { language: 'go', confidence: HIGH, signals: ['go.mod'] };
60
+ }
61
+ if (hasFile(projectRoot, 'requirements.txt') ||
62
+ hasFile(projectRoot, 'pyproject.toml') ||
63
+ hasFile(projectRoot, 'setup.py')) {
64
+ const signals = [];
65
+ if (hasFile(projectRoot, 'requirements.txt'))
66
+ signals.push('requirements.txt');
67
+ if (hasFile(projectRoot, 'pyproject.toml'))
68
+ signals.push('pyproject.toml');
69
+ if (hasFile(projectRoot, 'setup.py'))
70
+ signals.push('setup.py');
71
+ return { language: 'python', confidence: HIGH, signals };
72
+ }
73
+ if (hasFile(projectRoot, 'package.json')) {
74
+ if (hasFile(projectRoot, 'tsconfig.json')) {
75
+ return {
76
+ language: 'typescript',
77
+ confidence: HIGH,
78
+ signals: ['package.json', 'tsconfig.json']
79
+ };
80
+ }
81
+ const pkgType = readPackageType(projectRoot);
82
+ if (pkgType === 'module') {
83
+ return {
84
+ language: 'javascript',
85
+ confidence: MEDIUM,
86
+ signals: ['package.json', '"type": "module"']
87
+ };
88
+ }
89
+ return {
90
+ language: 'javascript',
91
+ confidence: LOW,
92
+ signals: ['package.json']
93
+ };
94
+ }
95
+ return { language: 'unknown', confidence: 0, signals: [] };
96
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Slice 2026-08-12 best-practice-scan — output formatter.
3
+ *
4
+ * Renders the 8-row business-decision comparison table (per spec §5) plus
5
+ * the 3-line footer (spec §5 footer + §7 ⚠️ gate). The formatter is the
6
+ * single source of truth for the artifact's text shape; the standalone
7
+ * template file at `src/templates/best-practice-scan.md` mirrors this
8
+ * shape for human reference.
9
+ *
10
+ * Hard rules enforced here (defensive scans):
11
+ * - Forbidden words: 会爆 / 头疼 / 黑暗 / 崩
12
+ * - Forbidden developer-perspective phrases: 代码 50 行 / 学习曲线 / 你的难
13
+ * - Forbidden binary framing: MVP vs 长期
14
+ * - Forbidden project-stage judgment: MVP 项目
15
+ *
16
+ * The `★` recommendation marker is placed on the column header whose
17
+ * `recommendation` value (A / B / C) matches `opts.recommendation`.
18
+ */
19
+ import type { DocFragment } from './scan-orchestrator.js';
20
+ export type RecommendationChoice = 'A' | 'B' | 'C';
21
+ export type FormatOptions = {
22
+ readonly intent: string;
23
+ readonly language: string;
24
+ readonly fragments: readonly DocFragment[];
25
+ readonly recommendation: RecommendationChoice;
26
+ readonly reasoning: string;
27
+ };
28
+ export declare function formatOutputTable(opts: FormatOptions): string;
29
+ /**
30
+ * Defensive scan: returns the list of forbidden-word / phrase hits
31
+ * present in the rendered output. The catch-gate test calls this to
32
+ * fail-loud if a future code change reintroduces a banned token.
33
+ */
34
+ export declare function findForbiddenTokens(rendered: string): readonly string[];
35
+ export declare const __TEST__: {
36
+ ROW_LABELS: readonly ["技术组合(通俗)", "peaks-code 估算", "user 看到", "user 操作", "业务影响(6 个月后)", "适用场景", "适用场景 (parallel)", "LLM 推荐"];
37
+ FORBIDDEN_WORDS: readonly ["会爆", "头疼", "黑暗", "崩"];
38
+ FORBIDDEN_PHRASES: readonly ["代码 50 行", "学习曲线", "你的难"];
39
+ FORBIDDEN_BINARY: readonly ["MVP vs 长期"];
40
+ FORBIDDEN_STAGE: readonly ["MVP 项目"];
41
+ };
@@ -0,0 +1,116 @@
1
+ const ROW_LABELS = [
2
+ '技术组合(通俗)',
3
+ 'peaks-code 估算',
4
+ 'user 看到',
5
+ 'user 操作',
6
+ '业务影响(6 个月后)',
7
+ '适用场景',
8
+ '适用场景 (parallel)',
9
+ 'LLM 推荐'
10
+ ];
11
+ const FORBIDDEN_WORDS = ['会爆', '头疼', '黑暗', '崩'];
12
+ const FORBIDDEN_PHRASES = ['代码 50 行', '学习曲线', '你的难'];
13
+ const FORBIDDEN_BINARY = ['MVP vs 长期'];
14
+ const FORBIDDEN_STAGE = ['MVP 项目'];
15
+ function rowLabel(row) {
16
+ return `| **${row}** | ... | ... | ... |`;
17
+ }
18
+ function recommendedColumnHeader(label, isRecommended) {
19
+ return isRecommended ? `**${label} ★**` : label;
20
+ }
21
+ export function formatOutputTable(opts) {
22
+ const cols = [
23
+ {
24
+ choice: 'A',
25
+ label: recommendedColumnHeader('方案 A', opts.recommendation === 'A'),
26
+ body: '面向长期维护,组件复用度高,适合多人协作。'
27
+ },
28
+ {
29
+ choice: 'B',
30
+ label: recommendedColumnHeader('方案 B', opts.recommendation === 'B'),
31
+ body: '中等复杂度,上手快,适合迭代中的小团队。'
32
+ },
33
+ {
34
+ choice: 'C',
35
+ label: recommendedColumnHeader('方案 C', opts.recommendation === 'C'),
36
+ body: '极简实现,适合一次性脚本或概念验证。'
37
+ }
38
+ ];
39
+ const colA = cols[0];
40
+ const colB = cols[1];
41
+ const colC = cols[2];
42
+ if (colA === undefined || colB === undefined || colC === undefined) {
43
+ throw new Error('formatOutputTable: column triple must be present');
44
+ }
45
+ const head = `| ${ROW_LABELS[0]} | ${colA.label} | ${colB.label} | ${colC.label} |`;
46
+ const sep = '| --- | --- | --- | --- |';
47
+ const bodyRows = [];
48
+ for (let i = 0; i < ROW_LABELS.length; i += 1) {
49
+ const label = ROW_LABELS[i];
50
+ if (label === undefined)
51
+ continue;
52
+ if (label === 'LLM 推荐') {
53
+ const recCell = cols
54
+ .map((c) => (c.choice === opts.recommendation ? `**${c.choice}**` : c.choice))
55
+ .join(' / ');
56
+ bodyRows.push(`| **${label}** | ${recCell} | ${recCell} | ${recCell} |`);
57
+ continue;
58
+ }
59
+ if (label === '适用场景 (parallel)') {
60
+ bodyRows.push(`| **${label}** | < 5 字段 + < 2 月就用掉 | 字段会增长 + 1 年+ 持续维护 | 灵活配置 + 长期演进 |`);
61
+ continue;
62
+ }
63
+ bodyRows.push(rowLabel(label));
64
+ }
65
+ const reasoningBlock = `> **LLM 推理:** ${opts.reasoning}`;
66
+ const footer = [
67
+ '↩ 默认走方案 ' + opts.recommendation + ';觉得估错 / 推荐不对就告诉我。',
68
+ '⚠️ 任何跟你真实业务不一样,改 — LLM 推荐可能错。',
69
+ '📝 备注:user 体验在 2/3 个方案里几乎一致,差别在 6 个月后的维护成本和你的项目长期节奏。'
70
+ ].join('\n');
71
+ const meta = [
72
+ `language: ${opts.language}`,
73
+ `intent: ${opts.intent}`,
74
+ `fragments: ${opts.fragments.length}`,
75
+ `recommendation: ${opts.recommendation}`
76
+ ].join(' | ');
77
+ return [
78
+ `# Best-Practice Scan — ${opts.intent}`,
79
+ '',
80
+ `> ${meta}`,
81
+ '',
82
+ reasoningBlock,
83
+ '',
84
+ head,
85
+ sep,
86
+ ...bodyRows,
87
+ '',
88
+ footer
89
+ ].join('\n');
90
+ }
91
+ /**
92
+ * Defensive scan: returns the list of forbidden-word / phrase hits
93
+ * present in the rendered output. The catch-gate test calls this to
94
+ * fail-loud if a future code change reintroduces a banned token.
95
+ */
96
+ export function findForbiddenTokens(rendered) {
97
+ const hits = [];
98
+ for (const word of FORBIDDEN_WORDS) {
99
+ if (rendered.includes(word))
100
+ hits.push(word);
101
+ }
102
+ for (const phrase of FORBIDDEN_PHRASES) {
103
+ if (rendered.includes(phrase))
104
+ hits.push(phrase);
105
+ }
106
+ for (const binary of FORBIDDEN_BINARY) {
107
+ if (rendered.includes(binary))
108
+ hits.push(binary);
109
+ }
110
+ for (const stage of FORBIDDEN_STAGE) {
111
+ if (rendered.includes(stage))
112
+ hits.push(stage);
113
+ }
114
+ return hits;
115
+ }
116
+ export const __TEST__ = { ROW_LABELS, FORBIDDEN_WORDS, FORBIDDEN_PHRASES, FORBIDDEN_BINARY, FORBIDDEN_STAGE };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Slice 2026-08-12 best-practice-scan — scan orchestrator.
3
+ *
4
+ * Resolves a doc-fragment set for a (intent, language) query via the
5
+ * following priority chain:
6
+ * 1. Context7 MCP (priority 1; 30 s timeout)
7
+ * 2. WebSearch (priority 2 fallback; 200 ms simulated delay)
8
+ * 3. Empty (both sources exhausted)
9
+ *
10
+ * The Context7 + WebSearch calls are STUB implementations for v1:
11
+ * they sleep for a fixed delay and return synthetic doc fragments so
12
+ * the orchestrator's source-priority logic is testable end-to-end.
13
+ * Real MCP wiring is a future slice — the stubs are clearly marked.
14
+ *
15
+ * The orchestrator emits structured log lines via the injected `io`
16
+ * (stdout for progress, stderr for warnings) so a CLI caller can see
17
+ * which fallback path was taken.
18
+ */
19
+ import type { ProgramIO } from '../../cli/cli-helpers.js';
20
+ export type DocFragment = {
21
+ readonly title: string;
22
+ readonly url: string;
23
+ readonly snippet: string;
24
+ };
25
+ export type ScanSource = 'context7' | 'websearch' | 'fallback';
26
+ export type ScanResult = {
27
+ readonly results: readonly DocFragment[];
28
+ readonly fragments: readonly DocFragment[];
29
+ readonly source: ScanSource;
30
+ readonly elapsedMs: number;
31
+ };
32
+ export type ScanOptions = {
33
+ readonly intent: string;
34
+ readonly language: string;
35
+ readonly projectRoot: string;
36
+ readonly io: ProgramIO;
37
+ readonly context7TimeoutMs?: number;
38
+ };
39
+ export declare function scanBestPractice(opts: ScanOptions): Promise<ScanResult>;
@@ -0,0 +1,85 @@
1
+ const DEFAULT_CONTEXT7_TIMEOUT_MS = 30_000;
2
+ const DEFAULT_CONTEXT7_DELAY_MS = 100;
3
+ const DEFAULT_WEBSEARCH_DELAY_MS = 200;
4
+ const defaultContext7Lookup = async (intent, language) => {
5
+ // TODO: real MCP integration — call `@upstash/context7-mcp`'s
6
+ // resolve-library-id + query-docs and translate errors into the
7
+ // shared `{ ok, results }` envelope.
8
+ await new Promise((resolveFn) => setTimeout(resolveFn, DEFAULT_CONTEXT7_DELAY_MS));
9
+ return {
10
+ ok: true,
11
+ results: [
12
+ {
13
+ title: `Context7 ${language} doc for "${intent}"`,
14
+ url: `https://context7.com/${language}/${encodeURIComponent(intent)}`,
15
+ snippet: `best practice for ${intent} in ${language} (context7 stub)`
16
+ }
17
+ ]
18
+ };
19
+ };
20
+ const defaultWebSearchLookup = async (intent, language) => {
21
+ // TODO: real web search wiring — use Context7 WebSearch tool or
22
+ // WebFetch + ranking; current stub returns a synthetic fragment.
23
+ await new Promise((resolveFn) => setTimeout(resolveFn, DEFAULT_WEBSEARCH_DELAY_MS));
24
+ return {
25
+ ok: true,
26
+ results: [
27
+ {
28
+ title: `WebSearch ${language} case for "${intent}"`,
29
+ url: `https://example.com/search?q=${encodeURIComponent(intent)}&lang=${language}`,
30
+ snippet: `implementation example for ${intent} in ${language} (websearch stub)`
31
+ }
32
+ ]
33
+ };
34
+ };
35
+ export async function scanBestPractice(opts) {
36
+ const timeoutMs = opts.context7TimeoutMs ?? DEFAULT_CONTEXT7_TIMEOUT_MS;
37
+ const startedAt = Date.now();
38
+ opts.io.stdout(`[scan-orchestrator] querying context7 for "${opts.intent}" (${opts.language})`);
39
+ let context7Outcome = null;
40
+ let context7Error = null;
41
+ try {
42
+ const ctxPromise = defaultContext7Lookup(opts.intent, opts.language);
43
+ const ctxTimer = new Promise((_, reject) => {
44
+ setTimeout(() => reject(new Error(`context7 timeout after ${timeoutMs}ms`)), timeoutMs).unref();
45
+ });
46
+ context7Outcome = await Promise.race([ctxPromise, ctxTimer]);
47
+ }
48
+ catch (err) {
49
+ context7Error = err instanceof Error ? err.message : String(err);
50
+ opts.io.stderr(`[scan-orchestrator] context7 failed: ${context7Error}`);
51
+ }
52
+ if (context7Outcome !== null && context7Outcome.ok && context7Outcome.results.length > 0) {
53
+ return {
54
+ results: context7Outcome.results,
55
+ fragments: context7Outcome.results,
56
+ source: 'context7',
57
+ elapsedMs: Date.now() - startedAt
58
+ };
59
+ }
60
+ opts.io.stdout(`[scan-orchestrator] falling back to websearch for "${opts.intent}" (${opts.language})`);
61
+ let webOutcome = null;
62
+ let webError = null;
63
+ try {
64
+ webOutcome = await defaultWebSearchLookup(opts.intent, opts.language);
65
+ }
66
+ catch (err) {
67
+ webError = err instanceof Error ? err.message : String(err);
68
+ opts.io.stderr(`[scan-orchestrator] websearch failed: ${webError}`);
69
+ }
70
+ if (webOutcome !== null && webOutcome.ok && webOutcome.results.length > 0) {
71
+ return {
72
+ results: webOutcome.results,
73
+ fragments: webOutcome.results,
74
+ source: 'websearch',
75
+ elapsedMs: Date.now() - startedAt
76
+ };
77
+ }
78
+ opts.io.stderr('[scan-orchestrator] both sources empty; returning empty fallback');
79
+ return {
80
+ results: [],
81
+ fragments: [],
82
+ source: 'fallback',
83
+ elapsedMs: Date.now() - startedAt
84
+ };
85
+ }