peaks-cli 1.2.8 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +12 -0
  2. package/dist/src/cli/commands/project-commands.js +1 -1
  3. package/dist/src/cli/commands/scan-commands.js +22 -0
  4. package/dist/src/cli/commands/workspace-commands.js +59 -1
  5. package/dist/src/services/memory/project-memory-service.d.ts +1 -1
  6. package/dist/src/services/memory/project-memory-service.js +52 -23
  7. package/dist/src/services/sc/sc-service.d.ts +52 -1
  8. package/dist/src/services/sc/sc-service.js +266 -17
  9. package/dist/src/services/scan/libraries-service.d.ts +24 -0
  10. package/dist/src/services/scan/libraries-service.js +419 -0
  11. package/dist/src/services/scan/libraries-types.d.ts +59 -0
  12. package/dist/src/services/scan/libraries-types.js +9 -0
  13. package/dist/src/services/session/session-manager.d.ts +7 -5
  14. package/dist/src/services/session/session-manager.js +48 -14
  15. package/dist/src/services/skills/skill-presence-service.js +102 -68
  16. package/dist/src/services/skills/skill-runbook-service.js +36 -2
  17. package/dist/src/services/skills/skill-statusline-service.js +13 -7
  18. package/dist/src/services/workflow/autonomous-resume-writer.js +7 -7
  19. package/dist/src/services/workspace/reconcile-service.d.ts +119 -0
  20. package/dist/src/services/workspace/reconcile-service.js +464 -0
  21. package/dist/src/services/workspace/reconcile-types.d.ts +93 -0
  22. package/dist/src/services/workspace/reconcile-types.js +13 -0
  23. package/dist/src/shared/change-id.d.ts +30 -0
  24. package/dist/src/shared/change-id.js +40 -6
  25. package/dist/src/shared/paths.d.ts +1 -1
  26. package/dist/src/shared/paths.js +2 -1
  27. package/dist/src/shared/version.d.ts +1 -1
  28. package/dist/src/shared/version.js +1 -1
  29. package/package.json +4 -1
  30. package/schemas/library-breaking-changes.data.json +141 -0
  31. package/schemas/library-breaking-changes.meta.json +6 -0
  32. package/schemas/library-breaking-changes.schema.json +50 -0
  33. package/skills/peaks-qa/SKILL.md +12 -0
  34. package/skills/peaks-rd/SKILL.md +145 -2
  35. package/skills/peaks-solo/SKILL.md +93 -319
  36. package/skills/peaks-solo/references/runbook.md +168 -0
  37. package/skills/peaks-solo/references/workflow-gates-and-types.md +177 -0
  38. package/skills/peaks-solo-resume/SKILL.md +81 -0
  39. package/skills/peaks-solo-status/SKILL.md +120 -0
  40. package/skills/peaks-solo-test/SKILL.md +84 -0
  41. package/skills/peaks-txt/SKILL.md +8 -5
package/README.md CHANGED
@@ -13,6 +13,18 @@ npm install -g peaks-cli
13
13
 
14
14
  安装后,Peaks 会把内置的 8 个 `peaks-*` 技能注册到 Claude Code,会话里直接通过技能名调用即可。
15
15
 
16
+ ## 本地开发(从源码跑 CLI)
17
+
18
+ 仓库自带 `peaks` CLI 源码。开发模式用 `tsx` 直接跑 `src/cli/index.ts`,所以**首次克隆后 `node_modules/` 里不会有 `chalk` / `ora` / `terminal-kit` 等运行时依赖**——直接 `tsx src/cli/index.ts` 会报 `ERR_MODULE_NOT_FOUND: chalk`。先执行一次 `pnpm install` 把依赖补齐,再验证:
19
+
20
+ ```bash
21
+ pnpm install
22
+ pnpm exec tsx src/cli/index.ts --version # 应打印 1.2.9
23
+ pnpm exec tsx src/cli/index.ts <cmd> # 与全局 `peaks <cmd>` 行为一致
24
+ ```
25
+
26
+ 热重载开发循环可用 `pnpm dev:watch`。
27
+
16
28
  ## 5 分钟上手
17
29
 
18
30
  在 Claude Code 对话里,**直接对 Claude 说「用 X 技能做 Y」** 即可,技能会接管剩下的所有流程:
@@ -123,7 +123,7 @@ export function registerProjectCommands(program, io) {
123
123
  .command('memories')
124
124
  .description('Read durable project memories (decisions, conventions, modules, rules) from .peaks/memory for LLM consumption')
125
125
  .requiredOption('--project <path>', 'target project root')
126
- .option('--kind <kind>', 'filter by kind: project, rule, decision, reference, feedback, convention, module')).action((options) => {
126
+ .option('--kind <kind>', 'filter by kind: project, rule, decision, reference, feedback, convention, module, lesson')).action((options) => {
127
127
  try {
128
128
  const result = readProjectMemories(options.project);
129
129
  if (options.kind) {
@@ -5,6 +5,7 @@ import { checkTypeSanity } from '../../services/scan/type-sanity-service.js';
5
5
  import { getAcceptanceCoverage, isAcceptanceCoverageError } from '../../services/scan/acceptance-coverage-service.js';
6
6
  import { getDiffVsScope, isDiffScopeError } from '../../services/scan/diff-scope-service.js';
7
7
  import { scanFileSize, DEFAULT_FILE_SIZE_THRESHOLD } from '../../services/scan/file-size-scan.js';
8
+ import { scanLibraries } from '../../services/scan/libraries-service.js';
8
9
  import { isRequestType, VALID_REQUEST_TYPES } from '../../services/artifacts/artifact-prerequisites.js';
9
10
  import { fail, ok } from '../../shared/result.js';
10
11
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
@@ -221,4 +222,25 @@ export function registerScanCommands(program, io) {
221
222
  process.exitCode = 1;
222
223
  }
223
224
  });
225
+ addJsonOption(scan
226
+ .command('libraries')
227
+ .description('Enumerate every dependency + devDependency + peerDependency + optionalDependency in package.json with parsed major version (read-only). Output goes to ## Library versions in rd/project-scan.md.')
228
+ .requiredOption('--project <path>', 'target project root')).action(async (options) => {
229
+ try {
230
+ const report = await scanLibraries({ projectRoot: options.project });
231
+ const nextActions = [];
232
+ if (report.libraries.length === 0) {
233
+ nextActions.push('No dependencies found — verify package.json exists and is valid JSON.');
234
+ }
235
+ else {
236
+ nextActions.push('Paste the report under `## Library versions` in .peaks/<sid>/rd/project-scan.md.');
237
+ nextActions.push('peaks-rd preflight will cross-check diff imports against schemas/library-breaking-changes.data.json.');
238
+ }
239
+ printResult(io, ok('scan.libraries', report, [], nextActions), options.json);
240
+ }
241
+ catch (error) {
242
+ printResult(io, fail('scan.libraries', 'SCAN_LIBRARIES_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and is readable']), options.json);
243
+ process.exitCode = 1;
244
+ }
245
+ });
224
246
  }
@@ -1,8 +1,11 @@
1
1
  import { initWorkspace, InvalidSessionIdError, ConflictingSessionError } from '../../services/workspace/workspace-service.js';
2
+ import { reconcileWorkspace } from '../../services/workspace/reconcile-service.js';
2
3
  import { ensureSession } from '../../services/session/session-manager.js';
3
4
  import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
4
5
  import { fail, ok } from '../../shared/result.js';
5
6
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
7
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
8
+ const DEFAULT_RECONCILE_AGE_DAYS = 7;
6
9
  export function registerWorkspaceCommands(program, io) {
7
10
  const workspace = program.command('workspace').description('Manage the Peaks per-session artifact workspace (.peaks/<session-id>/)');
8
11
  addJsonOption(workspace
@@ -18,7 +21,7 @@ export function registerWorkspaceCommands(program, io) {
18
21
  // session, unless --allow-session-rebind is set)
19
22
  // - omitted: defer to ensureSession(), which reuses an existing
20
23
  // binding or auto-generates a fresh one. The init then writes
21
- // .session.json so the binding sticks.
24
+ // .peaks/_runtime/session.json so the binding sticks.
22
25
  //
23
26
  // Before that: canonicalise the project root. If the user (or the
24
27
  // LLM via "$(pwd)") passed a sub-directory of a real git repo
@@ -75,4 +78,59 @@ export function registerWorkspaceCommands(program, io) {
75
78
  process.exitCode = 1;
76
79
  }
77
80
  });
81
+ addJsonOption(workspace
82
+ .command('reconcile')
83
+ .description('Scan .peaks/2026-MM-DD-session-*/ directories and re-point .peaks/_runtime/session.json ' +
84
+ 'to the canonical session (4-tier heuristic: active-skill binding -> latest session.json mtime -> ' +
85
+ 'latest any-file mtime -> dir-name sort). Also migrates any legacy .peaks/.session.json / ' +
86
+ '.peaks/.active-skill.json / .peaks/sop-state/ into .peaks/_runtime/ (idempotent; no-op on a ' +
87
+ 'tree that is already on the new layout). By default the command is a dry-run: it reports empty / abandoned ' +
88
+ `session dirs older than ${DEFAULT_RECONCILE_AGE_DAYS} days as deletion candidates but does not delete them. ` +
89
+ 'Pass --apply to actually remove the listed candidate dirs (destructive). ' +
90
+ 'Override the age threshold with --older-than <days>.')
91
+ .requiredOption('--project <path>', 'target project root')
92
+ .option('--apply', 'actually delete the deletion candidates (destructive); without it, dry-run only', false)
93
+ .option('--older-than <days>', `age threshold in days for deletion candidates (default: ${DEFAULT_RECONCILE_AGE_DAYS})`, (value) => Number.parseFloat(value))).action((options) => {
94
+ try {
95
+ const projectRoot = resolveCanonicalProjectRoot(options.project);
96
+ const olderThanDays = options.olderThan ?? DEFAULT_RECONCILE_AGE_DAYS;
97
+ if (typeof olderThanDays !== 'number' || !Number.isFinite(olderThanDays) || olderThanDays <= 0) {
98
+ printResult(io, fail('workspace.reconcile', 'INVALID_AGE_THRESHOLD', `--older-than must be a positive number of days`, { provided: options.olderThan }, ['Use --older-than 7 (or omit it to accept the 7-day default)']), options.json);
99
+ process.exitCode = 1;
100
+ return;
101
+ }
102
+ const olderThanMs = olderThanDays * MS_PER_DAY;
103
+ const apply = options.apply === true;
104
+ const result = reconcileWorkspace({
105
+ projectRoot,
106
+ apply,
107
+ olderThanMs
108
+ });
109
+ const warnings = [];
110
+ if (result.sessions.length === 0) {
111
+ warnings.push('No session directories found under .peaks/. Run peaks workspace init first.');
112
+ }
113
+ if (apply && result.deleted.length > 0) {
114
+ warnings.push(`Deleted ${result.deleted.length} session dir(s) older than ${olderThanDays} day(s).`);
115
+ }
116
+ const nextActions = [];
117
+ if (result.migratedFiles.length > 0) {
118
+ nextActions.push(`Migrated ${result.migratedFiles.length} legacy runtime file(s) into .peaks/_runtime/: ${result.migratedFiles.join(', ')}.`);
119
+ }
120
+ if (result.repointed) {
121
+ nextActions.push(`Re-pointed .peaks/_runtime/session.json from ${result.repointedFrom ?? '<unbound>'} to ${result.repointedTo}.`);
122
+ }
123
+ if (!apply && result.wouldDelete.length > 0) {
124
+ nextActions.push(`Re-run with --apply to delete ${result.wouldDelete.length} candidate dir(s).`);
125
+ }
126
+ printResult(io, ok('workspace.reconcile', result, warnings, nextActions), options.json);
127
+ if (result.errors.length > 0) {
128
+ process.exitCode = 1;
129
+ }
130
+ }
131
+ catch (error) {
132
+ printResult(io, fail('workspace.reconcile', 'WORKSPACE_RECONCILE_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and is writable']), options.json);
133
+ process.exitCode = 1;
134
+ }
135
+ });
78
136
  }
@@ -1,4 +1,4 @@
1
- export type ProjectMemoryKind = 'project' | 'rule' | 'decision' | 'reference' | 'feedback' | 'convention' | 'module';
1
+ export type ProjectMemoryKind = 'project' | 'rule' | 'decision' | 'reference' | 'feedback' | 'convention' | 'module' | 'lesson';
2
2
  export type ExtractedProjectMemory = {
3
3
  title: string;
4
4
  kind: ProjectMemoryKind;
@@ -3,13 +3,20 @@ import { dirname, basename, isAbsolute, join, relative, resolve } from 'node:pat
3
3
  import { isInsidePath, isWindowsAbsolutePath, normalizePath, resolveInputPath, stablePath, stableRealPath } from '../../shared/path-utils.js';
4
4
  import { containsSensitiveConfigValue, isSensitiveConfigPath } from '../config/config-service.js';
5
5
  // Hot kinds: full body kept in index for always-available context
6
- const HOT_KINDS = new Set(['feedback', 'decision', 'rule', 'convention', 'module']);
6
+ const HOT_KINDS = new Set(['feedback', 'decision', 'rule', 'convention', 'module', 'lesson']);
7
7
  // ---------------------------------------------------------------------------
8
8
  // Internal helpers (kept from original, sorted by dependency order)
9
9
  // ---------------------------------------------------------------------------
10
10
  const START_MARKER = '<!-- peaks-memory:start -->';
11
11
  const END_MARKER = '<!-- peaks-memory:end -->';
12
- const VALID_MEMORY_KINDS = new Set(['project', 'rule', 'decision', 'reference', 'feedback', 'convention', 'module']);
12
+ const VALID_MEMORY_KINDS = new Set(['project', 'rule', 'decision', 'reference', 'feedback', 'convention', 'module', 'lesson']);
13
+ // Length bounds for index entry descriptions. The numbers were chosen when
14
+ // summarizeMemoryBody was first introduced; locking them in as named
15
+ // constants is a doc-as-code move so the truncation rule is no longer
16
+ // "magic". Bump MAX_DESCRIPTION_LENGTH deliberately if downstream UIs grow.
17
+ const MIN_BODY_SENTENCE_LENGTH = 20; // skip fragments shorter than this when picking a leading sentence
18
+ const MAX_DESCRIPTION_LENGTH = 120; // hard cap on description length in the memory index entry
19
+ const ELLIPSIS_RESERVE = 3; // length of the trailing "..." when truncating with an ellipsis
13
20
  function normalizeRoot(path) {
14
21
  return resolveInputPath(path);
15
22
  }
@@ -214,15 +221,15 @@ function summarizeMemoryBody(body) {
214
221
  .replace(/^\s*[-*+]\s+/gm, '')
215
222
  .replace(/\n+/g, ' ')
216
223
  .trim();
217
- const sentences = cleaned.split(/(?<=[.!?])\s+/).filter((s) => s.length > 20 && !/^\[.+\]$/.test(s));
224
+ const sentences = cleaned.split(/(?<=[.!?])\s+/).filter((s) => s.length > MIN_BODY_SENTENCE_LENGTH && !/^\[.+\]$/.test(s));
218
225
  if (sentences.length === 0) {
219
- return cleaned.slice(0, 120) || 'Project memory';
226
+ return cleaned.slice(0, MAX_DESCRIPTION_LENGTH) || 'Project memory';
220
227
  }
221
228
  const first = sentences[0];
222
- if (first.length <= 120) {
229
+ if (first.length <= MAX_DESCRIPTION_LENGTH) {
223
230
  return first;
224
231
  }
225
- return first.slice(0, 117) + '...';
232
+ return first.slice(0, MAX_DESCRIPTION_LENGTH - ELLIPSIS_RESERVE) + '...';
226
233
  }
227
234
  // ---------------------------------------------------------------------------
228
235
  // Session memory extraction (new extract path)
@@ -296,7 +303,7 @@ function readStoredMemoryNames(memoryDir) {
296
303
  function generateMemoryIndexFile(projectRoot, memoryDir, indexPath) {
297
304
  const memories = readProjectMemories(projectRoot);
298
305
  const hot = {
299
- feedback: [], decision: [], rule: [], convention: [], module: []
306
+ feedback: [], decision: [], rule: [], convention: [], module: [], lesson: []
300
307
  };
301
308
  const warm = {
302
309
  project: [], reference: []
@@ -350,29 +357,49 @@ function readExistingIndex(indexPath) {
350
357
  return null;
351
358
  }
352
359
  }
360
+ // Decide whether readMemoryIndex should rebuild the on-disk index.json.
361
+ // The rule is: rebuild iff index.json is missing OR any memory.md has an
362
+ // mtime strictly greater than index.json's mtime. Any statSync failure
363
+ // falls back to "rebuild" — a safe default that matches the prior
364
+ // always-rebuild behaviour and avoids serving a stale index from a
365
+ // partially-corrupt dir.
366
+ function shouldRegenerateIndex(indexPath, memoryFiles) {
367
+ let indexMtimeMs = 0;
368
+ try {
369
+ indexMtimeMs = statSync(indexPath).mtimeMs;
370
+ }
371
+ catch {
372
+ return true; // no index → must regenerate
373
+ }
374
+ for (const memoryPath of memoryFiles) {
375
+ try {
376
+ const memoryMtimeMs = statSync(memoryPath).mtimeMs;
377
+ if (memoryMtimeMs > indexMtimeMs)
378
+ return true;
379
+ }
380
+ catch {
381
+ return true; // unreadable file → safe default is regenerate
382
+ }
383
+ }
384
+ return false;
385
+ }
353
386
  export function readMemoryIndex(projectRoot) {
354
387
  const normalizedRoot = normalizeRoot(projectRoot);
355
388
  const memoryDir = assertSafeProjectMemoryDir(normalizedRoot);
356
389
  const indexPath = join(memoryDir, 'index.json');
357
- // Read-side bootstrap: if the memory dir is missing entirely, build a full
358
- // empty index so downstream readers always see a well-formed result. We
359
- // also fall through if the dir is present but the index is missing — the
360
- // user may have nuked the index file, or never had one because no
361
- // memory has ever been extracted in this project.
390
+ // Read-side bootstrap: if the memory dir is missing entirely, build it and
391
+ // return whatever index is on disk (likely null on a fresh project). We
392
+ // deliberately do NOT pre-write an empty index here: the mtime-based
393
+ // regeneration guard below is the sole authority on whether index.json
394
+ // gets materialised, and pre-writing an empty index would race the guard
395
+ // (giving it a current-time mtime that defeats "memory older than index"
396
+ // detection on the first read).
362
397
  if (!existsSync(memoryDir)) {
363
398
  ensureMemoryBootstrap(normalizedRoot);
364
399
  return readExistingIndex(indexPath);
365
400
  }
366
- if (!existsSync(indexPath)) {
367
- try {
368
- writeFileSync(indexPath, renderEmptyIndex(), { mode: 0o644 });
369
- }
370
- catch {
371
- // fall through — readExistingIndex will return null
372
- }
373
- }
374
401
  const files = listMarkdownFiles(memoryDir);
375
- if (files.length > 0) {
402
+ if (files.length > 0 && shouldRegenerateIndex(indexPath, files)) {
376
403
  try {
377
404
  generateMemoryIndexFile(normalizedRoot, memoryDir, indexPath);
378
405
  }
@@ -659,7 +686,8 @@ function emptyByKind() {
659
686
  reference: [],
660
687
  feedback: [],
661
688
  convention: [],
662
- module: []
689
+ module: [],
690
+ lesson: []
663
691
  };
664
692
  }
665
693
  function emptyIndex() {
@@ -676,7 +704,8 @@ function emptyIndex() {
676
704
  decision: [],
677
705
  rule: [],
678
706
  convention: [],
679
- module: []
707
+ module: [],
708
+ lesson: []
680
709
  },
681
710
  warm: {
682
711
  project: [],
@@ -51,6 +51,52 @@ export type CommitBoundary = {
51
51
  syncState: 'synced' | 'pending' | 'failed';
52
52
  rollbackPoint: string | null;
53
53
  };
54
+ /**
55
+ * Resolution sources for `resolveArtifactSession`, in priority order.
56
+ * - `active-skill`: the orchestrator's active-skill marker
57
+ * (`.peaks/_runtime/active-skill.json`, with a one-minor-release
58
+ * fallback to `.peaks/.active-skill.json`) `sessionId` points to a
59
+ * session dir that owns the slice's marker artifact.
60
+ * - `session-json`: the workspace binding in
61
+ * `.peaks/_runtime/session.json` (with back-compat fallback to
62
+ * `.peaks/.session.json`) points to a session dir that owns the
63
+ * slice's marker artifact (active-skill was checked but did not
64
+ * own it; session-json is the next source).
65
+ * - `find-fallback`: neither binding owned the artifact, but a `find`
66
+ * walk under `.peaks/` located a session dir that does own it.
67
+ */
68
+ export type ArtifactSessionSource = 'active-skill' | 'session-json' | 'find-fallback';
69
+ export type ResolvedArtifactSession = {
70
+ resolvedSessionId: string | null;
71
+ candidateSources: ArtifactSessionSource[];
72
+ };
73
+ /**
74
+ * Resolve the session id that owns the slice's artifacts using a 3-tier
75
+ * precedence:
76
+ *
77
+ * 1. `.peaks/_runtime/active-skill.json` `sessionId` (with back-compat
78
+ * fallback to `.peaks/.active-skill.json`) if it points to a real
79
+ * session that owns the slice.
80
+ * 2. `.peaks/_runtime/session.json` `sessionId` (with back-compat
81
+ * fallback to `.peaks/.session.json`) if it points to a real
82
+ * session that owns the slice.
83
+ * 3. `find .peaks/ -name '<marker>'` — the first session dir under
84
+ * `.peaks/` that owns the slice.
85
+ * 4. else `{ resolvedSessionId: null, candidateSources: [] }`.
86
+ *
87
+ * The back-compat fallbacks are tolerated for one minor release so
88
+ * users with pre-migration trees (or running an older CLI version)
89
+ * still get a clean resolution. After the migration (or after v1.3.0
90
+ * is installed and `peaks workspace reconcile` has been run), only
91
+ * the new paths exist and the fallbacks never fire.
92
+ *
93
+ * `candidateSources` reports which sources were checked before the
94
+ * resolver found (or did not find) a winner; the list is in the order
95
+ * the resolver consulted them. This makes the precedence observable in
96
+ * the JSON envelope so a human reviewer can see "active-skill was empty
97
+ * AND session-json was empty, so find-fallback won".
98
+ */
99
+ export declare function resolveArtifactSession(projectRoot: string, sliceId: string): ResolvedArtifactSession;
54
100
  export declare function getChangeTraceabilityStatus(): ChangeTraceabilityStatus;
55
101
  export declare function createChangeImpact(options: {
56
102
  changeId: string;
@@ -71,10 +117,15 @@ export declare function recordCommitBoundary(options: {
71
117
  sliceId: string;
72
118
  artifacts?: string[];
73
119
  codeFiles?: string[];
74
- }): CommitBoundary;
120
+ }): CommitBoundary & {
121
+ resolvedSessionId: string | null;
122
+ candidateSources: ArtifactSessionSource[];
123
+ };
75
124
  export declare function validateArtifactRetention(sliceId: string): {
76
125
  valid: boolean;
77
126
  missingArtifacts: string[];
78
127
  warnings: string[];
128
+ resolvedSessionId: string | null;
129
+ candidateSources: ArtifactSessionSource[];
79
130
  };
80
131
  export declare function getScHelpText(): string[];